Variant Conventions
How @diffgazer/ui decides between CVA, CSS files, Records, and plain Tailwind for component variants.
Decision rules
Pick the first rule that matches:
- Component has named variant dimensions (size, variant, tone, density) → CVA
- Variant key maps to a non-class value (ASCII character, tag default, content string) → Record
- Styling requires
@keyframes, CSS counters,::before/::afterpositioning, multi-attribute data selectors, orforced-colors/prefers-reduced-motionoverrides → CSS file - Single boolean conditional, no dimensions → plain Tailwind +
cn()
Everything else defaults to CVA.
Token layer rule
Component CSS and CVA strings read the semantic layer (var(--border), var(--success), the bg-*/text-*/border-* utilities). They must never reference the Tailwind bridge namespace (var(--color-*)).
The bridge (--color-*) is emitted only at :root, so a var(--color-border) reference would freeze at the root theme and ignore a data-theme subtree. The semantic layer is re-declared inside every [data-theme] block, so reading var(--border) lets subtree re-theming reach component CSS. This is what makes @theme inline deliver subtree theming end-to-end.
CVA pattern
Every component with variant dimensions follows this shape:
Rules
- Export the variants function so other components can compose it.
- Export
VariantProps<typeof X>for type-safe consumption. - Use
compoundVariantsfor combination-dependent styles. Do not use ternaries insidecn()for things that depend on two variant axes. - Use
defaultVariants— not fallback logic at the call site. - Wrap with
cn()so consumers can override classes viaclassName.
Shared variant modules
Extract to registry/lib/ when two or more components share the same variant axes:
Keep variants local when only one component uses them: horizontal-stepper/horizontal-stepper-variants.ts, sidebar/sidebar-variants.ts, and toast/toast-variants.ts sit in the component folder and are imported relatively. Colocating costs nothing at install time — a colocated module can still be its own hidden registry item, which is how horizontal-stepper-variants and sidebar-variants ship, so installing the component pulls its CVA strings and nothing else.
When CSS files are justified
CSS files handle things Tailwind utilities cannot express. Put only these in CSS:
Current CSS files and why they exist
What does NOT belong in CSS files:
- Variant class logic reachable via CVA (sizes, colors, intents)
- Hover/focus/active states reachable via Tailwind utilities
- Conditional styling driven by component props
- Layout and spacing
@applyblocks
Coarse-pointer hit areas
WCAG 2.5.5 asks for a 44x44 CSS px target on touch. Our fine-pointer densities are smaller than that on purpose, so the coarse-pointer target is added on top of the visual box with one of exactly three recipes. Pick by asking what the control is allowed to do to its surroundings.
1. Real minimum size
The control simply becomes 44px on coarse pointers. This is the default recipe and by far the most used one: CodeBlock's copy button (via code-block.css, which also grows the header row so the taller control is not clipped), Toast's close button, SearchInput's clear button (as pointer-coarse:size-11, since it grows in both axes), Sidebar.Trigger, sidebar rows and section titles, the Checkbox/Radio row (selectable-variants.ts), and Tabs/ToggleGroup at sm (segmented-variants.ts). Grep pointer-coarse:(min-h-11|size-11) for the live list.
Preconditions: the control's row is allowed to grow. Fails inside a fixed-height chrome — a toolbar, a status bar, a header with a pinned height — where the extra pixels either overflow or get clipped.
2. Padding plus negative margin
Padding grows the target, the negative margin hands the space back so the surrounding rhythm is unchanged, and on coarse pointers the pull-back is dropped for a real minimum height. Used by Accordion.Trigger, Pager.Link, Breadcrumbs.Link, and the Stepper row trigger (stepper-variants.ts). Grep pointer-coarse:my-0 for the live list.
Preconditions:
- The control participates in normal flow (inline or block) — negative margins do nothing useful on an absolutely positioned or grid-placed box.
- The pull-back stays vertical. A horizontal pull-back makes an inline run overlap its own separators.
- Spell the pull-back longhand (
-mt-2 -mb-2) whenever a variant overrides one side, so the override does not depend on shorthand/longhand rule order.
3. Transparent pseudo-element
The visual box never changes; an invisible ::before overhangs it and catches the tap. Used by Button (sm/md/icon), Switch, and Callout.Dismiss — controls that live in fixed-height toolbars, dense rows, and a callout's own top edge, where recipes 1 and 2 would reflow the layout. Grep pointer-coarse:before for the live list.
Preconditions:
- The element itself carries
relative, so it is the pseudo-element's containing block. Putrelativeon the sizes that need it, not on the shared base — a size that is already 44px (Buttonlg) should stayposition: static. - Room for the overhang inside the nearest
overflow-hiddenancestor. Such an ancestor is almost always there — the app shells and panel frames wrap nearly every control in one — and it only bites when the control sits closer to that ancestor's clip edge than the overhang reaches: then the overhang is cut and the target silently shrinks back to its visual size. Check the gap, not the presence of the ancestor.CodeBlock's copy button is the case that fails it (its header is the clip edge on both sides), which is why it uses recipe 1 instead. - Not inside a floating panel.
Popover,Menu, andSelectcontent is a scroll container (.ui-floating-panelsetsoverflow: autoso the viewport size caps scroll instead of clipping), so a pseudo-element hit area on a control inside one is clipped at the panel edge. Use recipe 1 or panel padding there. - A minimum gap to the next interactive row, equal to twice the overhang: 16px for
Buttonsm, 8px forButtonmd/icon. Below that, neighbouring hit areas overlap and a tap lands on the wrong control. - Grow vertically only unless the box is also too narrow.
Buttoniconis the one case that widens (4px per side, 36 → 44), because horizontal growth in a button row is otherwise an overlap.
Documented exception: text inputs
Input sm and md stay below 44px on coarse pointers, and this is deliberate. A text field is not a point target — it is dragged, tapped mid-string, and stacked in dense forms. Auto-raising every field would reflow whole forms on touch, and none of the three recipes is safe here: recipes 1 and 2 change the form's vertical rhythm, and recipe 3 would put a transparent overlay across the caret area of the neighbouring field. Consumers that need a large field opt into size="lg".
When records are OK
Use a Record<VariantKey, T> when the mapping produces non-className values:
Do not use a Record when the values are className strings that duplicate a CVA variant axis. Use CVA instead.
Data-Attribute Vocabulary
State-driven styling hooks use ONE fixed vocabulary across every component (the Radix model) so a copy/shadcn consumer can write one selector strategy per concept:
Rules:
- Presence-only attributes carry no value (
data-highlighted, notdata-highlighted="true"). Match them withdata-[highlighted]:/group-data-[highlighted]:, neverdata-[highlighted=true]:. data-stateis the only state attribute that carries an enumerated value; match a specific value withdata-[state=active]:/group-data-[state=on]:.- The active diff hunk uses
data-highlighted(it is keyboard navigation focus, not a widget state). - Do not introduce
data-activeordata-focus— they previously carried four different meanings and are removed.
Component CSS variable naming
Component-scoped CSS custom properties — the copy-mode theming contract — use ONE prefix rule: the full component slug, never an abbreviation.
--dialog-*,--diff-view-*,--callout-*,--command-palette-*,--code-block-*,--panel-*(not--dlg-,--dv-,--cal-,--cp-,--cb-).- The viewfinder corner knob is shared across panel, diff-view, and dialog under ONE name:
--viewfinder-size,--viewfinder-weight,--viewfinder-color,--viewfinder-offset.
Anti-Patterns
CVA-as-type-guard
Defining CVA variants where every value is "" and the real styling lives in a CSS file:
If the CSS file owns the styling, use a plain TypeScript type for the prop and skip CVA:
Parallel records duplicating CVA keys
Fold it into the CVA as a compound variant, or use a CSS custom property set by the CVA class.
Raw ternaries when sibling uses CVA
If a sibling component already has a CVA for the same variant axis, reuse it.
Mixed 3-system styling
Avoid combining CSS file + Tailwind classes + inline style objects in the same component. Pick one primary approach:
- CSS-driven components (code-block, diff-view): CSS file + data-attributes, minimal Tailwind
- CVA-driven components (button, badge, switch): CVA + Tailwind, no CSS file