Skip to main content

Overview

Kuzenbo uses Tailwind Variants for type-safe variant composition and provides utilities for class merging and Base UI integration.

Installation

npm install tailwind-variants tailwind-merge

Tailwind Variants

Kuzenbo components use tv() for variant-based styling with full TypeScript support.

Basic Usage

import { tv } from "tailwind-variants";
import type { VariantProps } from "tailwind-variants";

const buttonVariants = tv({
  base: "inline-flex items-center justify-center rounded-[var(--kb-radius)]",
  variants: {
    variant: {
      default: "bg-primary text-primary-foreground",
      outline: "border border-border bg-background",
      ghost: "hover:bg-accent hover:text-accent-foreground",
    },
    size: {
      sm: "h-8 px-3 text-xs",
      md: "h-9 px-4 text-sm",
      lg: "h-10 px-5 text-base",
    },
  },
  defaultVariants: {
    variant: "default",
    size: "md",
  },
});

type ButtonVariants = VariantProps<typeof buttonVariants>;

function Button({ variant, size, className }: ButtonVariants & { className?: string }) {
  return (
    <button className={buttonVariants({ variant, size, className })}>
      Click me
    </button>
  );
}

Slots (Multi-part Components)

For components with multiple styled parts:
import { tv } from "tailwind-variants";

const cardVariants = tv({
  slots: {
    root: "rounded-lg border bg-card text-card-foreground",
    header: "flex flex-col space-y-1.5 p-6",
    title: "text-2xl font-semibold leading-none tracking-tight",
    content: "p-6 pt-0",
    footer: "flex items-center p-6 pt-0",
  },
  variants: {
    size: {
      sm: {
        header: "p-3",
        content: "p-3 pt-0",
        footer: "p-3 pt-0",
      },
      lg: {
        header: "p-8",
        content: "p-8 pt-0",
        footer: "p-8 pt-0",
      },
    },
  },
});

const { root, header, title, content, footer } = cardVariants({ size: "lg" });

function Card({ size }: { size?: "sm" | "lg" }) {
  const slots = cardVariants({ size });
  
  return (
    <div className={slots.root()}>
      <div className={slots.header()}>
        <h3 className={slots.title()}>Card Title</h3>
      </div>
      <div className={slots.content()}>Card content</div>
      <div className={slots.footer()}>Card footer</div>
    </div>
  );
}

Compound Variants

Combine multiple variant states:
import { tv } from "tailwind-variants";

const buttonVariants = tv({
  base: "inline-flex items-center",
  variants: {
    variant: {
      default: "bg-primary",
      destructive: "bg-danger",
    },
    size: {
      sm: "h-8 px-3",
      lg: "h-10 px-5",
    },
  },
  compoundVariants: [
    {
      variant: "destructive",
      size: "lg",
      className: "font-bold uppercase",
    },
  ],
});

Extending Variants

Reuse and extend existing variant definitions:
import { tv } from "tailwind-variants";

const baseButtonVariants = tv({
  base: "inline-flex items-center rounded-[var(--kb-radius)]",
  variants: {
    variant: {
      default: "bg-primary text-primary-foreground",
      outline: "border border-border",
    },
  },
});

const iconButtonVariants = tv({
  extend: baseButtonVariants,
  base: "justify-center",
  variants: {
    size: {
      sm: "size-8",
      md: "size-9",
      lg: "size-10",
    },
  },
});

Compound Slots

Apply same classes to multiple slots:
import { tv } from "tailwind-variants";

const alertVariants = tv({
  slots: {
    root: "border rounded-lg p-4",
    icon: "size-4",
    title: "font-semibold",
    description: "text-sm",
  },
  variants: {
    variant: {
      default: {},
      destructive: {},
    },
  },
  compoundSlots: [
    {
      slots: ["title", "description"],
      variant: "destructive",
      className: "text-danger-foreground",
    },
  ],
});

Class Merging

cn (Conflict-Aware Merge)

Merge Tailwind classes with conflict resolution:
import { cn } from "tailwind-variants";

const className = cn(
  "px-4 py-2",
  "px-6", // Overrides px-4
  "bg-primary text-primary-foreground"
);
// Result: "px-6 py-2 bg-primary text-primary-foreground"
Use when: Merging classes where conflicts should resolve to the last value.

cx (Simple Concatenation)

Concatenate classes without conflict resolution:
import { cx } from "tailwind-variants";

const className = cx(
  "flex",
  isActive && "bg-accent",
  "rounded-lg"
);
Use when: No conflicting classes expected and you want faster performance.

Usage in Components

import { cn, tv } from "tailwind-variants";
import type { VariantProps } from "tailwind-variants";

const buttonVariants = tv({
  base: "inline-flex items-center",
  variants: {
    variant: {
      default: "bg-primary",
    },
  },
});

type ButtonProps = VariantProps<typeof buttonVariants> & {
  className?: string;
};

function Button({ variant, className, ...props }: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ variant }), className)}
      {...props}
    />
  );
}

// Consumer can override styles
<Button className="bg-secondary">Custom Button</Button>

Base UI Integration

mergeBaseUIClassName

Merge Kuzenbo styles with Base UI’s callback-based className:
import { mergeBaseUIClassName } from "@kuzenbo/core/utils/merge-base-ui-class-name";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cn } from "tailwind-variants";

function Button({ className, ...props }) {
  return (
    <ButtonPrimitive
      className={mergeBaseUIClassName(
        "inline-flex items-center bg-primary",
        className
      )}
      {...props}
    />
  );
}

// Supports both string and callback className
<Button className="custom-class" />
<Button className={(state) => state.disabled ? "opacity-50" : ""} />
Type Signature:
type BaseUIClassName<State> =
  | ((state: State) => string | undefined)
  | string
  | undefined;

function mergeBaseUIClassName<State>(
  baseClassName: string | undefined,
  className: BaseUIClassName<State>
): BaseUIClassName<State>;
Parameters:
  • baseClassName: Component’s default classes
  • className: User-provided classes (string or state callback)
Returns: Merged className (same type as input)

Styling Patterns

Size-Based Variants

Use UISize metric families for consistent sizing:
import { tv } from "tailwind-variants";
import {
  resolveFieldHeightClassBySize,
  resolveFieldTextClassBySize,
} from "@kuzenbo/core/size";

const inputVariants = tv({
  base: "w-full rounded-[var(--kb-radius)] border",
  variants: {
    size: {
      sm: [
        resolveFieldHeightClassBySize("sm"),
        resolveFieldTextClassBySize("sm"),
        "px-2",
      ],
      md: [
        resolveFieldHeightClassBySize("md"),
        resolveFieldTextClassBySize("md"),
        "px-3",
      ],
      lg: [
        resolveFieldHeightClassBySize("lg"),
        resolveFieldTextClassBySize("lg"),
        "px-4",
      ],
    },
  },
  defaultVariants: {
    size: "md",
  },
});

Data Attributes for State

Use data attributes for component state:
import { tv } from "tailwind-variants";

const itemVariants = tv({
  base: cn(
    "flex items-center",
    "data-[selected]:bg-accent",
    "data-[disabled]:opacity-50",
    "data-[highlighted]:bg-muted"
  ),
});

function Item({ selected, disabled }) {
  return (
    <div
      className={itemVariants()}
      data-selected={selected || undefined}
      data-disabled={disabled || undefined}
    >
      Item content
    </div>
  );
}

Semantic Color Tokens

Always use semantic tokens instead of raw Tailwind colors:
// ✅ Correct - semantic tokens
const variants = tv({
  base: "bg-background text-foreground border-border",
  variants: {
    variant: {
      default: "bg-primary text-primary-foreground",
      danger: "bg-danger text-danger-foreground",
    },
  },
});

// ❌ Wrong - raw palette classes
const variants = tv({
  base: "bg-white text-gray-900 border-gray-200",
  variants: {
    variant: {
      default: "bg-blue-600 text-white",
      danger: "bg-red-600 text-white",
    },
  },
});

CSS Variables

Reference Kuzenbo design tokens:
import { tv } from "tailwind-variants";

const cardVariants = tv({
  base: cn(
    "rounded-[var(--kb-radius)]",
    "bg-card text-card-foreground",
    "border-border"
  ),
});
Common CSS variables:
  • --kb-radius - Global border radius
  • --kb-cursor - Interactive cursor (use cursor-clickable)
  • --kb-container-max-width - Container max width

TypeScript Utilities

VariantProps

Extract props type from variant definition:
import { tv } from "tailwind-variants";
import type { VariantProps } from "tailwind-variants";
import type { ComponentProps } from "react";

const buttonVariants = tv({
  variants: {
    variant: { default: "", outline: "" },
    size: { sm: "", md: "", lg: "" },
  },
});

type ButtonProps = ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & {
    isLoading?: boolean;
  };

function Button({ variant, size, isLoading, ...props }: ButtonProps) {
  return <button {...props} />;
}

Making Variants Required

Enforce required variant props:
import type { VariantProps } from "tailwind-variants";

const variants = tv({
  variants: {
    variant: { default: "", outline: "" },
    size: { sm: "", md: "" },
  },
});

// Make 'variant' required
type Props = Omit<VariantProps<typeof variants>, "variant"> &
  Required<Pick<VariantProps<typeof variants>, "variant">>;

function Component({ variant, size }: Props) {
  // variant is required, size is optional
}

Complete Example

import { tv, cn } from "tailwind-variants";
import type { VariantProps } from "tailwind-variants";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import type { ComponentProps } from "react";
import { mergeBaseUIClassName } from "@kuzenbo/core/utils/merge-base-ui-class-name";
import { useComponentSize } from "@kuzenbo/core/provider";
import {
  resolveFieldHeightClassBySize,
  resolveFieldTextClassBySize,
} from "@kuzenbo/core/size";

const buttonVariants = tv({
  base: cn(
    "inline-flex items-center justify-center",
    "rounded-[var(--kb-radius)]",
    "font-medium transition-colors",
    "focus-visible:outline-none focus-visible:ring-2",
    "disabled:pointer-events-none disabled:opacity-50"
  ),
  variants: {
    variant: {
      default: "bg-primary text-primary-foreground hover:bg-primary/90",
      outline: "border border-border bg-background hover:bg-accent",
      ghost: "hover:bg-accent hover:text-accent-foreground",
      danger: "bg-danger text-danger-foreground hover:bg-danger/90",
    },
    size: {
      sm: [
        resolveFieldHeightClassBySize("sm"),
        resolveFieldTextClassBySize("sm"),
        "px-3",
      ],
      md: [
        resolveFieldHeightClassBySize("md"),
        resolveFieldTextClassBySize("md"),
        "px-4",
      ],
      lg: [
        resolveFieldHeightClassBySize("lg"),
        resolveFieldTextClassBySize("lg"),
        "px-5",
      ],
    },
  },
  defaultVariants: {
    variant: "default",
    size: "md",
  },
});

export type ButtonProps = ComponentProps<typeof ButtonPrimitive> &
  VariantProps<typeof buttonVariants>;

export function Button({ variant, size, className, ...props }: ButtonProps) {
  const resolvedSize = useComponentSize(size);
  
  return (
    <ButtonPrimitive
      className={mergeBaseUIClassName(
        buttonVariants({ variant, size: resolvedSize }),
        className
      )}
      data-size={resolvedSize}
      {...props}
    />
  );
}

Build docs developers (and LLMs) love