Skip to content

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:

  1. Component has named variant dimensions (size, variant, tone, density) → CVA
  2. Variant key maps to a non-class value (ASCII character, tag default, content string) → Record
  3. Styling requires @keyframes, CSS counters, ::before/::after positioning, multi-attribute data selectors, or forced-colors/prefers-reduced-motion overridesCSS file
  4. Single boolean conditional, no dimensionsplain 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:

tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

export const buttonVariants = cva(
  "inline-flex items-center justify-center font-mono transition-colors disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        primary: "bg-foreground text-background hover:bg-foreground/90",
        ghost: "hover:bg-secondary",
        outline: "border border-border bg-transparent hover:bg-secondary",
      },
      size: {
        sm: "h-7 px-2.5 text-xs",
        md: "h-8 px-3 text-sm",
        lg: "h-9 px-4 text-sm",
      },
    },
    compoundVariants: [
      { variant: "outline", size: "sm", className: "border-2" },
    ],
    defaultVariants: { variant: "primary", size: "md" },
  }
);

export interface ButtonProps
  extends React.ComponentProps<"button">,
    VariantProps<typeof buttonVariants> {}

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

Rules

  • Export the variants function so other components can compose it.
  • Export VariantProps<typeof X> for type-safe consumption.
  • Use compoundVariants for combination-dependent styles. Do not use ternaries inside cn() 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 via className.

Shared variant modules

Extract to registry/lib/ when two or more components share the same variant axes:

ModuleUsed by
selectable-variants.tsCheckbox, Radio, Switch
segmented-variants.tsTabs, ToggleGroup
input-variants.tsInput, InputGroup, Textarea, SearchInput
corner-label-variants.tsPanel, Card
stepper-variants.tsStepper — the one single-consumer module still in lib/

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:

ContentWhy
@keyframesTailwind references animation tokens, but keyframe blocks must be declared in CSS
CSS counters (counter(), counter-increment)No Tailwind equivalent
::before/::after with variant-specific positioningPseudo-element geometry tied to data-attribute selectors
Complex multi-attribute selectors ([data-state="open"][data-side="bottom"])Unreadable as arbitrary Tailwind variants
forced-colors, prefers-reduced-motion overridesDocument-level media query overrides
Scrollbar styling (::-webkit-scrollbar-*)Not covered by Tailwind utilities

Current CSS files and why they exist

FileJustified content
stepper.css@keyframes stepper-blink, CSS counter for numbered variant, ::before connector line positioning
sidebar.cssgrid-template-rows transition with data-attribute state selectors
code-block.cssSyntax token color classes (.code-*, .hljs-*), variant chrome via multi-attribute selectors, forced-colors, and the @media (pointer: coarse) block that grows the header row and the copy button to 44px (see Coarse-pointer hit areas)
diff-view.cssRow state coloring via multi-attribute selectors, colorblind palette via [data-diff-palette], forced-colors
dialog.cssCorner accent ::before/::after geometry, open/close keyframes on the --dialog-duration entrance clock, prefers-reduced-motion override
command-palette.cssDensity CSS custom properties, multi-attribute frame/density/tone selectors, forced-colors
callout.cssTone cascade via CSS custom properties, frame chrome via data-attributes, forced-colors
panel.cssFrame chrome (hairline/rail/viewfinder/surface) via multi-attribute selectors, forced-colors
progress.cssCell-grid mask on the progress track/fill (data-variant="cells") — no Tailwind equivalent for a shared mask on both layers
skeleton.cssCharacter-cell strip mask and shimmer keyframes on [data-slot="skeleton"] — same cell language as Spinner/BlockBar
overlay-hints.cssCoarse-pointer collapse/hide rules for the keyed legend row (:has, aria-hidden, data-touch) — layout here is the contract: one flex row with a fixed wrap gap so hint bars never scroll horizontally at 390px
spinner.cssTheme-conditional trail alphas — the shipped theme defaults to dark on a bare :root, so the dark variant cannot express a light-only override

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
  • @apply blocks

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

plaintext
pointer-coarse:min-h-11        /* or a plain width/height in CSS */

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

plaintext
py-2 -mt-2 -mb-2 pointer-coarse:mt-0 pointer-coarse:mb-0 pointer-coarse:min-h-11

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

plaintext
relative pointer-coarse:before:absolute pointer-coarse:before:inset-x-0
pointer-coarse:before:-inset-y-2 pointer-coarse:before:content-['']

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. Put relative on the sizes that need it, not on the shared base — a size that is already 44px (Button lg) should stay position: static.
  • Room for the overhang inside the nearest overflow-hidden ancestor. 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, and Select content is a scroll container (.ui-floating-panel sets overflow: auto so 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 Button sm, 8px for Button md/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. Button icon is 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:

tsx
// OK: maps to content characters, not classes
const checkboxIndicators: Record<string, string> = {
  checked: "[x]",
  unchecked: "[ ]",
  indeterminate: "[-]",
};

// OK: maps heading tag to default prop value
const HEADING_DEFAULT_SIZE: Record<HeadingTag, TypographySize> = {
  h1: "3xl", h2: "2xl", h3: "xl", h4: "lg", h5: "base", h6: "sm",
};

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:

ConceptAttributeValue form
Keyboard / virtual focus (active descendant)data-highlightedpresence-only (data-highlighted)
Selection in a composite (menu item, listbox option, sidebar/toc current item)data-selectedpresence-only (data-selected)
Enumerated widget statedata-stateactive/inactive (tabs), on/off (pressed toggles), checked/unchecked/indeterminate (checks), open/closed (disclosure)

Rules:

  • Presence-only attributes carry no value (data-highlighted, not data-highlighted="true"). Match them with data-[highlighted]: / group-data-[highlighted]:, never data-[highlighted=true]:.
  • data-state is the only state attribute that carries an enumerated value; match a specific value with data-[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-active or data-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:

tsx
// BAD: CVA does nothing, CSS does the work via [data-frame]
frame: { border: "", viewfinder: "", terminal: "", card: "", none: "" }

If the CSS file owns the styling, use a plain TypeScript type for the prop and skip CVA:

tsx
// GOOD: honest about where styling lives
type Frame = "border" | "viewfinder" | "terminal" | "card" | "none";

Parallel records duplicating CVA keys

tsx
// BAD: same variant keys in both CVA and a separate Record
const badgeVariants = cva("...", { variants: { variant: { success: "...", error: "..." } } });
const dotColorMap: Record<BadgeVariant, string> = { success: "bg-green-500", error: "bg-red-500" };

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

tsx
// BAD: MenuItem uses CVA for state, but MenuItemCheckbox duplicates the same logic as ternaries
className={isHighlighted ? "bg-accent text-accent-foreground" : "text-foreground"}

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