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 /* ... */ });