Skip to Content

TypeScript

Extract typed props with VariantProps, work with slotted return types, and keep variant inference reliable.

Tailwind Variants is written in TypeScript. Variant keys and values infer automatically — no code generation required.

VariantProps

Extract the props a recipe accepts:

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

export const button = tv({
  base: 'inline-flex cursor-pointer items-center justify-center rounded-full px-4 py-1.5 font-medium select-none',
  variants: {
    variant: {
      primary: 'bg-zinc-900 text-white',
      secondary: 'bg-zinc-100 text-zinc-900',
      tertiary: 'text-zinc-600'
    },
    flat: {
      true: 'bg-transparent shadow-none'
    }
  },
  defaultVariants: {
    variant: 'primary'
  }
});

type ButtonVariants = VariantProps<typeof button>;
// variant?: "primary" | "secondary" | "tertiary"
// flat?: boolean

interface ButtonProps extends ButtonVariants {
  children: React.ReactNode;
  className?: string;
}

export function Button({ children, className, ...variants }: ButtonProps) {
  return (
    <button className={button({ ...variants, className })}>
      {children}
    </button>
  );
}

Keys with defaultVariants become optional on the type.

Numeric keys

Options keyed by numbers, such as spacing scales, stay number literals on the type:

const box = tv({
  variants: {
    gap: { px: 'gap-px', 0: 'gap-0', 2: 'gap-2' },
  },
});

type BoxVariants = VariantProps<typeof box>;
// gap?: "px" | 0 | 2

box({ gap: 2 }); // "gap-2"

Pass them as numbers. String forms such as "2" are not part of the type.

Required variants

TV does not have a built-in "required variant" flag. Use TypeScript utilities:

type ButtonVariants = VariantProps<typeof button>;

type RequiredSize = Omit<ButtonVariants, 'size'> &
  Required<Pick<ButtonVariants, 'size'>>;

Or model the axis without a default so TypeScript keeps it required.

Stricter validation

Unknown or misspelled keys in variants, compoundVariants, and defaultVariants fail at compile time, as do unknown slot names in slot-shaped class values. Nothing gets silently ignored:

const button = tv({
  variants: { size: { sm: 'text-sm' } },
  // ✗ compile error — 'colour' is not a variant key
  defaultVariants: { colour: 'primary' }
});

If an upgrade surfaces new type errors here, they almost always point at real typos. See Migration.

Slotted return types

Slotted recipes return an object of slot functions. Destructure once for cleaner types:

const alert = tv({
  slots: {
    base: 'flex gap-3 rounded-lg p-4',
    title: 'font-semibold',
    description: 'text-sm'
  },
  variants: {
    color: {
      default: {
        base: 'bg-zinc-100',
        title: 'text-zinc-900'
      },
      danger: {
        base: 'bg-red-50',
        title: 'text-red-900'
      }
    }
  }
});

type AlertSlots = ReturnType<typeof alert>;
// AlertSlots.base, .title, .description — each (props?) => string

function Alert({ color }: VariantProps<typeof alert>) {
  const { base, title, description } = alert({ color });

  return (
    <div className={base()}>
      <p className={title()}>Title</p>
      <p className={description()}>Body</p>
    </div>
  );
}

as const for external definitions

When variants live outside tv, use as const so TypeScript preserves literal keys:

const variants = {
  primary: 'bg-zinc-900 text-white',
  secondary: 'bg-zinc-100 text-zinc-900',
  tertiary: 'text-zinc-600'
} as const;

const button = tv({
  variants: { variant: variants }
});

On this page