Extending

Compose component recipes with extend — inherit base styles, variants, slots, and defaults from one or more parents without copying definitions.

extend merges one or more recipes into another. Start from a base button, then add icon-only sizing or new variant tokens without copying the whole definition.

Basic extend

Pass a parent recipe via the extend option (arrays of parents are covered in Multiple parents):

import { tv } from 'tailwind-variants';const baseButton = tv({  base: 'inline-flex cursor-pointer items-center justify-center rounded-full font-medium select-none transition-colors',  variants: {    variant: {      primary: 'bg-zinc-900 text-white',      secondary: 'border border-zinc-300 bg-zinc-50 text-zinc-900',      tertiary: 'text-zinc-700'    },    size: {      sm: 'h-8 px-3 text-sm',      md: 'h-10 px-4 text-sm'    }  },  defaultVariants: {    variant: 'primary',    size: 'md'  }});const iconButton = tv({  extend: baseButton,  base: 'gap-2',  variants: {    iconOnly: {      true: 'aspect-square px-0',      false: ''    }  }});

Merge behavior

extend deep-merges:

  • base — concatenated
  • slots — merged by key
  • variants — merged by axis; new keys add axes, existing keys add values
  • defaultVariants — child overrides parent for matching keys
  • compoundVariants — arrays concatenated
  • compoundSlots — arrays concatenated

The returned object exposes merged metadata: variants, variantKeys, defaultVariants, and slot keys reflect the full composed recipe.

Plain and slotted recipes compose cleanly. When one side of a merge is a plain class string and the other is a per-slot object, the string folds into the base slot:

import { tv } from 'tailwind-variants';

const plain = tv({
  base: 'p-1',
  variants: { size: { sm: 'text-sm' } }
});

const slotted = tv({
  extend: plain,
  slots: { icon: 'size-4' },
  variants: { size: { sm: { icon: 'size-3' } } }
});

const { base, icon } = slotted({ size: 'sm' });
base(); // => "p-1 text-sm" (the parent's string lands on base)
icon(); // => "size-4 size-3"

Extending slotted recipes

Slots merge by key. Child recipes can add slots or override styles on inherited ones:

import { tv } from 'tailwind-variants';

const card = tv({
  slots: {
    base: 'rounded-xl border p-4',
    title: 'text-sm font-medium',
    description: 'text-sm text-zinc-500'
  }
});

const mediaCard = tv({
  extend: card,
  slots: {
    media: 'mb-3 aspect-video rounded-lg bg-zinc-100',
    title: 'text-base font-semibold'
  }
});

const { base, media, title, description } = mediaCard();

mediaCard keeps base / description from card, adds media, and tightens title.

Multiple parents

Pass a non-empty array to compose several recipes at once. Parents merge left to right, and the child's own options are applied last:

const button = tv({
  extend: [baseButton, focusRing, pressable],
  base: 'gap-2' // applied after every parent
});

Merging happens once at definition time, so composing multiple parents doesn't slow down calls.

Small recipes are a natural home for cross-cutting concerns like focus rings, press feedback, and disabled states. Compose them into any interactive component. Try tabbing to and pressing the buttons below:

import { tv } from 'tailwind-variants';const focusRing = tv({  base: 'outline-none focus-visible:ring-2 focus-visible:ring-zinc-400'});const pressable = tv({  base: 'transition active:scale-95'});const baseButton = tv({  base: 'inline-flex cursor-pointer items-center justify-center rounded-full font-medium select-none',  variants: {    variant: {      primary: 'bg-zinc-900 text-white',      secondary: 'border border-zinc-300 bg-zinc-50 text-zinc-900',      tertiary: 'text-zinc-700'    },    size: {      sm: 'h-8 px-3 text-sm',      md: 'h-10 px-4 text-sm'    }  },  defaultVariants: {    variant: 'primary',    size: 'md'  }});const actionButton = tv({  extend: [baseButton, focusRing, pressable]});actionButton(); // base + focus ring + press feedbackactionButton({ variant: 'secondary' });

When parents overlap, the usual merge rules apply in array order: later parents win for defaultVariants, and conflicting classes resolve in favor of the last contributor. If two recipes share a variant axis that should stay independent, keep them separate and join their output with cx / cn instead.

Before v4.0.0, chain extends to combine multiple bases:

const extended = tv({ extend: baseButton });
const final = tv({ extend: extended /* ... */ });

On this page