Skip to main content
OrchestKit v7.43.0 — 104 skills, 36 agents, 173 hooks · Claude Code 2.1.105+
OrchestKit
Skills

Ui Components

UI component library patterns for shadcn/ui and Radix Primitives. Use when building accessible component libraries, customizing shadcn components, using Radix unstyled primitives, or creating design system foundations.

Reference medium

Auto-activated — this skill loads automatically when Claude detects matching context.

UI Components

Comprehensive patterns for building accessible UI component libraries with shadcn/ui and Radix Primitives. Covers CVA variants, OKLCH theming, cn() utility, component extension, asChild composition, dialog/menu patterns, and data-attribute styling. Each category has individual rule files in rules/ loaded on-demand.

Quick Reference

CategoryRulesImpactWhen to Use
shadcn/ui4HIGHCVA variants, component customization, form patterns, data tables, v4 styles
Radix Primitives3HIGHDialogs, polymorphic composition, data-attribute styling
Design System5HIGHW3C tokens, OKLCH theming, spacing scales, typography, component states, animation
Design System Components1HIGHAtomic design, CVA variants, accessibility, Storybook
Forms2HIGHReact Hook Form v7, Zod validation, Server Actions
Modern CSS & Tooling3HIGHCSS cascade layers, Tailwind v4, Storybook CSF3
UX Foundations4HIGHVisual hierarchy, typography thresholds, color system, empty states

Total: 22 rules across 7 categories

Quick Start

// CVA variant system with cn() utility
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground',
        outline: 'border border-input bg-background hover:bg-accent',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 px-3',
        lg: 'h-11 px-8',
      },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
)
// Radix Dialog with asChild composition
import { Dialog } from 'radix-ui'

<Dialog.Root>
  <Dialog.Trigger asChild>
    <Button>Open</Button>
  </Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay className="fixed inset-0 bg-black/50" />
    <Dialog.Content className="data-[state=open]:animate-in">
      <Dialog.Title>Title</Dialog.Title>
      <Dialog.Description>Description</Dialog.Description>
      <Dialog.Close>Close</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

shadcn/ui

Beautifully designed, accessible components built on CVA variants, cn() utility, and OKLCH theming.

RuleFileKey Pattern
Customizationrules/shadcn-customization.mdCVA variants, cn() utility, OKLCH theming, component extension
Formsrules/shadcn-forms.mdForm field wrappers, react-hook-form integration, validation
Data Tablerules/shadcn-data-table.mdTanStack Table integration, column definitions, sorting/filtering
v4 Stylesrules/shadcn-v4-styles.md6 styles (Vega→Luma), preset codes, style detection, class mapping

v4 Style System

shadcn CLI v4 ships 6 visual styles. Each rewrites component class names — not just CSS variables.

StyleCharacterBest For
VegaBalanced radius, clean linesGeneral purpose (successor to New York)
NovaCompact padding, reduced marginsDense dashboards, admin panels
MaiaSoft, rounded, generous spacingConsumer-facing, friendly apps
LyraSharp, zero radius, monospace pairsEditorial, developer tools
MiraUltra-compact, minimal chromeSpreadsheets, data-heavy interfaces
LumaExtreme rounding (rounded-4xl), soft elevation (shadow-md + ring), breathable layoutsPolished native-app feel, macOS Tahoe-inspired

Preset codes encode style + theme + fonts + icons into a shareable 7-char string:

npx shadcn@latest init --preset b2D0xPaDb  # Luma + Emerald + Geist + HugeIcons

Configure visually at ui.shadcn.com/create. Same code = same output on any machine.

Detection: Read components.json"style" field (e.g., "radix-luma", "base-nova"). Old "new-york" and "default" styles are superseded by Vega.

Radix Primitives

Unstyled, accessible React primitives for building high-quality design systems.

RuleFileKey Pattern
Dialogrules/radix-dialog.mdDialog, AlertDialog, controlled state, animations
Compositionrules/radix-composition.mdasChild, Slot, nested triggers, polymorphic rendering
Stylingrules/radix-styling.mdData attributes, Tailwind arbitrary variants, focus management

Key Decisions

DecisionRecommendation
Color formatOKLCH for perceptually uniform theming
Class mergingAlways use cn() for Tailwind conflicts
Extending componentsWrap, don't modify source files
VariantsUse CVA for type-safe multi-axis variants
Styling approachData attributes + Tailwind arbitrary variants
CompositionUse asChild to avoid wrapper divs
AnimationCSS-only with data-state selectors
Form componentsCombine with react-hook-form

Anti-Patterns (FORBIDDEN)

  • Modifying shadcn source: Wrap and extend instead of editing generated files
  • Skipping cn(): Direct string concatenation causes Tailwind class conflicts
  • Inline styles over CVA: Use CVA for type-safe, reusable variants
  • Wrapper divs: Use asChild to avoid extra DOM elements
  • Missing Dialog.Title: Every dialog must have an accessible title
  • Positive tabindex: Using tabindex > 0 disrupts natural tab order
  • Color-only states: Use data attributes + multiple indicators
  • Manual focus management: Use Radix built-in focus trapping

Detailed Documentation

ResourceDescription
scripts/Templates: CVA component, extended button, dialog, dropdown
checklists/shadcn setup, accessibility audit checklists
references/CVA system, OKLCH theming, cn() utility, focus management

Design System

Design token architecture, spacing, typography, and interactive component states.

RuleFileKey Pattern
Token Architecturerules/design-system-tokens.mdW3C tokens, OKLCH colors, Tailwind @theme
Spacing Scalerules/design-system-spacing.md8px grid, Tailwind space-1 to space-12
Typography Scalerules/design-system-typography.mdFont sizes, weights, line heights
Component Statesrules/design-system-states.mdHover, focus, active, disabled, loading, animation presets

Design System Components

Component architecture patterns with atomic design and accessibility.

RuleFileKey Pattern
Component Architecturerules/design-system-components.mdAtomic design, CVA variants, WCAG 2.1 AA, Storybook

Forms

React Hook Form v7 with Zod validation and React 19 Server Actions.

RuleFileKey Pattern
React Hook Formrules/forms-react-hook-form.mduseForm, field arrays, Controller, wizards, file uploads
Zod & Server Actionsrules/forms-validation-zod.mdZod schemas, Server Actions, useActionState, async validation

Modern CSS & Tooling

Modern CSS patterns, Tailwind v4, and component documentation tooling for 2026.

RuleFileKey Pattern
CSS Cascade Layersrules/css-cascade-layers.md@layer ordering, specificity-free overrides, third-party isolation
Tailwind v4rules/tailwind-v4-patterns.mdCSS-first @theme, native container queries, @max-* variants
Storybook Docsrules/storybook-component-docs.mdCSF3 stories, play() interaction tests, Chromatic visual regression

UX Foundations

Cognitive-science-grounded UI/UX principles with specific numeric thresholds for production-quality interfaces.

RuleFileKey Pattern
Visual Hierarchyrules/visual-hierarchy.mdButton tiers, de-emphasis, F/Z scan, Von Restorff, proximity, max-width
Typography Thresholdsrules/typography-thresholds.md65ch line length, 1.4–1.6 line height, rem units, modular type scale
Color Systemrules/color-system.mdOKLCH 9-shade scales, semantic categories, no true black, brand-tinted neutrals
Empty Statesrules/empty-states.mdSkeleton-first, icon + headline + description + CTA, cause-specific tone
  • ork:accessibility - WCAG compliance and React Aria patterns
  • ork:testing-unit - Component testing patterns

Rules (21)

Build a color system with OKLCH, 9-shade scales, and semantic categories — HIGH

Color System Architecture

Incorrect — true black, pure grey, and unstructured colors:

/* WRONG: True black creates harsh contrast and eye strain */
color: #000000;

/* WRONG: Pure neutral grey feels lifeless (no brand personality) */
background: #808080;

/* WRONG: Unstructured hex soup — no scale, no semantics */
--primary: #0066cc;
--secondary: #aaaaaa;
--error: #ff0000;

Correct — OKLCH scale with brand-tinted neutrals:

@theme {
  /* PRIMARY — 9-shade OKLCH scale (50→950) */
  --color-brand-50:  oklch(0.97 0.02 250);
  --color-brand-100: oklch(0.93 0.04 250);
  --color-brand-200: oklch(0.86 0.07 250);
  --color-brand-300: oklch(0.76 0.10 250);
  --color-brand-400: oklch(0.65 0.13 250);
  --color-brand-500: oklch(0.55 0.15 250);  /* base */
  --color-brand-600: oklch(0.46 0.15 248);  /* hue shift darker */
  --color-brand-700: oklch(0.38 0.14 246);
  --color-brand-800: oklch(0.30 0.12 244);
  --color-brand-950: oklch(0.18 0.08 240);

  /* NEUTRALS — tinted with brand hue (not pure grey) */
  --color-neutral-50:  oklch(0.98 0.005 250);  /* off-white, not #ffffff */
  --color-neutral-900: oklch(0.18 0.01  250);  /* dark text, not #000000 */

  /* SEMANTIC — success / error / warning / info */
  --color-success: oklch(0.55 0.15 145);
  --color-error:   oklch(0.55 0.18  25);
  --color-warning: oklch(0.65 0.15  75);
  --color-info:    oklch(0.55 0.12 230);

  /* SURFACE — temper max contrast */
  --color-background: oklch(0.98 0.005 250);  /* slate-50 equivalent */
  --color-foreground: oklch(0.18 0.01  250);  /* slate-900 equivalent */
}

Color Categories

CategoryPurposeExample Tokens
Brand/AccentPrimary identity, CTAsbrand-500, brand-600
SemanticStatus communicationsuccess, error, warning, info
NeutralText, backgrounds, bordersneutral-50neutral-950

Shade Scale Rules

ShadeLightness (OKLCH L)Usage
50~0.97Tinted backgrounds
100–2000.90–0.86Hover backgrounds
300–4000.76–0.65Borders, disabled states
500~0.55Base/default (AA on white)
600–7000.46–0.38Hover states for base
800–9500.30–0.18Dark mode surfaces, deep text

Key Decisions

DecisionRecommendation
Color notationOKLCH — perceptually uniform, easy to adjust programmatically
Shade count9 shades per hue (50, 100, 200, 300, 400, 500, 600, 700, 800, 950)
True blackNever #000000 — use neutral-950 (oklch ~0.18)
Neutral tintingTint greys with brand hue (cool brand = cool neutrals)
Hue rotationShift hue 2–6° darker as lightness decreases to maintain saturation
BackgroundOff-white (neutral-50) not pure white — reduces eye strain
Max contrastBody text neutral-900 on neutral-50 — not pure black on white

Use CSS Cascade Layers for predictable style precedence without specificity wars — HIGH

CSS Cascade Layers (@layer)

Cascade layers give you explicit control over which styles win, regardless of specificity or source order within each layer. Styles in later layers always beat earlier layers.

/* Declare layer order once at the top of your entry CSS file */
@layer reset, base, tokens, components, utilities, overrides;
LayerPurposeExample
resetNormalize browser defaults*, *::before \{ box-sizing: border-box; margin: 0; \}
baseElement-level defaultsbody \{ font-family: var(--font-sans); \}
tokensDesign tokens / CSS custom properties:root \{ --color-primary: oklch(0.6 0.2 250); \}
componentsComponent-scoped styles.card \{ border-radius: var(--radius-md); \}
utilitiesTailwind or utility classes.sr-only \{ position: absolute; ... \}
overridesPage-specific or one-off overrides.hero-banner .card \{ padding: 3rem; \}

Assigning Third-Party CSS to Early Layers

Push third-party styles into a low-priority layer so your styles always win:

/* Import third-party CSS into the reset layer */
@import url('normalize.css') layer(reset);
@import url('some-library/styles.css') layer(base);

Unlayered Styles

Styles outside any @layer always beat layered styles. Use this sparingly for truly global escape hatches.

Incorrect -- Fighting specificity with !important and deep nesting

/* Specificity war — fragile and hard to maintain */
.page-wrapper .content-area .sidebar .card .card-header h2 {
  color: blue;
}

.card-header h2 {
  color: red !important; /* Only way to override the above */
}

Correct -- Clean layer structure with clear precedence

@layer reset, base, tokens, components, utilities, overrides;

@layer components {
  .card-header h2 {
    color: var(--color-heading);
  }
}

@layer overrides {
  /* Wins over components layer regardless of specificity */
  .card-header h2 {
    color: var(--color-accent);
  }
}

Key Rules

  • Declare all layers in a single @layer statement at the top of your entry CSS
  • Later layers beat earlier layers regardless of selector specificity
  • Assign third-party CSS to early layers (reset or base) for clean overrides
  • Never use !important to fight specificity — restructure layers instead
  • Unlayered CSS beats all layers — keep it minimal
  • Tailwind v4 uses layers internally; place custom layers around it accordingly

Reference: MDN @layer

Structure scalable component libraries using atomic design and composition patterns — HIGH

Design System Component Architecture

Incorrect — unstructured components without patterns:

// WRONG: No variant system, inline styles
function Button({ type, children }) {
  const style = type === 'primary'
    ? { background: 'blue', color: 'white', padding: '10px 20px' }
    : { background: 'gray', color: 'black', padding: '10px 20px' };
  return <button style={style}>{children}</button>;
}

// WRONG: Wrapper divs instead of composition
<div className="dialog-wrapper">
  <div className="dialog-overlay" />
  <div className="dialog-content">
    <button onClick={close}>Close</button>
  </div>
</div>

Correct — Atomic Design with CVA variants:

// Atomic Design hierarchy
// Atoms -> Molecules -> Organisms -> Templates -> Pages

// Atom: Button with CVA variants
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground',
        outline: 'border border-input bg-background hover:bg-accent',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 px-3',
        lg: 'h-11 px-8',
      },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
)

Atomic Design Levels

LevelDescriptionExamples
AtomsIndivisible primitivesButton, Input, Label, Icon
MoleculesSimple compositionsFormField, SearchBar, Card
OrganismsComplex compositionsNavigation, Modal, DataTable
TemplatesPage layoutsDashboardLayout, AuthLayout
PagesSpecific instancesHomePage, SettingsPage

WCAG 2.1 Level AA Requirements

RequirementThreshold
Normal text contrast4.5:1 minimum
Large text contrast3:1 minimum
UI components3:1 minimum

Accessibility Essentials

  • Keyboard Navigation: All interactive elements must be keyboard accessible
  • Focus Management: Use focus traps in modals, maintain logical focus order
  • Semantic HTML: Use &lt;button&gt;, &lt;nav&gt;, &lt;main&gt; instead of generic divs
  • ARIA Attributes: aria-label, aria-expanded, aria-controls, aria-live
  • No positive tabindex: Using tabindex > 0 disrupts natural tab order

Key Decisions

DecisionRecommendation
Component architectureAtomic Design (scalable hierarchy)
Variant managementCVA (Class Variance Authority)
DocumentationStorybook (interactive component playground)
CompositionUse asChild to avoid wrapper divs
Extending componentsWrap, don't modify source files
Class mergingAlways use cn() for Tailwind conflicts

Use 8px grid spacing scale for consistent component and layout spacing — MEDIUM

8px Grid Spacing System

Incorrect -- arbitrary pixel values:

// WRONG: Random spacing with no system
<div style={{ padding: '13px', marginBottom: '7px', gap: '11px' }}>
  <h2 style={{ marginTop: '19px' }}>Title</h2>
  <p style={{ padding: '5px 9px' }}>Content</p>
</div>

// WRONG: Mixing spacing systems
<div className="p-3 mb-[7px] gap-[11px]">

Correct -- 8px grid spacing scale:

Spacing Scale

TokenValueTailwindUse Case
micro4pxp-1, gap-1Icon-to-label gaps, inline element spacing
tight8pxp-2, gap-2Compact lists, tight form fields, badge padding
compact12pxp-3, gap-3Card padding (small), button group gaps
default16pxp-4, gap-4Standard padding, form field gaps, paragraph spacing
comfortable24pxp-6, gap-6Card padding (large), section gaps within a panel
loose32pxp-8, gap-8Page section separation, modal padding
section48pxp-12, gap-12Major page sections, hero spacing

Component Spacing Guide

// Card with consistent spacing
<Card className="p-6 space-y-4">          {/* comfortable padding, default internal gaps */}
  <CardHeader className="space-y-2">       {/* tight gaps between title/description */}
    <CardTitle>Title</CardTitle>
    <CardDescription>Description</CardDescription>
  </CardHeader>
  <CardContent className="space-y-4">      {/* default gaps between content blocks */}
    <p>Body text</p>
  </CardContent>
  <CardFooter className="gap-2">           {/* tight gaps between action buttons */}
    <Button variant="outline">Cancel</Button>
    <Button>Submit</Button>
  </CardFooter>
</Card>

Layout Spacing

// Page layout with section spacing
<main className="space-y-12 px-8 py-12">   {/* section gaps, loose horizontal padding */}
  <section className="space-y-6">           {/* comfortable gaps within section */}
    <h1 className="mb-4">Page Title</h1>    {/* default gap below heading */}
    <div className="grid gap-6">            {/* comfortable grid gaps */}
      {items.map(item => <Card key={item.id} />)}
    </div>
  </section>
</main>

Rules

  • All spacing values must be multiples of 4px
  • Use Tailwind spacing utilities, never arbitrary px values
  • Nest spacing: outer containers use larger values, inner elements use smaller
  • Consistent gap hierarchy: section (48px) > panel (24-32px) > content (16px) > elements (8px) > micro (4px)

Key decisions:

  • Base unit: 8px (with 4px half-step for micro adjustments)
  • Never use odd pixel values or non-grid-aligned spacing
  • Prefer gap and space-y/space-x over individual margins
  • Scale spacing with viewport using responsive utilities (gap-4 md:gap-6 lg:gap-8)

Define all interactive component states with consistent visual feedback patterns — HIGH

Interactive Component States

Incorrect -- button with only default state:

// WRONG: No hover, focus, disabled, or loading states
function Button({ children, onClick }) {
  return (
    <button onClick={onClick} className="bg-blue-500 text-white px-4 py-2 rounded">
      {children}
    </button>
  );
}
// User gets no feedback on hover, no focus ring for keyboard users,
// no visual change when disabled, no loading indicator

Correct -- button with all 6 states plus animation:

Required States for Interactive Components

StateVisual IndicatorPurpose
DefaultBase stylingResting appearance
HoverSubtle background shift, cursor changeIndicates interactivity
FocusVisible ring (2px offset)Keyboard navigation feedback
Active/PressedScale down or darkenConfirms click/tap registered
DisabledReduced opacity, no pointer eventsShows unavailability
LoadingSpinner + disabled interactionAsync operation in progress

TypeScript Interface

interface ComponentStateProps {
  isDisabled?: boolean;
  isLoading?: boolean;
  // Default, hover, focus, active are handled via CSS
}

Tailwind State Classes

function Button({ children, onClick, isDisabled, isLoading }: ComponentStateProps & {
  children: React.ReactNode;
  onClick?: () => void;
}) {
  return (
    <button
      onClick={onClick}
      disabled={isDisabled || isLoading}
      className={cn(
        // Default
        "bg-primary text-primary-foreground px-4 py-2 rounded-md font-medium",
        "inline-flex items-center justify-center gap-2",
        // Hover
        "hover:bg-primary/90",
        // Focus (visible ring for keyboard, not mouse)
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
        // Active
        "active:scale-[0.98] active:bg-primary/80",
        // Disabled
        "disabled:opacity-50 disabled:pointer-events-none",
        // Transition
        "transition-all duration-150 ease-in-out",
      )}
    >
      {isLoading && <Spinner className="h-4 w-4 animate-spin" />}
      {children}
    </button>
  );
}

Motion Presets

ContextAnimation NameCSS/TailwindDurationEasing
Page transitionspageFadeanimate-in fade-in200msease-out
ModalsmodalContentanimate-in zoom-in-95 fade-in200msease-out
List itemsstaggerItemanimate-in slide-in-from-bottom-2150msease-out
Card hovercardHoverhover:-translate-y-1 hover:shadow-lg200msease-in-out
Button taptapScaleactive:scale-[0.98]100msease-in
Toast entertoastSlideInanimate-in slide-in-from-right300msease-out
// Staggered list animation
{items.map((item, i) => (
  <div
    key={item.id}
    className="animate-in slide-in-from-bottom-2 fade-in"
    style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
  >
    {item.content}
  </div>
))}

Accessibility Contrast Requirements

ElementMinimum RatioTarget RatioWCAG Level
Body text4.5:17:1AA / AAA
Large text (18px+ or 14px bold)3:14.5:1AA / AAA
UI components (borders, icons)3:14.5:1AA
Focus indicators3:14.5:1AA

State Checklist for New Components

Every interactive component must define:

  1. Default -- base visual appearance
  2. Hover -- hover: modifier with subtle visual shift
  3. Focus -- focus-visible:ring-2 (never remove focus outlines)
  4. Active -- active: feedback (scale, darken, or both)
  5. Disabled -- disabled:opacity-50 disabled:pointer-events-none
  6. Loading -- spinner icon, disabled interaction, aria-busy="true"

Key decisions:

  • Always use focus-visible (not focus) to avoid showing rings on mouse click
  • Keep transitions under 200ms for interactive feedback (longer feels sluggish)
  • Use prefers-reduced-motion media query to disable animations for accessibility
  • Test all states in Storybook with a dedicated "States" story per component

Define consistent design tokens to enable global theme changes without visual drift — HIGH

Design System Token Architecture

Incorrect — hardcoded values and CSS variable abuse:

// WRONG: Hardcoded colors
<div className="bg-[#0066cc] text-[#ffffff]">

// WRONG: CSS variables in className (use semantic tokens instead)
<div className="bg-[var(--color-primary)]">

// WRONG: No token structure
const styles = { color: '#333', padding: '17px', fontSize: '15px' };

Correct — W3C design token structure with Tailwind @theme:

const tokens = {
  colors: {
    primary: { base: "#0066cc", hover: "#0052a3" },
    semantic: { success: "#28a745", error: "#dc3545" }
  },
  spacing: { xs: "4px", sm: "8px", md: "16px", lg: "24px" }
};
/* Tailwind @theme directive (recommended) */
@theme {
  --color-primary: oklch(0.55 0.15 250);
  --color-primary-hover: oklch(0.45 0.15 250);
  --color-text-primary: oklch(0.15 0 0);
  --color-background: oklch(0.98 0 0);
  --spacing-xs: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
}
// Components use Tailwind utilities (correct)
<div className="bg-primary text-text-primary p-md">

Token Categories

CategoryExamplesScale
Colorsblue.500, text.primary, feedback.error50-950
TypographyfontSize.base, fontWeight.semiboldxs-5xl
Spacingspacing.4, spacing.80-24 (4px base)
Border RadiusborderRadius.md, borderRadius.fullnone-full
Shadowsshadow.sm, shadow.lgxs-xl

Design System Layers

LayerDescriptionExamples
Design TokensFoundational design decisionsColors, spacing, typography
ComponentsReusable UI building blocksButton, Input, Card, Modal
PatternsCommon UX solutionsForms, Navigation, Layouts
GuidelinesRules and best practicesAccessibility, naming, APIs

Key Decisions

DecisionRecommendation
Token formatW3C Design Tokens (industry standard)
Color formatOKLCH for perceptually uniform theming
Styling approachTailwind @theme directive
Spacing base4px system
Dark modeTailwind @theme with CSS variables

Apply consistent typography scale with semantic size roles for readable hierarchies — MEDIUM

Typography Scale

Incorrect -- arbitrary font sizes:

// WRONG: Random sizes with no hierarchy
<h1 style={{ fontSize: '27px', fontWeight: 400 }}>Title</h1>
<p style={{ fontSize: '15px', lineHeight: '1.3' }}>Body</p>
<span style={{ fontSize: '11px' }}>Caption</span>

// WRONG: Mixing Tailwind and arbitrary values
<h1 className="text-[27px] font-normal leading-[1.3]">Title</h1>

Correct -- semantic typography scale:

Size Scale

TokenSizeTailwindSemantic Role
caption12pxtext-xsCaptions, timestamps, helper text
secondary14pxtext-smSecondary text, labels, metadata
body16pxtext-baseBody copy, primary content
large18pxtext-lgLarge body, lead paragraphs
subheading20pxtext-xlSection subheadings
heading24pxtext-2xlCard/panel headings
page-title30pxtext-3xlPage titles, hero headings

Weight Pairing Guide

WeightTailwindUse With
Normal (400)font-normalBody text, paragraphs, descriptions
Medium (500)font-mediumLabels, nav items, subtle emphasis
Semibold (600)font-semiboldHeadings, card titles, table headers
Bold (700)font-boldPrimary emphasis, key metrics, CTAs

Line Height Rules

ContextTailwindRatioWhen
Headingsleading-tight1.25Short, large text (h1-h3)
Bodyleading-normal1.5Standard paragraphs, lists
Long-formleading-relaxed1.625Articles, documentation, dense content

Correct Usage

// Page with consistent typography hierarchy
<main>
  <h1 className="text-3xl font-semibold leading-tight">
    Page Title
  </h1>
  <p className="text-lg font-normal leading-normal text-muted-foreground">
    Lead paragraph with larger body text.
  </p>

  <section>
    <h2 className="text-2xl font-semibold leading-tight">Section Heading</h2>
    <p className="text-base font-normal leading-normal">
      Standard body text for primary content.
    </p>
    <span className="text-sm font-medium text-muted-foreground">
      Label or metadata
    </span>
    <p className="text-xs text-muted-foreground">
      Caption or timestamp
    </p>
  </section>
</main>

Rules

  • Never use arbitrary font sizes -- always use the scale tokens
  • Pair weights intentionally: body=normal, labels=medium, headings=semibold
  • Use leading-tight for headings, leading-normal for body, leading-relaxed for long-form
  • Maximum 2 font families per project (1 sans-serif + 1 monospace is ideal)
  • Responsive scaling: text-2xl md:text-3xl lg:text-4xl for page titles

Key decisions:

  • Base size: 16px (text-base) -- browser default, accessible
  • Scale ratio: ~1.25 (Major Third) for harmonious progression
  • Weight hierarchy: normal < medium < semibold < bold (never skip 2+ levels)
  • Line height decreases as font size increases

Design intentional empty states with clear structure, actionable CTAs, and skeleton-first loading — HIGH

Empty State Patterns

Incorrect — blank screen and unusable no-results state:

// WRONG: Renders nothing when list is empty
function ProjectList({ projects }) {
  return (
    <ul>
      {projects.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}

// WRONG: Clinical message with no guidance or action
{projects.length === 0 && <p>No projects.</p>}

// WRONG: Flash empty state before data loads (jarring UX)
{!isLoading && data.length === 0 && <EmptyState />}
{isLoading && <Spinner />}

Correct — skeleton-first, then structured empty state:

// RIGHT: Skeleton while loading → empty state only if data is truly absent
function ProjectList({ isLoading, projects }: Props) {
  if (isLoading) return <ProjectListSkeleton />

  if (projects.length === 0) return <EmptyProjects />

  return (
    <ul className="space-y-2">
      {projects.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}

// RIGHT: Structured empty state — icon + headline + description + CTA
function EmptyProjects() {
  return (
    <div
      className="flex flex-col items-center justify-center gap-4 py-16 text-center"
      role="status"
      aria-label="No projects yet"
    >
      {/* 1. Illustration or icon */}
      <div className="rounded-full bg-muted p-4">
        <FolderPlusIcon className="h-8 w-8 text-muted-foreground" aria-hidden />
      </div>

      {/* 2. Encouraging headline (not clinical) */}
      <h2 className="text-lg font-semibold text-foreground">
        Create your first project
      </h2>

      {/* 3. Context description */}
      <p className="max-w-sm text-sm text-muted-foreground">
        Projects help you organise work and collaborate with your team.
        Get started in under a minute.
      </p>

      {/* 4. Primary action */}
      <Button>
        <PlusIcon className="mr-2 h-4 w-4" aria-hidden />
        New project
      </Button>
    </div>
  )
}

Empty State Taxonomy

CauseToneCTA Example
First-time (onboarding)Encouraging, welcoming"Create your first project"
No search resultsNeutral, helpful"Try different keywords or clear filters"
Error / failed to loadReassuring, recovery"Something went wrong — try again"
Permission / accessClear, non-alarming"Ask your admin for access"

Structure Checklist

[x] Icon or illustration (visual anchor)
[x] Headline — actionable, not clinical ("Create X" not "No X found")
[x] Description — 1-2 sentences of context
[x] Primary CTA button — one clear next step
[ ] Optional: secondary link for help docs

Key Decisions

DecisionRecommendation
Blank screenNever acceptable — always design an empty state
Loading orderSkeleton first → empty state if truly empty (no flash)
Headline toneEncouraging and action-oriented, never clinical
CTA countOne primary action maximum per empty state
Role attributerole="status" on the container for screen reader announcement
Cause-specificDifferent empty states for first-time, no-results, error, permissions

Build performant forms with React Hook Form v7 controlled renders and validation — HIGH

React Hook Form Patterns

Production form patterns with React Hook Form v7 for controlled rendering, field arrays, wizards, and file uploads.

Incorrect — uncontrolled form with useEffect fetch:

// WRONG: Fetching in useEffect, manual state management
function BadForm() {
  const [email, setEmail] = useState('');
  const [errors, setErrors] = useState({});

  useEffect(() => {
    // Manual validation on every render...
    if (!email.includes('@')) setErrors({ email: 'Invalid' });
  }, [email]);

  return <input value={email} onChange={(e) => setEmail(e.target.value)} />;
}

Correct — React Hook Form with Zod resolver:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const userSchema = z.object({
  email: z.string().email('Please enter a valid email'),
  password: z.string().min(8, 'Minimum 8 characters'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});

type UserForm = z.infer<typeof userSchema>;

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<UserForm>({
    resolver: zodResolver(userSchema),
    defaultValues: { email: '', password: '', confirmPassword: '' },
    mode: 'onBlur', // Validate on blur, not every keystroke
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input
        {...register('email')}
        aria-invalid={!!errors.email}
        aria-describedby={errors.email ? 'email-error' : undefined}
      />
      {errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Sign Up'}
      </button>
    </form>
  );
}

Field arrays for dynamic fields:

import { useFieldArray } from 'react-hook-form';

function OrderForm() {
  const { control, register } = useForm({
    defaultValues: { items: [{ productId: '', quantity: 1 }] },
  });
  const { fields, append, remove } = useFieldArray({ control, name: 'items' });

  return (
    <>
      {fields.map((field, index) => (
        <div key={field.id}> {/* Use field.id, NOT index */}
          <input {...register(`items.${index}.productId`)} />
          <input type="number" {...register(`items.${index}.quantity`, { valueAsNumber: true })} />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ productId: '', quantity: 1 })}>Add</button>
    </>
  );
}

Controller for third-party components:

import { Controller } from 'react-hook-form';

<Controller
  name="date"
  control={control}
  render={({ field, fieldState }) => (
    <DatePicker
      value={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
      error={fieldState.error?.message}
    />
  )}
/>

Key rules:

  • Always provide defaultValues (prevents uncontrolled-to-controlled warnings)
  • Use mode: 'onBlur' for better performance (not every keystroke)
  • Use field.id as key in field arrays, never index
  • Use Controller for non-native inputs (date pickers, selects, rich text)
  • Add noValidate to form element when using Zod (disable browser validation)
  • Use aria-invalid and role="alert" for accessibility

Validate forms with Zod type-safe schemas on both client and server sides — HIGH

Zod Validation & Server Actions

Type-safe validation with Zod schemas shared between client forms and React 19 Server Actions.

Incorrect — client-only validation without server check:

// WRONG: Client validation only — bypassable with DevTools
function ContactForm() {
  const handleSubmit = (e: FormEvent) => {
    if (!email.includes('@')) return; // Client-only, easily bypassed!
    fetch('/api/contact', { body: JSON.stringify({ email }) });
  };
}

Correct — shared Zod schema for client AND server:

// schemas/contact.ts — Shared validation (client + server)
import { z } from 'zod';

export const contactSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Please enter a valid email'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
});

export type ContactForm = z.infer<typeof contactSchema>;

Server Action with Zod validation (React 19):

// actions.ts
'use server';
import { contactSchema } from '@/schemas/contact';

export async function submitContact(formData: FormData) {
  const result = contactSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  });

  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }

  await saveContact(result.data);
  return { success: true };
}

// Component with useActionState
'use client';
import { useActionState } from 'react';
import { submitContact } from './actions';

function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, null);

  return (
    <form action={formAction}>
      <input name="name" />
      {state?.errors?.name && <span role="alert">{state.errors.name[0]}</span>}

      <input name="email" />
      {state?.errors?.email && <span role="alert">{state.errors.email[0]}</span>}

      <textarea name="message" />
      {state?.errors?.message && <span role="alert">{state.errors.message[0]}</span>}

      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

Advanced Zod patterns:

// Async validation (username availability)
const usernameSchema = z.object({
  username: z.string()
    .min(3, 'At least 3 characters')
    .refine(async (value) => {
      const available = await checkUsernameAvailability(value);
      return available;
    }, 'Username already taken'),
});

// Use mode: 'onBlur' to avoid async validation on every keystroke
useForm({ resolver: zodResolver(usernameSchema), mode: 'onBlur' });

// Transform + validate
const priceSchema = z.object({
  price: z.string()
    .transform((val) => parseFloat(val))
    .pipe(z.number().positive('Must be positive')),
});

// Discriminated union for conditional fields
const paymentSchema = z.discriminatedUnion('method', [
  z.object({ method: z.literal('card'), cardNumber: z.string().length(16) }),
  z.object({ method: z.literal('paypal'), paypalEmail: z.string().email() }),
]);

Key rules:

  • Share Zod schemas between client and server — single source of truth
  • Always validate on server even if client validation passes (never trust client)
  • Use safeParse (not parse) for server actions to return errors instead of throwing
  • Use z.infer&lt;typeof schema&gt; for automatic TypeScript types
  • Async validation: combine with mode: 'onBlur' to avoid excessive API calls
  • Custom error messages in every .email(), .min(), .refine() call

Compose polymorphic components using Radix asChild pattern and nested triggers — HIGH

Radix Composition Patterns

Polymorphic rendering with asChild, Slot, nested triggers, and ref forwarding.

asChild Pattern

The asChild prop renders children as the component itself, merging props and refs:

// Without asChild - nested elements
<Button>
  <Link href="/about">About</Link>
</Button>
// Renders: <button><a href="/about">About</a></button>

// With asChild - single element
<Button asChild>
  <Link href="/about">About</Link>
</Button>
// Renders: <a href="/about" class="button-styles">About</a>

How it works (via Radix Slot):

  1. Props merging: Parent props spread to child
  2. Ref forwarding: Refs correctly forwarded
  3. Event combining: Both onClick handlers fire
  4. Class merging: ClassNames combined

Nested Composition

Combine multiple Radix triggers on a single element:

import { Dialog, Tooltip } from 'radix-ui'

const MyButton = React.forwardRef((props, ref) => (
  <button {...props} ref={ref} />
))

export function DialogWithTooltip() {
  return (
    <Dialog.Root>
      <Tooltip.Root>
        <Tooltip.Trigger asChild>
          <Dialog.Trigger asChild>
            <MyButton>Open dialog</MyButton>
          </Dialog.Trigger>
        </Tooltip.Trigger>
        <Tooltip.Portal>
          <Tooltip.Content>Click to open dialog</Tooltip.Content>
        </Tooltip.Portal>
      </Tooltip.Root>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>Dialog content</Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Common Patterns

<Button asChild variant="outline">
  <Link href="/settings">Settings</Link>
</Button>

Icon Button

<Button asChild size="icon">
  <a href="https://github.com" target="_blank">
    <GitHubIcon />
  </a>
</Button>
<DropdownMenu.Item asChild>
  <Link href="/profile">Profile</Link>
</DropdownMenu.Item>

Polymorphic Extension with Slot

import { Slot } from '@radix-ui/react-slot'

interface IconButtonProps
  extends React.ComponentPropsWithoutRef<typeof Button> {
  icon: React.ReactNode
  label: string
}

const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
  ({ icon, label, asChild, ...props }, ref) => (
    <Button ref={ref} size="icon" aria-label={label} asChild={asChild} {...props}>
      {asChild ? <Slot>{icon}</Slot> : icon}
    </Button>
  )
)

When to Use asChild

Use CaseUse asChild?
Link styled as buttonYes
Combining triggersYes
Custom element with Radix behaviorYes
Default element is fineNo
Adds complexity without benefitNo

Requirements for Child Components

The child component MUST:

  1. Forward refs with React.forwardRef
  2. Spread props to underlying element
  3. Be a single element (not fragment)
// CORRECT - forwards ref and spreads props
const MyButton = React.forwardRef<HTMLButtonElement, Props>(
  (props, ref) => <button ref={ref} {...props} />
)

// INCORRECT - no ref forwarding
const MyButton = (props) => <button {...props} />

Primitives Catalog

Overlay Components

PrimitiveUse Case
DialogModal dialogs, forms, confirmations
AlertDialogDestructive action confirmations
SheetSide panels, mobile drawers

Popover Components

PrimitiveUse Case
PopoverRich content on trigger
TooltipSimple text hints
HoverCardPreview cards on hover
ContextMenuRight-click menus
PrimitiveUse Case
DropdownMenuAction menus
MenubarApplication menubars
NavigationMenuSite navigation

Form Components

PrimitiveUse Case
SelectCustom select dropdowns
RadioGroupSingle selection groups
CheckboxBoolean toggles
SwitchOn/off toggles
SliderRange selection

Incorrect — No ref forwarding:

// asChild won't work - no ref support
const MyButton = (props) => <button {...props} />

<Button asChild>
  <MyButton>Click</MyButton>
</Button>

Correct — Ref forwarding required:

// Forwards ref and spreads props
const MyButton = React.forwardRef<HTMLButtonElement, Props>(
  (props, ref) => <button ref={ref} {...props} />
)

<Button asChild>
  <MyButton>Click</MyButton>
</Button>

Disclosure Components

PrimitiveUse Case
AccordionExpandable sections
CollapsibleSingle toggle content
TabsTabbed interfaces

Build accessible modal dialogs with Radix primitives and focus management — HIGH

Radix Dialog Patterns

Accessible modal dialogs with Radix primitives, including Dialog and AlertDialog.

Dialog vs AlertDialog

FeatureDialogAlertDialog
Close on overlay clickYesNo
Close on EscapeYesRequires explicit action
Use caseForms, contentDestructive confirmations

Basic Dialog

import { Dialog } from 'radix-ui'

export function BasicDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <Button>Edit Profile</Button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50" />
        <Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6">
          <Dialog.Title>Edit Profile</Dialog.Title>
          <Dialog.Description>
            Make changes to your profile here.
          </Dialog.Description>
          {/* Form content */}
          <Dialog.Close asChild>
            <Button>Save changes</Button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Controlled Dialog

export function ControlledDialog() {
  const [open, setOpen] = useState(false)

  const handleSubmit = async () => {
    await saveData()
    setOpen(false)
  }

  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger asChild>
        <Button>Open</Button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <form onSubmit={handleSubmit}>
            {/* Form fields */}
            <Button type="submit">Save</Button>
          </form>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

AlertDialog (Destructive Actions)

import { AlertDialog } from 'radix-ui'

export function DeleteConfirmation({ onDelete }) {
  return (
    <AlertDialog.Root>
      <AlertDialog.Trigger asChild>
        <Button variant="destructive">Delete</Button>
      </AlertDialog.Trigger>
      <AlertDialog.Portal>
        <AlertDialog.Overlay className="fixed inset-0 bg-black/50" />
        <AlertDialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6">
          <AlertDialog.Title>Are you sure?</AlertDialog.Title>
          <AlertDialog.Description>
            This action cannot be undone.
          </AlertDialog.Description>
          <div className="flex gap-4 justify-end">
            <AlertDialog.Cancel asChild>
              <Button variant="outline">Cancel</Button>
            </AlertDialog.Cancel>
            <AlertDialog.Action asChild>
              <Button variant="destructive" onClick={onDelete}>
                Delete
              </Button>
            </AlertDialog.Action>
          </div>
        </AlertDialog.Content>
      </AlertDialog.Portal>
    </AlertDialog.Root>
  )
}

Abstracting Dialog Components

Create reusable wrappers for consistent styling:

import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'

export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
export const DialogClose = DialogPrimitive.Close

export const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ children, className, ...props }, ref) => (
  <DialogPrimitive.Portal>
    <DialogPrimitive.Overlay
      className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out"
    />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
        'bg-background rounded-lg shadow-lg p-6 w-full max-w-lg',
        'data-[state=open]:animate-in data-[state=closed]:animate-out',
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close className="absolute right-4 top-4">
        <X className="h-4 w-4" />
        <span className="sr-only">Close</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPrimitive.Portal>
))

Animation with data-state

[data-state="open"] .overlay {
  animation: fadeIn 150ms ease-out;
}
[data-state="closed"] .overlay {
  animation: fadeOut 150ms ease-in;
}

[data-state="open"] .content {
  animation: scaleIn 150ms ease-out;
}
[data-state="closed"] .content {
  animation: scaleOut 150ms ease-in;
}

@keyframes fadeIn { from { opacity: 0; } }
@keyframes fadeOut { to { opacity: 0; } }
@keyframes scaleIn { from { transform: scale(0.95); opacity: 0; } }
@keyframes scaleOut { to { transform: scale(0.95); opacity: 0; } }

Incorrect — Using Dialog for destructive actions:

// Dialog closes on overlay click - unsafe for deletion
<Dialog.Root>
  <Dialog.Trigger>Delete Account</Dialog.Trigger>
  <Dialog.Content>
    <p>Delete your account?</p>
    <Button onClick={deleteAccount}>Yes, delete</Button>
  </Dialog.Content>
</Dialog.Root>

Correct — AlertDialog for destructive actions:

// AlertDialog requires explicit action - safe
<AlertDialog.Root>
  <AlertDialog.Trigger>Delete Account</AlertDialog.Trigger>
  <AlertDialog.Content>
    <AlertDialog.Title>Are you sure?</AlertDialog.Title>
    <AlertDialog.Action onClick={deleteAccount}>Delete</AlertDialog.Action>
  </AlertDialog.Content>
</AlertDialog.Root>

Accessibility Built-in

  • Focus trapped within dialog
  • Focus returns to trigger on close
  • Escape closes dialog
  • Click outside closes (Dialog only)
  • Proper ARIA attributes
  • Screen reader announcements

Style Radix components using data attributes and state-driven focus management — HIGH

Radix Styling & Focus Management

Data attributes, Tailwind arbitrary variants, keyboard navigation, and focus management.

Styling with Data Attributes

Radix exposes state via data attributes for CSS styling:

/* Style based on state */
[data-state="open"] { /* open styles */ }
[data-state="closed"] { /* closed styles */ }
[data-disabled] { /* disabled styles */ }
[data-highlighted] { /* keyboard focus */ }
// Tailwind arbitrary variants
<Dialog.Content className="data-[state=open]:animate-in data-[state=closed]:animate-out">
const DropdownMenuContent = React.forwardRef(
  ({ className, sideOffset = 4, ...props }, ref) => (
    <DropdownMenuPrimitive.Portal>
      <DropdownMenuPrimitive.Content
        ref={ref}
        sideOffset={sideOffset}
        className={cn(
          'z-50 min-w-[8rem] rounded-md border bg-popover p-1 shadow-md',
          'data-[state=open]:animate-in data-[state=closed]:animate-out',
          'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
          'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
          'data-[side=bottom]:slide-in-from-top-2',
          'data-[side=left]:slide-in-from-right-2',
          'data-[side=right]:slide-in-from-left-2',
          'data-[side=top]:slide-in-from-bottom-2',
          className
        )}
        {...props}
      />
    </DropdownMenuPrimitive.Portal>
  )
)

const DropdownMenuItem = React.forwardRef(
  ({ className, ...props }, ref) => (
    <DropdownMenuPrimitive.Item
      ref={ref}
      className={cn(
        'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
        'focus:bg-accent focus:text-accent-foreground',
        'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
        className
      )}
      {...props}
    />
  )
)

Popover and Tooltip

// Tooltip with Provider for shared configuration
<Tooltip.Provider delayDuration={300}>
  <Tooltip.Root>
    <Tooltip.Trigger asChild>
      <Button size="icon" variant="ghost">
        <Settings className="h-4 w-4" />
      </Button>
    </Tooltip.Trigger>
    <Tooltip.Portal>
      <Tooltip.Content
        className="bg-gray-900 text-white px-3 py-1.5 rounded text-sm"
        sideOffset={5}
      >
        Settings
        <Tooltip.Arrow className="fill-gray-900" />
      </Tooltip.Content>
    </Tooltip.Portal>
  </Tooltip.Root>
</Tooltip.Provider>

Positioning Props

<Content
  side="top"              // top | right | bottom | left
  sideOffset={5}          // Distance from trigger
  align="center"          // start | center | end
  alignOffset={0}         // Offset from alignment
  avoidCollisions={true}  // Flip if clipped
  collisionPadding={8}    // Viewport padding
/>

Side-Aware Animations

[data-state="open"] { animation: fadeIn 200ms ease-out; }
[data-state="closed"] { animation: fadeOut 150ms ease-in; }

[data-side="top"] { animation-name: slideFromBottom; }
[data-side="bottom"] { animation-name: slideFromTop; }
[data-side="left"] { animation-name: slideFromRight; }
[data-side="right"] { animation-name: slideFromLeft; }

Built-in Accessibility

Every Radix primitive includes:

  • Keyboard navigation: Arrow keys, Escape, Enter, Tab
  • Focus management: Trap, return, visible focus rings
  • ARIA attributes: Roles, states, properties
  • Screen reader: Proper announcements

Focus Management

Dialog Focus Trap

<Dialog.Content
  onOpenAutoFocus={(event) => {
    event.preventDefault()
    document.getElementById('email-input')?.focus()
  }}
  onCloseAutoFocus={(event) => {
    event.preventDefault()
    document.getElementById('other-element')?.focus()
  }}
>

Escape Key Handling

<Dialog.Content
  onEscapeKeyDown={(event) => {
    if (hasUnsavedChanges) {
      event.preventDefault()
      showConfirmDialog()
    }
  }}
>

Focus Visible Styling

[data-focus-visible] {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">

Incorrect — Manual state styling:

// Hard to maintain, breaks when state changes
<div className={isOpen ? "opacity-100" : "opacity-0"}>

Correct — Data attribute styling:

// Uses Radix's built-in state tracking
<Dialog.Content className="data-[state=open]:animate-in data-[state=closed]:animate-out">

Keyboard Shortcuts Reference

ComponentKeyAction
DialogEscapeClose
MenuArrow Up/DownNavigate
MenuEnter/SpaceSelect
MenuRight ArrowOpen submenu
MenuLeft ArrowClose submenu
TabsArrow Left/RightSwitch tab
RadioGroupArrow Up/DownChange selection
SelectArrow Up/DownNavigate options
SelectEnterSelect option

Customize shadcn/ui components with CVA variants, tailwind-merge, and OKLCH theming — HIGH

shadcn/ui Customization

CVA variant system, cn() class merging, OKLCH theming, and component extension patterns.

CVA (Class Variance Authority)

Declarative, type-safe variant definitions:

import { cva, type VariantProps } from 'class-variance-authority'

const buttonVariants = cva(
  // Base classes (always applied)
  'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'h-10 w-10',
      },
    },
    compoundVariants: [
      { variant: 'outline', size: 'lg', className: 'border-2' },
    ],
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
)

// Type-safe props
interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

Boolean Variants

const cardVariants = cva(
  'rounded-lg border bg-card text-card-foreground',
  {
    variants: {
      elevated: {
        true: 'shadow-lg',
        false: 'shadow-none',
      },
      interactive: {
        true: 'cursor-pointer hover:bg-accent transition-colors',
        false: '',
      },
    },
    defaultVariants: { elevated: false, interactive: false },
  }
)

cn() Utility

Combines clsx + tailwind-merge for conflict resolution:

import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

// Usage - later classes win
cn('px-4 py-2', 'px-6')  // => 'py-2 px-6'
cn('text-red-500', condition && 'text-blue-500')

// With CVA variants
cn(buttonVariants({ variant, size }), className)

OKLCH Theming (2026 Standard)

Modern perceptually uniform color space:

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --border: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --radius: 0.625rem;
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.985 0 0);
  --destructive: oklch(0.396 0.141 25.723);
}

Why OKLCH? Perceptually uniform (equal steps look equal), better dark mode contrast, wide gamut support. Format: oklch(lightness chroma hue).

Component Extension Strategy

Wrap, don't modify source:

import { Button as ShadcnButton } from '@/components/ui/button'

interface ExtendedButtonProps
  extends React.ComponentPropsWithoutRef<typeof ShadcnButton> {
  loading?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ExtendedButtonProps>(
  ({ loading, disabled, children, ...props }, ref) => (
    <ShadcnButton ref={ref} disabled={disabled || loading} {...props}>
      {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
      {children}
    </ShadcnButton>
  )
)
Button.displayName = 'Button'

CLI Quick Reference

npx shadcn@latest init        # Initialize in project
npx shadcn@latest add button  # Add components
npx shadcn@latest add dialog card input label

Incorrect — Modifying shadcn source files:

// Editing components/ui/button.tsx directly
const buttonVariants = cva('...', {
  variants: {
    myCustomVariant: '...'  // Modified source!
  }
})

Correct — Wrap, don't modify:

// Create wrapper component
import { Button as ShadcnButton } from '@/components/ui/button'

interface ExtendedButtonProps extends React.ComponentPropsWithoutRef<typeof ShadcnButton> {
  loading?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ExtendedButtonProps>(
  ({ loading, ...props }, ref) => (
    <ShadcnButton ref={ref} {...props}>
      {loading && <Loader />}
      {props.children}
    </ShadcnButton>
  )
)

Best Practices

  1. Keep variants focused: Each variant should have a single responsibility
  2. Always forward refs: Components may need ref access
  3. Use compound variants sparingly: Only for complex combinations
  4. Type exports: Export VariantProps type for consumers
  5. Preserve displayName: Helps with debugging

Build sortable, filterable data tables with TanStack Table and shadcn/ui integration — HIGH

shadcn/ui Data Table

TanStack Table integration with shadcn/ui for sortable, filterable data tables.

Basic Table Structure

import {
  Table, TableBody, TableCell, TableHead,
  TableHeader, TableRow,
} from '@/components/ui/table'
import {
  useReactTable, getCoreRowModel, getSortedRowModel,
  getFilteredRowModel, getPaginationRowModel,
  flexRender, type ColumnDef, type SortingState,
} from '@tanstack/react-table'

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
}

export function DataTable<TData, TValue>({
  columns, data,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = useState<SortingState>([])
  const [columnFilters, setColumnFilters] = useState([])

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    state: { sorting, columnFilters },
  })

  return (
    <div>
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <TableHead key={header.id}>
                  {flexRender(
                    header.column.columnDef.header,
                    header.getContext()
                  )}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.map((row) => (
            <TableRow key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <TableCell key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </Table>

      {/* Pagination */}
      <div className="flex items-center justify-end gap-2 py-4">
        <Button
          variant="outline" size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </Button>
        <Button
          variant="outline" size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </Button>
      </div>
    </div>
  )
}

Column Definitions

const columns: ColumnDef<User>[] = [
  {
    accessorKey: 'name',
    header: ({ column }) => (
      <Button
        variant="ghost"
        onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
      >
        Name
        <ArrowUpDown className="ml-2 h-4 w-4" />
      </Button>
    ),
  },
  {
    accessorKey: 'email',
    header: 'Email',
  },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => {
      const status = row.getValue('status') as string
      return (
        <Badge variant={status === 'active' ? 'default' : 'secondary'}>
          {status}
        </Badge>
      )
    },
  },
  {
    id: 'actions',
    cell: ({ row }) => (
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" size="icon">
            <MoreHorizontal className="h-4 w-4" />
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuItem>Edit</DropdownMenuItem>
          <DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    ),
  },
]

Column Filtering

<Input
  placeholder="Filter by name..."
  value={(table.getColumn('name')?.getFilterValue() as string) ?? ''}
  onChange={(e) => table.getColumn('name')?.setFilterValue(e.target.value)}
  className="max-w-sm"
/>

Incorrect — Missing flexRender:

// Direct rendering breaks custom cells
<TableCell>{cell.column.columnDef.cell}</TableCell>

Correct — Use flexRender:

// Properly renders custom cell components
<TableCell>
  {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>

Dependencies

npm install @tanstack/react-table   # Table state management
npx shadcn@latest add table badge dropdown-menu button input

Create accessible shadcn/ui form fields with proper labels and validation errors — HIGH

shadcn/ui Form Patterns

Form field wrappers, validation states, and react-hook-form integration.

Form Field Wrapper

Reusable wrapper that associates Label, Input, and error messages:

import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'

interface FormFieldProps extends React.ComponentPropsWithoutRef<typeof Input> {
  label: string
  error?: string
  description?: string
}

const FormField = React.forwardRef<HTMLInputElement, FormFieldProps>(
  ({ label, error, description, className, id, ...props }, ref) => {
    const inputId = id || label.toLowerCase().replace(/\s+/g, '-')

    return (
      <div className={cn('space-y-2', className)}>
        <Label htmlFor={inputId}>{label}</Label>
        <Input
          ref={ref}
          id={inputId}
          aria-describedby={error ? `${inputId}-error` : undefined}
          aria-invalid={!!error}
          className={cn(error && 'border-destructive')}
          {...props}
        />
        {description && !error && (
          <p className="text-sm text-muted-foreground">{description}</p>
        )}
        {error && (
          <p id={`${inputId}-error`} className="text-sm text-destructive">
            {error}
          </p>
        )}
      </div>
    )
  }
)

Input with States

function Input({ className, error, ...props }) {
  return (
    <input
      className={cn(
        'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
        'file:border-0 file:bg-transparent file:text-sm file:font-medium',
        'placeholder:text-muted-foreground',
        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
        'disabled:cursor-not-allowed disabled:opacity-50',
        error && 'border-destructive focus-visible:ring-destructive',
        className
      )}
      {...props}
    />
  )
}

Confirm Dialog (Form Composition)

import {
  Dialog, DialogContent, DialogHeader, DialogTitle,
  DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'

interface ConfirmDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: string
  description: string
  onConfirm: () => void
  variant?: 'default' | 'destructive'
}

export function ConfirmDialog({
  open, onOpenChange, title, description,
  onConfirm, variant = 'default',
}: ConfirmDialogProps) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <Button variant="outline" onClick={() => onOpenChange(false)}>
            Cancel
          </Button>
          <Button
            variant={variant === 'destructive' ? 'destructive' : 'default'}
            onClick={() => { onConfirm(); onOpenChange(false) }}
          >
            Confirm
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Dark Mode Toggle

'use client'

import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
import {
  DropdownMenu, DropdownMenuContent,
  DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'

export function ModeToggle() {
  const { setTheme } = useTheme()

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Hydration Safety

'use client'

import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'

export function ThemeAwareComponent() {
  const { resolvedTheme } = useTheme()
  const [mounted, setMounted] = useState(false)

  useEffect(() => { setMounted(true) }, [])

  if (!mounted) return <Skeleton />

  return <div>Current theme: {resolvedTheme}</div>
}

Incorrect — Missing label association:

// Not accessible - label not linked to input
<div>
  <label>Email</label>
  <input type="email" />
</div>

Correct — Proper label association:

// Accessible with htmlFor and id
<div>
  <Label htmlFor="email">Email</Label>
  <Input
    id="email"
    type="email"
    aria-describedby={error ? "email-error" : undefined}
  />
  {error && <p id="email-error">{error}</p>}
</div>

Dependencies

npm install next-themes              # Theme switching
npm install class-variance-authority # CVA variants
npm install clsx tailwind-merge      # Class merging
npm install react-hook-form          # Form management
npm install lucide-react             # Icons

shadcn/ui v4 Style System — 6 Styles + Preset Codes — HIGH

shadcn/ui v4 Style System

shadcn CLI v4 ships 6 visual styles that rewrite component class names — not just CSS variables. Each style defines its own radius, elevation, spacing, and visual weight. Detect the project's style from components.json and apply the correct classes.

Incorrect — hardcoding classes without checking project style:

// Assumes rounded-md everywhere — wrong for Luma (rounded-4xl) or Lyra (rounded-none)
const Card = ({ children }: CardProps) => (
  <div className="rounded-lg border p-4 shadow-sm">
    {children}
  </div>
)

Correct — reading project style and applying matching classes:

// 1. Detect style: Read components.json → "style" field
//    "radix-luma" | "radix-vega" | "base-nova" | etc.

// 2. Apply style-correct classes:
// Luma:  rounded-4xl, shadow-md + ring-1 ring-foreground/5, gap-6 py-6
// Vega:  rounded-lg, shadow-sm, gap-4 py-4 (balanced, general purpose)
// Nova:  rounded-md, no shadow, px-2 py-1 (compact dashboards)
// Maia:  rounded-xl, shadow-sm, gap-5 py-5 (soft, consumer)
// Lyra:  rounded-none, no shadow, gap-4 py-4 (sharp, editorial)
// Mira:  rounded-sm, no shadow, px-1 py-0.5 (ultra-dense)

const Card = ({ children }: CardProps) => (
  <div className="rounded-4xl border shadow-md ring-1 ring-foreground/5 p-6">
    {children}
  </div>
)

Preset Codes

All style + theme + font + icon choices encode into a shareable 7-char preset code:

Incorrect — using deprecated style names:

{
  "style": "new-york"
}

Correct — using v4 style names and preset codes:

# Initialize with preset (encodes all 10 design system params)
npx shadcn@latest init --preset b2D0xPaDb

# Preview changes before applying
npx shadcn@latest add button --diff
{
  "style": "radix-luma"
}

Style Detection Pattern

import { readFileSync } from 'fs'

// Read components.json to detect active style
const config = JSON.parse(readFileSync('components.json', 'utf-8'))
const style = config.style // "radix-luma", "base-nova", etc.
const styleName = style.split('-').pop() // "luma", "nova", etc.

Style Reference

StyleRadiusElevationSpacingBest For
Vegarounded-lgshadow-smBalancedGeneral purpose
Novarounded-mdNoneCompactDense dashboards
Maiarounded-xlshadow-smGenerousConsumer apps
Lyrarounded-noneNoneBalancedEditorial, dev tools
Mirarounded-smNoneUltra-denseSpreadsheets, data
Lumarounded-4xlshadow-md + ringBreathablePolished native-app

Configure visually at ui.shadcn.com/create. Old "new-york" and "default" are superseded by Vega.

Storybook CSF3 stories with play() interaction tests and Chromatic visual regression — HIGH

Storybook Component Documentation (2026)

Every component state should be a story, every story a visual test. Use CSF3 format, play() functions for interaction testing, and Chromatic for CI visual regression.

CSF3 Story Format

// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'

const meta = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: { control: 'select', options: ['default', 'destructive', 'outline'] },
    size: { control: 'select', options: ['sm', 'default', 'lg'] },
  },
} satisfies Meta<typeof Button>

export default meta
type Story = StoryObj<typeof meta>

// One story per visual state
export const Default: Story = {
  args: { children: 'Click me', variant: 'default' },
}

export const Destructive: Story = {
  args: { children: 'Delete', variant: 'destructive' },
}

export const Loading: Story = {
  args: { children: 'Saving...', loading: true, disabled: true },
}

play() Functions for Interaction Testing

Test component behavior in isolation without a full E2E framework:

import { expect, userEvent, within } from '@storybook/test'

export const FormSubmission: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
    await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com')
    await userEvent.type(canvas.getByLabelText('Password'), 'securepass')
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }))
    await expect(canvas.getByText('Welcome back!')).toBeInTheDocument()
  },
}

Chromatic CI Visual Regression

# .github/workflows/chromatic.yml
- uses: chromaui/action@latest
  with:
    projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
    onlyChanged: true        # Only snapshot changed stories
    exitZeroOnChanges: true   # Don't fail CI, flag for review

Incorrect -- No stories, manual visual testing only

// Component exists but no stories
// "I'll just check it in the browser"
// No automated visual regression — bugs ship silently

// Or: stories without interaction coverage
export const Default: Story = { args: { open: true } }
// Never tests open/close flow, form validation, error states

Correct -- Story per state, play() for interactions, Chromatic in CI

// Every meaningful state is a story
export const Empty: Story = { args: { items: [] } }
export const WithItems: Story = { args: { items: mockItems } }
export const Loading: Story = { args: { loading: true } }
export const Error: Story = { args: { error: 'Failed to load' } }

// Interactive flows tested with play()
export const AddItem: Story = {
  args: { items: [] },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
    await userEvent.click(canvas.getByRole('button', { name: /add/i }))
    await expect(canvas.getByText('New Item')).toBeInTheDocument()
  },
}

Key Rules

  • Use CSF3 format (satisfies Meta&lt;typeof Component&gt;) for type safety
  • Add tags: ['autodocs'] for automatic documentation generation
  • Create one story per meaningful visual state (empty, loading, error, populated)
  • Use play() functions to test interactions without E2E overhead
  • Run Chromatic in CI for automated visual regression on every PR
  • Keep stories co-located with components (Component.stories.tsx)

Reference: Storybook Docs

Tailwind v4 CSS-first configuration and native container queries — HIGH

Tailwind v4 Patterns (2026)

Tailwind v4 moves configuration to CSS, drops tailwind.config.js, and adds native container query support without plugins.

CSS-First Configuration

All theme customization lives in CSS via @theme:

/* app.css — replaces tailwind.config.js */
@import "tailwindcss";

@theme {
  --color-primary: oklch(0.6 0.2 250);
  --color-secondary: oklch(0.7 0.15 200);
  --font-sans: "Inter", system-ui, sans-serif;
  --radius-lg: 0.75rem;
  --breakpoint-xs: 30rem;
}

No tailwind.config.js, no resolveConfig, no JavaScript theme access at build time.

Native Container Queries

Container queries are built-in — no @tailwindcss/container-queries plugin needed.

Basic Usage

{/* Parent declares containment */}
<div className="@container">
  {/* Children respond to parent's width */}
  <div className="flex flex-col @md:flex-row @lg:grid @lg:grid-cols-3">
    <Card />
  </div>
</div>

Named Containers

{/* Named container */}
<div className="@container/card">
  <p className="text-sm @md/card:text-base @lg/card:text-lg">
    Responds to the card container width
  </p>
</div>

Max-Width Container Queries

{/* Max-width variant — styles apply below the breakpoint */}
<div className="@container">
  <nav className="@max-md:hidden">Desktop only nav</nav>
  <nav className="@md:hidden">Mobile nav</nav>
</div>

Incorrect -- Using tailwind.config.js in v4

// tailwind.config.js — WRONG in v4, this file is ignored
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: '#3b82f6',
      },
    },
  },
  plugins: [
    require('@tailwindcss/container-queries'), // Plugin not needed in v4
  ],
}
// Using plugin-based container syntax — unnecessary in v4
<div className="@container">
  <div className="@[480px]:flex"> {/* Old plugin syntax */}
    Content
  </div>
</div>

Correct -- CSS-first @theme and native @container

/* app.css */
@import "tailwindcss";

@theme {
  --color-primary: oklch(0.6 0.2 250);
  --color-surface: oklch(0.98 0 0);
}
<div className="@container/sidebar">
  <div className="flex flex-col @sm/sidebar:flex-row @md/sidebar:grid @md/sidebar:grid-cols-2">
    <Widget className="@max-sm/sidebar:p-2 @sm/sidebar:p-4" />
  </div>
</div>

Key Rules

  • Use @theme in CSS for all configuration — no tailwind.config.js
  • Container queries are native — do not install @tailwindcss/container-queries
  • Use @sm:, @md:, @lg: variants for container-width breakpoints
  • Use @max-*: variants for max-width container queries
  • Name containers with @container/&lt;name&gt; for targeted queries
  • Use /name suffix on variants to target specific named containers
  • Migrate existing tailwind.config.js to @theme block when upgrading to v4

Reference: Tailwind CSS v4 Docs

Apply numeric typography thresholds for line length, line height, and font scale — HIGH

Typography Thresholds

Incorrect — unconstrained text and cascading em bugs:

// WRONG: Unbounded paragraph width causes too-long lines (eye fatigue)
<p className="w-full text-base">Long body copy...</p>

// WRONG: em units for font-size cause cascading multiplication
<div style={{ fontSize: '1.2em' }}>
  <p style={{ fontSize: '1.2em' }}>  {/* Actually 1.44× root — bug! */}
    Nested text
  </p>
</div>

// WRONG: Line height too tight for body text
<p className="leading-tight text-base">Body copy with 1.25 line height</p>

// WRONG: Font weight 500 for "emphasis" — too subtle
<strong className="font-medium">Important</strong>

// WRONG: Inline links without underline (accessibility failure)
<a className="text-primary no-underline">Click here</a>

Correct — constrained width, rem scale, proper line height:

// RIGHT: Max-width on paragraph containers (50-75ch ideal, 65ch default)
<p className="max-w-prose text-base leading-relaxed">
  Body copy with constrained line length and proper line height.
</p>

// RIGHT: Heading gets tighter line height
<h1 className="text-3xl font-bold leading-tight">Page Heading</h1>
<h2 className="text-2xl font-semibold leading-snug">Section Title</h2>

// RIGHT: Inline links always underlined
<a className="text-primary underline underline-offset-2 hover:text-primary/80">
  Inline link
</a>

Type Scale (Tailwind — modular, 1.25 Major Third ratio)

/* In your global CSS or @theme block */
@theme {
  --font-size-xs:   0.64rem;   /* ~10px */
  --font-size-sm:   0.8rem;    /* ~13px */
  --font-size-base: 1rem;      /* 16px  */
  --font-size-lg:   1.25rem;   /* ~20px */
  --font-size-xl:   1.563rem;  /* ~25px */
  --font-size-2xl:  1.953rem;  /* ~31px */
  --font-size-3xl:  2.441rem;  /* ~39px */
}

Threshold Reference

PropertyThresholdNotes
Line length — print50–75 chUse max-w-prose (65ch)
Line length — screen60–100 chUI panels can go wider
Line height — body1.4–1.6×leading-relaxed = 1.625
Line height — headings1.2–1.3×leading-tight = 1.25
Line height — minimum1.2×Never below this
Font weight — body400 (regular)font-normal
Font weight — emphasis600+ (semibold)font-semibold minimum
Font weight — headings700 (bold)font-bold
Font size unitsrem onlyNever em for font-size

Key Decisions

DecisionRecommendation
Font size unitsrem only — em cascades multiplicatively
Line lengthmax-w-prose (65ch) on all paragraph containers
Link underlinesAlways underlined for inline links — no exceptions
Type scaleDerive all sizes from a modular scale ratio (1.25 or 1.333)
UI fontSans-serif for UI chrome; proportional serif optional for long-form

Design visual hierarchy using weight, contrast, and spatial relationships — not color alone — HIGH

Visual Hierarchy & Layout

Incorrect — competing primaries and flat hierarchy:

// WRONG: Two primary buttons side-by-side (creates decision paralysis)
<div className="flex gap-2">
  <button className="bg-primary text-white px-4 py-2 rounded">Save</button>
  <button className="bg-primary text-white px-4 py-2 rounded">Cancel</button>
</div>

// WRONG: Emphasizing everything equally — nothing stands out
<h1 className="font-bold text-xl">Title</h1>
<p className="font-bold text-xl">Body copy that competes with heading</p>
<span className="font-bold text-xl">Label also fighting for attention</span>

Correct — three-tier button hierarchy with de-emphasized secondary:

// RIGHT: One primary CTA. Secondary is outlined. Tertiary is ghost/text.
<div className="flex items-center gap-3">
  {/* Primary — full color, highest visual weight */}
  <Button variant="default">Save changes</Button>

  {/* Secondary — outline, reduced weight */}
  <Button variant="outline">Preview</Button>

  {/* Tertiary — ghost/text, lowest weight */}
  <Button variant="ghost">Cancel</Button>
</div>

// RIGHT: De-emphasize secondary content rather than only boosting primary
<h1 className="text-2xl font-bold text-foreground">Page title</h1>
<p className="text-base text-muted-foreground">Supporting description</p>
<span className="text-sm text-muted-foreground/70">Metadata label</span>

Hierarchy Principles

PrincipleRuleRationale
Button tiersprimary → outline → ghostOne primary per view maximum
De-emphasisMute secondary contentEasier than boosting everything
F/Z scan pathCritical info top-left → rightMatches natural eye movement
Von RestorffIsolate ONE element per viewUniqueness signals importance
ProximityGroup related elements closelySpacing communicates relationship
Max-widthContain layout, don't fill screenPrevents unreadable line lengths

Layout Rules

// RIGHT: Contain content width for readability
<main className="max-w-4xl mx-auto px-4">
  {/* Never stretch to 100vw on wide screens */}
</main>

// RIGHT: Use deliberate spacing to show/break relationships
<section className="space-y-1">   {/* Tight = related items */}
  <label>Email</label>
  <input type="email" />
</section>
<section className="mt-8">       {/* Gap = new section */}
  <label>Password</label>
  <input type="password" />
</section>

Key Decisions

DecisionRecommendation
Grayscale testDesign in grayscale first — hierarchy must work without color
Button countMaximum ONE primary button per view
Emphasis strategyDe-emphasize secondary; don't over-emphasize primary
Von RestorffUse isolation sparingly — max one "different" element per view
Readability capmax-w-prose or max-w-4xl on all content containers

References (10)

Aschild Composition

asChild Composition Pattern

Polymorphic rendering without wrapper divs.

What is asChild?

The asChild prop renders children as the component itself, merging props and refs. This avoids extra DOM elements while preserving functionality.

Basic Usage

import { Button } from '@/components/ui/button'
import Link from 'next/link'

// Without asChild - nested elements
<Button>
  <Link href="/about">About</Link>
</Button>
// Renders: <button><a href="/about">About</a></button>

// With asChild - single element
<Button asChild>
  <Link href="/about">About</Link>
</Button>
// Renders: <a href="/about" class="button-styles">About</a>

How It Works

Under the hood, asChild uses Radix's Slot component:

  1. Props merging: Parent props spread to child
  2. Ref forwarding: Refs correctly forwarded
  3. Event combining: Both onClick handlers fire
  4. Class merging: ClassNames combined
// Internal implementation concept
function Slot({ children, ...props }) {
  return React.cloneElement(children, {
    ...props,
    ...children.props,
    ref: mergeRefs(props.ref, children.ref),
    className: cn(props.className, children.props.className),
    onClick: chain(props.onClick, children.props.onClick),
  })
}

Nested Composition

Combine multiple Radix triggers:

import { Dialog, Tooltip } from 'radix-ui'

const MyButton = React.forwardRef((props, ref) => (
  <button {...props} ref={ref} />
))

export function DialogWithTooltip() {
  return (
    <Dialog.Root>
      <Tooltip.Root>
        <Tooltip.Trigger asChild>
          <Dialog.Trigger asChild>
            <MyButton>Open dialog</MyButton>
          </Dialog.Trigger>
        </Tooltip.Trigger>
        <Tooltip.Portal>
          <Tooltip.Content>Click to open dialog</Tooltip.Content>
        </Tooltip.Portal>
      </Tooltip.Root>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>Dialog content</Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Common Patterns

<Button asChild variant="outline">
  <Link href="/settings">Settings</Link>
</Button>

Icon Button

<Button asChild size="icon">
  <a href="https://github.com" target="_blank">
    <GitHubIcon />
  </a>
</Button>
<DropdownMenu.Item asChild>
  <Link href="/profile">Profile</Link>
</DropdownMenu.Item>

When to Use

Use CaseUse asChild?
Link styled as buttonYes
Combining triggersYes
Custom element with Radix behaviorYes
Default element is fineNo
Adds complexity without benefitNo

Requirements for Child Components

The child component MUST:

  1. Forward refs with React.forwardRef
  2. Spread props to underlying element
  3. Be a single element (not fragment)
// ✅ Correct - forwards ref and spreads props
const MyButton = React.forwardRef<HTMLButtonElement, Props>(
  (props, ref) => <button ref={ref} {...props} />
)

// ❌ Incorrect - no ref forwarding
const MyButton = (props) => <button {...props} />

Cn Utility Patterns

cn() Utility Patterns

Class merging with tailwind-merge and clsx.

Setup

// lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

What It Solves

Problem: Tailwind Class Conflicts

// Without cn() - px-4 and px-6 both apply (unpredictable)
<div className={`px-4 ${props.className}`}>
// If props.className = "px-6", result is "px-4 px-6" (conflict!)

// With cn() - px-6 wins (later class wins)
<div className={cn('px-4', props.className)}>
// Result: "px-6" (clean, predictable)

Common Patterns

Conditional Classes

cn(
  'base-class',
  isActive && 'active-class',
  isDisabled && 'disabled-class'
)
// Falsy values are filtered out

Object Syntax

cn({
  'bg-blue-500': variant === 'primary',
  'bg-gray-500': variant === 'secondary',
  'opacity-50 cursor-not-allowed': disabled,
})

With CVA Variants

cn(buttonVariants({ variant, size }), className)

Array of Classes

cn([
  'flex items-center',
  'gap-2',
  'p-4',
])

Real-World Component Examples

Button with Override Support

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => (
    <button
      className={cn(
        // Base styles
        'inline-flex items-center justify-center rounded-md font-medium',
        // Variant styles from CVA
        buttonVariants({ variant, size }),
        // Consumer overrides (wins over variants)
        className
      )}
      ref={ref}
      {...props}
    />
  )
)

Card with Conditional Styling

function Card({ className, elevated, interactive, ...props }) {
  return (
    <div
      className={cn(
        // Base
        'rounded-lg border bg-card text-card-foreground',
        // Conditional
        elevated && 'shadow-lg',
        interactive && 'cursor-pointer hover:bg-accent transition-colors',
        // Overrides
        className
      )}
      {...props}
    />
  )
}

Input with States

function Input({ className, error, ...props }) {
  return (
    <input
      className={cn(
        'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
        'file:border-0 file:bg-transparent file:text-sm file:font-medium',
        'placeholder:text-muted-foreground',
        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
        'disabled:cursor-not-allowed disabled:opacity-50',
        // Error state
        error && 'border-destructive focus-visible:ring-destructive',
        className
      )}
      {...props}
    />
  )
}

Order Matters

// Classes are processed left to right
// Later classes override earlier ones for the same property

cn('text-red-500', 'text-blue-500')
// Result: "text-blue-500"

cn('p-4', 'p-2', 'p-8')
// Result: "p-8"

cn('text-sm md:text-base', 'text-lg')
// Result: "text-lg md:text-base"
// (base overridden, responsive preserved)

With Responsive Classes

cn(
  'grid grid-cols-1',
  'md:grid-cols-2',
  'lg:grid-cols-3',
  fullWidth && 'lg:grid-cols-4'
)

Performance Notes

  • twMerge is optimized and fast for typical use
  • Avoid calling cn() in loops with dynamic classes
  • For static classes, regular string concatenation is fine
// ✅ Good - cn() handles conflicts
cn(baseClasses, props.className)

// ✅ Good - no conflicts possible
`${staticClass} ${anotherStatic}`

// ⚠️ Avoid - cn() in hot loop
items.map(item => cn(classes, item.className)) // Consider memoization

Component Extension

Component Extension Patterns

Extending shadcn/ui components without modifying source.

Principle: Wrap, Don't Modify

shadcn/ui components are meant to be copied and owned. However, when extending:

  1. Wrap the original component
  2. Forward refs correctly
  3. Preserve the variant system
  4. Add new functionality as props

Basic Extension: Adding Props

import { Button as ShadcnButton } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'

interface ExtendedButtonProps
  extends React.ComponentPropsWithoutRef<typeof ShadcnButton> {
  loading?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ExtendedButtonProps>(
  ({ loading, disabled, children, ...props }, ref) => (
    <ShadcnButton
      ref={ref}
      disabled={disabled || loading}
      {...props}
    >
      {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
      {children}
    </ShadcnButton>
  )
)
Button.displayName = 'Button'

export { Button }

Adding New Variants

import { cva, type VariantProps } from 'class-variance-authority'
import { Button as ShadcnButton } from '@/components/ui/button'
import { cn } from '@/lib/utils'

// Extended variant system
const extendedButtonVariants = cva('', {
  variants: {
    glow: {
      true: 'shadow-lg shadow-primary/25 hover:shadow-primary/40',
      false: '',
    },
    pulse: {
      true: 'animate-pulse',
      false: '',
    },
  },
  defaultVariants: {
    glow: false,
    pulse: false,
  },
})

interface ExtendedButtonProps
  extends React.ComponentPropsWithoutRef<typeof ShadcnButton>,
    VariantProps<typeof extendedButtonVariants> {}

const Button = React.forwardRef<HTMLButtonElement, ExtendedButtonProps>(
  ({ className, glow, pulse, ...props }, ref) => (
    <ShadcnButton
      ref={ref}
      className={cn(extendedButtonVariants({ glow, pulse }), className)}
      {...props}
    />
  )
)

Composition: Combining Components

import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'

interface ConfirmDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: string
  description: string
  onConfirm: () => void
  onCancel?: () => void
  confirmText?: string
  cancelText?: string
  variant?: 'default' | 'destructive'
}

export function ConfirmDialog({
  open,
  onOpenChange,
  title,
  description,
  onConfirm,
  onCancel,
  confirmText = 'Confirm',
  cancelText = 'Cancel',
  variant = 'default',
}: ConfirmDialogProps) {
  const handleConfirm = () => {
    onConfirm()
    onOpenChange(false)
  }

  const handleCancel = () => {
    onCancel?.()
    onOpenChange(false)
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <Button variant="outline" onClick={handleCancel}>
            {cancelText}
          </Button>
          <Button
            variant={variant === 'destructive' ? 'destructive' : 'default'}
            onClick={handleConfirm}
          >
            {confirmText}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Polymorphic Extension with asChild

import { Button } from '@/components/ui/button'
import { Slot } from '@radix-ui/react-slot'

interface IconButtonProps
  extends React.ComponentPropsWithoutRef<typeof Button> {
  icon: React.ReactNode
  label: string // For accessibility
}

const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
  ({ icon, label, asChild, ...props }, ref) => {
    return (
      <Button
        ref={ref}
        size="icon"
        aria-label={label}
        asChild={asChild}
        {...props}
      >
        {asChild ? (
          <Slot>{icon}</Slot>
        ) : (
          icon
        )}
      </Button>
    )
  }
)

Form Field Wrapper

import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'

interface FormFieldProps extends React.ComponentPropsWithoutRef<typeof Input> {
  label: string
  error?: string
  description?: string
}

const FormField = React.forwardRef<HTMLInputElement, FormFieldProps>(
  ({ label, error, description, className, id, ...props }, ref) => {
    const inputId = id || label.toLowerCase().replace(/\s+/g, '-')

    return (
      <div className={cn('space-y-2', className)}>
        <Label htmlFor={inputId}>{label}</Label>
        <Input
          ref={ref}
          id={inputId}
          aria-describedby={error ? `${inputId}-error` : undefined}
          aria-invalid={!!error}
          className={cn(error && 'border-destructive')}
          {...props}
        />
        {description && !error && (
          <p className="text-sm text-muted-foreground">{description}</p>
        )}
        {error && (
          <p id={`${inputId}-error`} className="text-sm text-destructive">
            {error}
          </p>
        )}
      </div>
    )
  }
)

Best Practices

  1. Always forward refs - Components may need ref access
  2. Preserve displayName - Helps with debugging
  3. Type props explicitly - Use ComponentPropsWithoutRef
  4. Keep variants compatible - Don't break existing API
  5. Document extensions - Make custom props discoverable

Cva Variant System

CVA Variant System

Type-safe, declarative component variants with Class Variance Authority.

Core Pattern

import { cva, type VariantProps } from 'class-variance-authority'

const buttonVariants = cva(
  // Base classes (always applied)
  'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
)

Type-Safe Props

import { cn } from '@/lib/utils'

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button'
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)

Compound Variants

Apply classes when multiple variants are combined:

const alertVariants = cva(
  'relative w-full rounded-lg border p-4',
  {
    variants: {
      variant: {
        default: 'bg-background text-foreground',
        destructive: 'border-destructive/50 text-destructive',
        success: 'border-green-500/50 text-green-700',
      },
      size: {
        default: 'text-sm',
        lg: 'text-base p-6',
      },
    },
    compoundVariants: [
      // When variant=destructive AND size=lg, add extra styles
      {
        variant: 'destructive',
        size: 'lg',
        className: 'border-2 font-semibold',
      },
      // Multiple variants can match
      {
        variant: ['destructive', 'success'],
        size: 'lg',
        className: 'shadow-lg',
      },
    ],
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
)

Boolean Variants

const cardVariants = cva(
  'rounded-lg border bg-card text-card-foreground',
  {
    variants: {
      elevated: {
        true: 'shadow-lg',
        false: 'shadow-none',
      },
      interactive: {
        true: 'cursor-pointer hover:bg-accent transition-colors',
        false: '',
      },
    },
    defaultVariants: {
      elevated: false,
      interactive: false,
    },
  }
)

// Usage
<Card elevated interactive>Click me</Card>

Extending Variants

// Base badge variants
const badgeVariants = cva(
  'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground',
        secondary: 'bg-secondary text-secondary-foreground',
        outline: 'border text-foreground',
      },
    },
    defaultVariants: { variant: 'default' },
  }
)

// Extended with status colors
const statusBadgeVariants = cva(
  badgeVariants({ variant: 'outline' }), // Use base as starting point
  {
    variants: {
      status: {
        pending: 'border-yellow-500 text-yellow-700 bg-yellow-50',
        active: 'border-green-500 text-green-700 bg-green-50',
        inactive: 'border-gray-500 text-gray-700 bg-gray-50',
        error: 'border-red-500 text-red-700 bg-red-50',
      },
    },
    defaultVariants: { status: 'pending' },
  }
)

With Responsive Variants

CVA doesn't handle responsive directly, but combine with Tailwind:

const layoutVariants = cva('grid gap-4', {
  variants: {
    columns: {
      1: 'grid-cols-1',
      2: 'grid-cols-1 md:grid-cols-2',
      3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
      4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
    },
  },
  defaultVariants: { columns: 1 },
})

Best Practices

  1. Keep variants focused: Each variant should have a single responsibility
  2. Use compound variants sparingly: Only for complex combinations
  3. Default variants: Always set sensible defaults
  4. Type exports: Export VariantProps type for consumers
  5. Consistent naming: variant for style, size for dimensions

Anti-Patterns

// ❌ Don't mix styling and behavior
const badVariants = cva('...', {
  variants: {
    onClick: { /* This should be a prop, not a variant */ }
  }
})

// ❌ Don't duplicate Tailwind breakpoints in variants
const badVariants = cva('...', {
  variants: {
    mobilePadding: { /* Use responsive Tailwind classes instead */ }
  }
})

// ✅ Keep variants about visual presentation
const goodVariants = cva('...', {
  variants: {
    variant: { /* visual style */ },
    size: { /* dimensions */ },
  }
})

Dark Mode Toggle

Dark Mode Toggle

Theme switching with next-themes and shadcn/ui.

Setup with next-themes

1. Install

npm install next-themes

2. Add ThemeProvider

// app/providers.tsx
'use client'

import { ThemeProvider as NextThemesProvider } from 'next-themes'

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <NextThemesProvider
      attribute="class"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      {children}
    </NextThemesProvider>
  )
}

3. Wrap App

// app/layout.tsx
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Toggle Component (Dropdown)

'use client'

import * as React from 'react'
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'

import { Button } from '@/components/ui/button'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'

export function ModeToggle() {
  const { setTheme } = useTheme()

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme('light')}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('dark')}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('system')}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Toggle Component (Simple Button)

'use client'

import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'

export function ThemeToggle() {
  const { theme, setTheme } = useTheme()

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
    >
      <Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
      <Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
      <span className="sr-only">Toggle theme</span>
    </Button>
  )
}

Toggle Component (Switch)

'use client'

import { useTheme } from 'next-themes'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Moon, Sun } from 'lucide-react'

export function ThemeSwitch() {
  const { theme, setTheme } = useTheme()
  const isDark = theme === 'dark'

  return (
    <div className="flex items-center gap-2">
      <Sun className="h-4 w-4" />
      <Switch
        id="theme-switch"
        checked={isDark}
        onCheckedChange={(checked) => setTheme(checked ? 'dark' : 'light')}
      />
      <Moon className="h-4 w-4" />
      <Label htmlFor="theme-switch" className="sr-only">
        Toggle dark mode
      </Label>
    </div>
  )
}

Handling Hydration

'use client'

import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'

export function ThemeAwareComponent() {
  const { theme, resolvedTheme } = useTheme()
  const [mounted, setMounted] = useState(false)

  // Avoid hydration mismatch
  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    return <Skeleton /> // Or null
  }

  // Safe to use theme now
  return (
    <div>
      Current theme: {resolvedTheme}
    </div>
  )
}

CSS Variables Setup

Ensure your CSS supports both themes:

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  /* ... other light mode variables */
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  /* ... other dark mode variables */
}

Configuration Options

<NextThemesProvider
  attribute="class"           // Use class strategy
  defaultTheme="system"       // Default to system preference
  enableSystem                // Enable system preference detection
  disableTransitionOnChange   // Prevent flash on theme change
  storageKey="theme"          // localStorage key
  themes={['light', 'dark', 'system']}  // Available themes
/>

Tailwind v4 Dark Mode

In Tailwind CSS v4, dark mode uses a CSS-first approach. The dark: variant works automatically based on the .dark class or prefers-color-scheme media query.

/* app.css - Tailwind v4 CSS-first approach */
@import "tailwindcss";

@theme {
  /* Define your theme tokens */
  --color-background: oklch(1 0 0);
  --color-foreground: oklch(0.145 0 0);
}

/* Dark mode via .dark class (used by next-themes) */
.dark {
  --color-background: oklch(0.145 0 0);
  --color-foreground: oklch(0.985 0 0);
}

/* Or automatic detection via media query */
@media (prefers-color-scheme: dark) {
  :root:not(.light) {
    --color-background: oklch(0.145 0 0);
    --color-foreground: oklch(0.985 0 0);
  }
}

No tailwind.config.js configuration needed - the dark: variant is enabled by default in v4 and responds to the .dark class on a parent element.

Dialog Modal Patterns

Dialog and Modal Patterns

Accessible modal dialogs with Radix primitives.

Dialog vs AlertDialog

FeatureDialogAlertDialog
Close on overlay clickYesNo
Close on EscapeYesRequires explicit action
Use caseForms, contentDestructive confirmations

Basic Dialog

import { Dialog } from 'radix-ui'

export function BasicDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <Button>Edit Profile</Button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50" />
        <Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6">
          <Dialog.Title>Edit Profile</Dialog.Title>
          <Dialog.Description>
            Make changes to your profile here.
          </Dialog.Description>

          {/* Form content */}

          <Dialog.Close asChild>
            <Button>Save changes</Button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Controlled Dialog

export function ControlledDialog() {
  const [open, setOpen] = useState(false)

  const handleSubmit = async () => {
    await saveData()
    setOpen(false) // Close after save
  }

  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger asChild>
        <Button>Open</Button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <form onSubmit={handleSubmit}>
            {/* Form fields */}
            <Button type="submit">Save</Button>
          </form>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

AlertDialog (Destructive Actions)

import { AlertDialog } from 'radix-ui'

export function DeleteConfirmation({ onDelete }) {
  return (
    <AlertDialog.Root>
      <AlertDialog.Trigger asChild>
        <Button variant="destructive">Delete</Button>
      </AlertDialog.Trigger>
      <AlertDialog.Portal>
        <AlertDialog.Overlay className="fixed inset-0 bg-black/50" />
        <AlertDialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6">
          <AlertDialog.Title>Are you sure?</AlertDialog.Title>
          <AlertDialog.Description>
            This action cannot be undone. This will permanently delete your account.
          </AlertDialog.Description>
          <div className="flex gap-4 justify-end">
            <AlertDialog.Cancel asChild>
              <Button variant="outline">Cancel</Button>
            </AlertDialog.Cancel>
            <AlertDialog.Action asChild>
              <Button variant="destructive" onClick={onDelete}>
                Delete
              </Button>
            </AlertDialog.Action>
          </div>
        </AlertDialog.Content>
      </AlertDialog.Portal>
    </AlertDialog.Root>
  )
}

Abstracting Dialog Components

Create reusable wrappers:

// components/ui/dialog.tsx
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'

export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
export const DialogClose = DialogPrimitive.Close

export const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ children, className, ...props }, ref) => (
  <DialogPrimitive.Portal>
    <DialogPrimitive.Overlay
      className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out"
    />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
        'bg-background rounded-lg shadow-lg p-6 w-full max-w-lg',
        'data-[state=open]:animate-in data-[state=closed]:animate-out',
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close className="absolute right-4 top-4">
        <X className="h-4 w-4" />
        <span className="sr-only">Close</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPrimitive.Portal>
))

export const DialogHeader = ({ children }) => (
  <div className="space-y-1.5 mb-4">{children}</div>
)

export const DialogTitle = DialogPrimitive.Title
export const DialogDescription = DialogPrimitive.Description

Animation with data-state

/* Overlay animation */
[data-state="open"] .overlay {
  animation: fadeIn 150ms ease-out;
}
[data-state="closed"] .overlay {
  animation: fadeOut 150ms ease-in;
}

/* Content animation */
[data-state="open"] .content {
  animation: scaleIn 150ms ease-out;
}
[data-state="closed"] .content {
  animation: scaleOut 150ms ease-in;
}

@keyframes fadeIn { from { opacity: 0; } }
@keyframes fadeOut { to { opacity: 0; } }
@keyframes scaleIn { from { transform: scale(0.95); opacity: 0; } }
@keyframes scaleOut { to { transform: scale(0.95); opacity: 0; } }

Accessibility Built-in

  • Focus trapped within dialog
  • Focus returns to trigger on close
  • Escape closes dialog
  • Click outside closes (Dialog only)
  • Proper ARIA attributes
  • Screen reader announcements

Dropdown Menu Patterns

Accessible dropdown menus with Radix primitives.

Basic Dropdown Menu

import { DropdownMenu } from 'radix-ui'
import { ChevronDown } from 'lucide-react'

export function UserMenu() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <Button variant="outline">
          Options <ChevronDown className="ml-2 h-4 w-4" />
        </Button>
      </DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content
          className="min-w-[200px] bg-white rounded-md shadow-lg p-1"
          sideOffset={5}
        >
          <DropdownMenu.Item className="px-2 py-1.5 rounded hover:bg-gray-100 cursor-pointer">
            Profile
          </DropdownMenu.Item>
          <DropdownMenu.Item className="px-2 py-1.5 rounded hover:bg-gray-100 cursor-pointer">
            Settings
          </DropdownMenu.Item>
          <DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
          <DropdownMenu.Item className="px-2 py-1.5 rounded hover:bg-red-100 text-red-600 cursor-pointer">
            Sign out
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

With Checkbox Items

export function ViewOptionsMenu() {
  const [showGrid, setShowGrid] = useState(true)
  const [showDetails, setShowDetails] = useState(false)

  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <Button>View</Button>
      </DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content>
          <DropdownMenu.CheckboxItem
            checked={showGrid}
            onCheckedChange={setShowGrid}
          >
            <DropdownMenu.ItemIndicator>
              <Check className="h-4 w-4" />
            </DropdownMenu.ItemIndicator>
            Show Grid
          </DropdownMenu.CheckboxItem>

          <DropdownMenu.CheckboxItem
            checked={showDetails}
            onCheckedChange={setShowDetails}
          >
            <DropdownMenu.ItemIndicator>
              <Check className="h-4 w-4" />
            </DropdownMenu.ItemIndicator>
            Show Details
          </DropdownMenu.CheckboxItem>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

With Radio Group

export function SortMenu() {
  const [sort, setSort] = useState('date')

  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <Button>Sort by</Button>
      </DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content>
          <DropdownMenu.RadioGroup value={sort} onValueChange={setSort}>
            <DropdownMenu.RadioItem value="date">
              <DropdownMenu.ItemIndicator>
                <Circle className="h-2 w-2 fill-current" />
              </DropdownMenu.ItemIndicator>
              Date
            </DropdownMenu.RadioItem>
            <DropdownMenu.RadioItem value="name">
              <DropdownMenu.ItemIndicator>
                <Circle className="h-2 w-2 fill-current" />
              </DropdownMenu.ItemIndicator>
              Name
            </DropdownMenu.RadioItem>
            <DropdownMenu.RadioItem value="size">
              <DropdownMenu.ItemIndicator>
                <Circle className="h-2 w-2 fill-current" />
              </DropdownMenu.ItemIndicator>
              Size
            </DropdownMenu.RadioItem>
          </DropdownMenu.RadioGroup>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

With Submenus

export function FileMenu() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <Button>File</Button>
      </DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content>
          <DropdownMenu.Item>New File</DropdownMenu.Item>
          <DropdownMenu.Item>Open</DropdownMenu.Item>

          <DropdownMenu.Sub>
            <DropdownMenu.SubTrigger>
              Export
              <ChevronRight className="ml-auto h-4 w-4" />
            </DropdownMenu.SubTrigger>
            <DropdownMenu.Portal>
              <DropdownMenu.SubContent>
                <DropdownMenu.Item>PDF</DropdownMenu.Item>
                <DropdownMenu.Item>PNG</DropdownMenu.Item>
                <DropdownMenu.Item>SVG</DropdownMenu.Item>
              </DropdownMenu.SubContent>
            </DropdownMenu.Portal>
          </DropdownMenu.Sub>

          <DropdownMenu.Separator />
          <DropdownMenu.Item>Quit</DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

Custom Abstraction

// components/ui/dropdown-menu.tsx
export const DropdownMenu = DropdownMenuPrimitive.Root
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger

export const DropdownMenuContent = React.forwardRef(
  ({ className, sideOffset = 4, ...props }, ref) => (
    <DropdownMenuPrimitive.Portal>
      <DropdownMenuPrimitive.Content
        ref={ref}
        sideOffset={sideOffset}
        className={cn(
          'z-50 min-w-[8rem] rounded-md border bg-popover p-1 shadow-md',
          'data-[state=open]:animate-in data-[state=closed]:animate-out',
          className
        )}
        {...props}
      />
    </DropdownMenuPrimitive.Portal>
  )
)

export const DropdownMenuItem = React.forwardRef(
  ({ className, ...props }, ref) => (
    <DropdownMenuPrimitive.Item
      ref={ref}
      className={cn(
        'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
        'focus:bg-accent focus:text-accent-foreground',
        'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
        className
      )}
      {...props}
    />
  )
)

Keyboard Navigation

Built-in keyboard support:

  • Arrow keys: Navigate items
  • Enter/Space: Select item
  • Escape: Close menu
  • Right arrow: Open submenu
  • Left arrow: Close submenu
  • Type ahead: Focus matching item

Focus Management

Focus Management

Keyboard navigation and focus handling with Radix.

Built-in Focus Features

All Radix primitives include:

FeatureDescription
Focus trapFocus stays within component
Focus returnFocus returns to trigger on close
Visible focusClear focus indicators
Roving tabindexArrow key navigation in groups

Dialog Focus Trap

Focus is automatically trapped within dialogs:

<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Content>
      {/* Focus trapped here */}
      <input autoFocus />  {/* Receives initial focus */}
      <button>Action 1</button>
      <button>Action 2</button>
      <Dialog.Close>Close</Dialog.Close>
      {/* Tab cycles through these elements */}
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

Custom Initial Focus

<Dialog.Content
  onOpenAutoFocus={(event) => {
    event.preventDefault()
    // Focus specific element
    document.getElementById('email-input')?.focus()
  }}
>
  <input id="name-input" />
  <input id="email-input" />  {/* Gets focus */}
</Dialog.Content>

Preventing Focus Return

<Dialog.Content
  onCloseAutoFocus={(event) => {
    event.preventDefault()
    // Focus something else instead of trigger
    document.getElementById('other-element')?.focus()
  }}
>
  {/* Content */}
</Dialog.Content>

Roving Tabindex (Menu Navigation)

Arrow keys navigate within menus:

<DropdownMenu.Content>
  {/* Tab: exits menu */}
  {/* Arrow Up/Down: navigates items */}
  <DropdownMenu.Item>Profile</DropdownMenu.Item>
  <DropdownMenu.Item>Settings</DropdownMenu.Item>
  <DropdownMenu.Item>Sign out</DropdownMenu.Item>
</DropdownMenu.Content>

RadioGroup Focus

<RadioGroup.Root>
  {/* Tab: enters group at selected item */}
  {/* Arrow keys: move between items */}
  <RadioGroup.Item value="a">Option A</RadioGroup.Item>
  <RadioGroup.Item value="b">Option B</RadioGroup.Item>
  <RadioGroup.Item value="c">Option C</RadioGroup.Item>
</RadioGroup.Root>

Tabs Focus

<Tabs.Root>
  <Tabs.List>
    {/* Arrow keys navigate tabs */}
    <Tabs.Trigger value="account">Account</Tabs.Trigger>
    <Tabs.Trigger value="password">Password</Tabs.Trigger>
  </Tabs.List>
  {/* Tab moves to content */}
  <Tabs.Content value="account">...</Tabs.Content>
  <Tabs.Content value="password">...</Tabs.Content>
</Tabs.Root>

Focus Visible Styling

Style focus indicators for keyboard users:

/* Only show focus ring for keyboard navigation */
[data-focus-visible] {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

/* Hide for mouse users */
:focus:not([data-focus-visible]) {
  outline: none;
}
// Tailwind
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">

Escape Key Handling

All overlays close on Escape:

<Dialog.Content
  onEscapeKeyDown={(event) => {
    // Prevent close if form has changes
    if (hasUnsavedChanges) {
      event.preventDefault()
      showConfirmDialog()
    }
  }}
>

Keyboard Shortcuts Reference

ComponentKeyAction
DialogEscapeClose
MenuArrow Up/DownNavigate
MenuEnter/SpaceSelect
MenuRight ArrowOpen submenu
MenuLeft ArrowClose submenu
TabsArrow Left/RightSwitch tab
RadioGroupArrow Up/DownChange selection
SelectArrow Up/DownNavigate options
SelectEnterSelect option

Focus Scope (Advanced)

For custom focus trapping:

import { FocusScope } from '@radix-ui/react-focus-scope'

<FocusScope
  trapped={true}
  onMountAutoFocus={(event) => {
    event.preventDefault()
    firstInput.current?.focus()
  }}
  onUnmountAutoFocus={(event) => {
    event.preventDefault()
    triggerRef.current?.focus()
  }}
>
  {/* Focusable content */}
</FocusScope>

Oklch Theming

OKLCH Theming (2026 Standard)

Modern perceptually uniform color space for shadcn/ui themes.

Why OKLCH?

FeatureOKLCHHSL
Perceptual uniformityYesNo
Wide gamut supportYesLimited
Predictable lightnessYesNo
Dark mode conversionEasierManual

Format: oklch(lightness chroma hue)

  • Lightness: 0 (black) to 1 (white)
  • Chroma: 0 (gray) to ~0.4 (most saturated)
  • Hue: 0-360 degrees

Complete Theme Structure

:root {
  /* Core semantic colors */
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);

  /* Card/Popover */
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);

  /* Primary brand color */
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);

  /* Secondary */
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);

  /* Muted/subdued */
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);

  /* Accent/highlight */
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);

  /* Destructive/danger */
  --destructive: oklch(0.577 0.245 27.325);
  --destructive-foreground: oklch(0.985 0 0);

  /* Borders and inputs */
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);

  /* Radius scale */
  --radius: 0.625rem;
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);

  --card: oklch(0.145 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.145 0 0);
  --popover-foreground: oklch(0.985 0 0);

  --primary: oklch(0.985 0 0);
  --primary-foreground: oklch(0.205 0 0);

  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);

  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);

  --accent: oklch(0.269 0 0);
  --accent-foreground: oklch(0.985 0 0);

  --destructive: oklch(0.396 0.141 25.723);
  --destructive-foreground: oklch(0.985 0 0);

  --border: oklch(0.269 0 0);
  --input: oklch(0.269 0 0);
  --ring: oklch(0.439 0 0);
}

Tailwind Integration

@theme inline {
  /* Map CSS variables to Tailwind utilities */
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-destructive-foreground: var(--destructive-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);

  /* Radius scale */
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
}

Chart Colors

Data visualization with distinct OKLCH hues:

:root {
  --chart-1: oklch(0.646 0.222 41.116);   /* Orange */
  --chart-2: oklch(0.6 0.118 184.704);     /* Teal */
  --chart-3: oklch(0.398 0.07 227.392);    /* Blue */
  --chart-4: oklch(0.828 0.189 84.429);    /* Yellow */
  --chart-5: oklch(0.769 0.188 70.08);     /* Amber */
}

.dark {
  --chart-1: oklch(0.488 0.243 264.376);   /* Indigo */
  --chart-2: oklch(0.696 0.17 162.48);      /* Cyan */
  --chart-3: oklch(0.769 0.188 70.08);      /* Amber */
  --chart-4: oklch(0.627 0.265 303.9);      /* Purple */
  --chart-5: oklch(0.645 0.246 16.439);     /* Red */
}

Creating Custom Brand Colors

/* Blue brand example */
:root {
  /* Primary blue - adjust lightness for variants */
  --primary: oklch(0.546 0.245 262.881);        /* Base blue */
  --primary-foreground: oklch(0.97 0.014 254.604);  /* Light text on blue */

  /* Derived variants */
  --primary-hover: oklch(0.496 0.245 262.881);  /* Darker for hover */
  --primary-muted: oklch(0.85 0.08 262.881);    /* Muted background */
}

.dark {
  --primary: oklch(0.707 0.165 254.624);        /* Lighter in dark mode */
  --primary-foreground: oklch(0.145 0 0);       /* Dark text */
}
:root {
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-primary: oklch(0.205 0 0);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.97 0 0);
  --sidebar-accent-foreground: oklch(0.205 0 0);
  --sidebar-border: oklch(0.922 0 0);
  --sidebar-ring: oklch(0.708 0 0);
}

Converting from HSL

HSL: hsl(220, 70%, 50%)

OKLCH: oklch(0.55 0.2 260)

Rough mapping:
- Lightness: HSL L% ÷ 100 ≈ OKLCH L
- Chroma: HSL S% × 0.003 ≈ OKLCH C (very rough)
- Hue: Similar but can shift

Use tools like oklch.com for accurate conversion.

Accessibility Considerations

  • Minimum contrast ratio: 4.5:1 for normal text
  • Large text (18px+): 3:1 minimum
  • OKLCH makes it easier to maintain contrast by adjusting lightness predictably

Popover Tooltip Patterns

Popover and Tooltip Patterns

Floating content with Radix primitives.

Tooltip vs Popover vs HoverCard

ComponentTriggerContentUse Case
TooltipHover/FocusText onlyIcon hints, abbreviations
PopoverClickInteractiveForms, rich content
HoverCardHoverRich previewUser cards, link previews

Basic Tooltip

import { Tooltip } from 'radix-ui'

export function IconWithTooltip() {
  return (
    <Tooltip.Provider delayDuration={300}>
      <Tooltip.Root>
        <Tooltip.Trigger asChild>
          <Button size="icon" variant="ghost">
            <Settings className="h-4 w-4" />
          </Button>
        </Tooltip.Trigger>
        <Tooltip.Portal>
          <Tooltip.Content
            className="bg-gray-900 text-white px-3 py-1.5 rounded text-sm"
            sideOffset={5}
          >
            Settings
            <Tooltip.Arrow className="fill-gray-900" />
          </Tooltip.Content>
        </Tooltip.Portal>
      </Tooltip.Root>
    </Tooltip.Provider>
  )
}

Tooltip Provider

Wrap your app for shared configuration:

// app/layout.tsx
import { Tooltip } from 'radix-ui'

export default function RootLayout({ children }) {
  return (
    <Tooltip.Provider
      delayDuration={400}      // Delay before showing
      skipDelayDuration={300}  // Skip delay when moving between tooltips
    >
      {children}
    </Tooltip.Provider>
  )
}

Basic Popover

import { Popover } from 'radix-ui'

export function FilterPopover() {
  return (
    <Popover.Root>
      <Popover.Trigger asChild>
        <Button variant="outline">
          <Filter className="mr-2 h-4 w-4" />
          Filters
        </Button>
      </Popover.Trigger>

      <Popover.Portal>
        <Popover.Content
          className="w-80 bg-white rounded-lg shadow-lg p-4"
          sideOffset={5}
        >
          <div className="space-y-4">
            <h4 className="font-medium">Filter options</h4>

            <div className="space-y-2">
              <Label>Status</Label>
              <Select>
                <option>All</option>
                <option>Active</option>
                <option>Archived</option>
              </Select>
            </div>

            <div className="space-y-2">
              <Label>Date range</Label>
              <Input type="date" />
            </div>

            <Button className="w-full">Apply filters</Button>
          </div>

          <Popover.Arrow className="fill-white" />
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  )
}

Controlled Popover

export function ControlledPopover() {
  const [open, setOpen] = useState(false)

  const handleSubmit = () => {
    // Process form
    setOpen(false) // Close after submit
  }

  return (
    <Popover.Root open={open} onOpenChange={setOpen}>
      <Popover.Trigger asChild>
        <Button>Add item</Button>
      </Popover.Trigger>
      <Popover.Portal>
        <Popover.Content>
          <form onSubmit={handleSubmit}>
            <Input placeholder="Item name" />
            <Button type="submit">Add</Button>
          </form>
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  )
}

HoverCard (Rich Previews)

import { HoverCard } from 'radix-ui'

export function UserHoverCard({ username }) {
  return (
    <HoverCard.Root>
      <HoverCard.Trigger asChild>
        <a href={`/user/${username}`} className="text-blue-500 hover:underline">
          @{username}
        </a>
      </HoverCard.Trigger>

      <HoverCard.Portal>
        <HoverCard.Content
          className="w-64 bg-white rounded-lg shadow-lg p-4"
          sideOffset={5}
        >
          <div className="flex gap-4">
            <Avatar src={`/avatars/${username}.jpg`} />
            <div>
              <h4 className="font-medium">{username}</h4>
              <p className="text-sm text-gray-500">Software Engineer</p>
              <p className="text-sm mt-2">Building cool things with React.</p>
            </div>
          </div>
          <HoverCard.Arrow className="fill-white" />
        </HoverCard.Content>
      </HoverCard.Portal>
    </HoverCard.Root>
  )
}

Positioning

Common positioning props:

<Content
  side="top"           // top | right | bottom | left
  sideOffset={5}       // Distance from trigger
  align="center"       // start | center | end
  alignOffset={0}      // Offset from alignment
  avoidCollisions={true}  // Flip if clipped
  collisionPadding={8}    // Viewport padding
/>

Styling States

/* Animation on open/close */
[data-state="open"] { animation: fadeIn 200ms ease-out; }
[data-state="closed"] { animation: fadeOut 150ms ease-in; }

/* Side-aware animations */
[data-side="top"] { animation-name: slideFromBottom; }
[data-side="bottom"] { animation-name: slideFromTop; }
[data-side="left"] { animation-name: slideFromRight; }
[data-side="right"] { animation-name: slideFromLeft; }

Checklists (2)

Accessibility Audit

Radix Accessibility Audit Checklist

Verify WCAG compliance for Radix-based components.

Keyboard Navigation

  • All interactive elements reachable via Tab
  • Tab order follows visual layout
  • Focus visible on all interactive elements
  • Escape closes overlays (dialogs, menus, popovers)
  • Enter/Space activates buttons and triggers
  • Arrow keys navigate menus, tabs, radio groups
  • Home/End jump to first/last items in lists

Focus Management

  • Focus trapped within dialogs when open
  • Focus returns to trigger on close
  • Initial focus on logical element (first input or primary action)
  • No focus loss when elements are removed
  • Focus visible indicator meets 3:1 contrast

ARIA Attributes (Auto-provided by Radix)

  • role appropriate for component type
  • aria-expanded on triggers for expandable content
  • aria-controls links trigger to content
  • aria-haspopup on menu triggers
  • aria-selected on selected tabs/items
  • aria-checked on checkboxes/radios
  • aria-disabled on disabled elements

Screen Reader Announcements

  • Dialog title announced on open
  • Dialog description announced on open
  • Menu items announced with role
  • State changes announced (expanded/collapsed)
  • Error messages associated with inputs

Dialogs (Dialog, AlertDialog)

  • Has accessible name via Dialog.Title
  • Has description via Dialog.Description
  • AlertDialog requires explicit action (no click-outside close)
  • Focus trapped within dialog
  • Escape closes dialog (or prevented with reason)
  • Trigger has aria-haspopup="menu"
  • Items navigable via arrow keys
  • Type-ahead focuses matching items
  • Submenus accessible via arrow keys
  • Disabled items skipped in navigation

Tooltips

  • Wrapped in Tooltip.Provider for delay sharing
  • Appears on hover AND focus
  • Dismissible via Escape
  • Does not block interaction with trigger
  • Content is text-only (use Popover for interactive)

Forms (Select, Checkbox, RadioGroup, Switch)

  • Associated with label
  • Required state indicated
  • Error messages programmatically associated
  • Disabled state communicated
  • Custom controls have appropriate ARIA roles

Testing Tools

# Install axe-core for automated testing
npm install -D @axe-core/react

# Or use browser extensions:
# - axe DevTools
# - WAVE
# - Accessibility Insights

Manual Testing

  1. Keyboard-only: Unplug mouse, navigate entire flow
  2. Screen reader: Test with VoiceOver (Mac), NVDA (Windows), or Orca (Linux)
  3. Zoom: Test at 200% and 400% zoom levels
  4. High contrast: Test with OS high contrast mode
  5. Reduced motion: Test with prefers-reduced-motion: reduce

Shadcn Setup

shadcn/ui Setup Checklist

Complete setup and configuration checklist.

Initial Setup

  • Initialize shadcn/ui: npx shadcn@latest init
  • Select style (New York or Default)
  • Select base color
  • Configure CSS variables: Yes
  • Configure components.json generated

File Structure Verification

  • components.json created at root
  • lib/utils.ts created with cn() function
  • components/ui/ directory created
  • CSS variables added to globals.css or app.css
  • Dark mode configured via CSS (Tailwind v4 CSS-first approach)

Dependencies Installed

  • class-variance-authority for variants
  • clsx for conditional classes
  • tailwind-merge for class merging
  • radix-ui unified package (or individual @radix-ui/react-*)
  • lucide-react for icons (optional)

Tailwind Configuration (v4 CSS-First)

/* app.css or globals.css */
@import "tailwindcss";

/* Dark mode via CSS variables - no tailwind.config.js needed */
/* Tailwind v4 auto-detects content files */
  • @import "tailwindcss" in CSS entry file
  • CSS variables define theme tokens
  • No tailwind.config.js needed (Tailwind v4 auto-detects content)

CSS Variables

  • Light mode variables defined in :root
  • Dark mode variables defined in .dark
  • All semantic colors defined:
    • --background, --foreground
    • --card, --card-foreground
    • --popover, --popover-foreground
    • --primary, --primary-foreground
    • --secondary, --secondary-foreground
    • --muted, --muted-foreground
    • --accent, --accent-foreground
    • --destructive, --destructive-foreground
    • --border, --input, --ring
    • --radius

Adding Components

# Add individual components
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add dialog

# Add multiple components
npx shadcn@latest add button card input label
  • Add commonly used components
  • Verify components render correctly
  • Test dark mode toggle

Dark Mode Setup

  • Install next-themes: npm install next-themes
  • Create ThemeProvider wrapper
  • Add provider to root layout
  • Add suppressHydrationWarning to &lt;html&gt;
  • Create theme toggle component
  • Test theme persistence

TypeScript Configuration

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}
  • Path alias @/* configured
  • Types resolve correctly

Testing Checklist

  • Button variants render correctly
  • Dark mode switches properly
  • No hydration mismatches
  • Keyboard navigation works
  • Focus states visible
  • Responsive at all breakpoints

Common Issues

Hydration Mismatch

// Wrap theme-dependent content
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null

Missing CSS Variables

  • Check globals.css is imported in layout
  • Verify variable names match exactly

Tailwind Classes Not Applying

  • Verify @import "tailwindcss" in CSS entry file
  • Restart dev server after config changes
  1. button - Foundation for CTAs
  2. input + label - Form basics
  3. card - Content containers
  4. dialog - Modals
  5. dropdown-menu - Actions menu
  6. toast - Notifications
Edit on GitHub

Last updated on

On this page

UI ComponentsQuick ReferenceQuick Startshadcn/uiv4 Style SystemRadix PrimitivesKey DecisionsAnti-Patterns (FORBIDDEN)Detailed DocumentationDesign SystemDesign System ComponentsFormsModern CSS & ToolingUX FoundationsRelated SkillsRules (21)Build a color system with OKLCH, 9-shade scales, and semantic categories — HIGHColor System ArchitectureColor CategoriesShade Scale RulesKey DecisionsUse CSS Cascade Layers for predictable style precedence without specificity wars — HIGHCSS Cascade Layers (@layer)Recommended Layer OrderAssigning Third-Party CSS to Early LayersUnlayered StylesIncorrect -- Fighting specificity with !important and deep nestingCorrect -- Clean layer structure with clear precedenceKey RulesStructure scalable component libraries using atomic design and composition patterns — HIGHDesign System Component ArchitectureAtomic Design LevelsWCAG 2.1 Level AA RequirementsAccessibility EssentialsKey DecisionsUse 8px grid spacing scale for consistent component and layout spacing — MEDIUM8px Grid Spacing SystemSpacing ScaleComponent Spacing GuideLayout SpacingRulesDefine all interactive component states with consistent visual feedback patterns — HIGHInteractive Component StatesRequired States for Interactive ComponentsTypeScript InterfaceTailwind State ClassesMotion PresetsAccessibility Contrast RequirementsState Checklist for New ComponentsDefine consistent design tokens to enable global theme changes without visual drift — HIGHDesign System Token ArchitectureToken CategoriesDesign System LayersKey DecisionsApply consistent typography scale with semantic size roles for readable hierarchies — MEDIUMTypography ScaleSize ScaleWeight Pairing GuideLine Height RulesCorrect UsageRulesDesign intentional empty states with clear structure, actionable CTAs, and skeleton-first loading — HIGHEmpty State PatternsEmpty State TaxonomyStructure ChecklistKey DecisionsBuild performant forms with React Hook Form v7 controlled renders and validation — HIGHReact Hook Form PatternsValidate forms with Zod type-safe schemas on both client and server sides — HIGHZod Validation & Server ActionsCompose polymorphic components using Radix asChild pattern and nested triggers — HIGHRadix Composition PatternsasChild PatternNested CompositionCommon PatternsLink as ButtonIcon ButtonMenu Item as LinkPolymorphic Extension with SlotWhen to Use asChildRequirements for Child ComponentsPrimitives CatalogOverlay ComponentsPopover ComponentsMenu ComponentsForm ComponentsDisclosure ComponentsBuild accessible modal dialogs with Radix primitives and focus management — HIGHRadix Dialog PatternsDialog vs AlertDialogBasic DialogControlled DialogAlertDialog (Destructive Actions)Abstracting Dialog ComponentsAnimation with data-stateAccessibility Built-inStyle Radix components using data attributes and state-driven focus management — HIGHRadix Styling & Focus ManagementStyling with Data AttributesDropdown Menu StylingPopover and TooltipPositioning PropsSide-Aware AnimationsBuilt-in AccessibilityFocus ManagementDialog Focus TrapEscape Key HandlingFocus Visible StylingKeyboard Shortcuts ReferenceCustomize shadcn/ui components with CVA variants, tailwind-merge, and OKLCH theming — HIGHshadcn/ui CustomizationCVA (Class Variance Authority)Boolean Variantscn() UtilityOKLCH Theming (2026 Standard)Component Extension StrategyCLI Quick ReferenceBest PracticesBuild sortable, filterable data tables with TanStack Table and shadcn/ui integration — HIGHshadcn/ui Data TableBasic Table StructureColumn DefinitionsColumn FilteringDependenciesCreate accessible shadcn/ui form fields with proper labels and validation errors — HIGHshadcn/ui Form PatternsForm Field WrapperInput with StatesConfirm Dialog (Form Composition)Dark Mode ToggleHydration SafetyDependenciesshadcn/ui v4 Style System — 6 Styles + Preset Codes — HIGHshadcn/ui v4 Style SystemPreset CodesStyle Detection PatternStyle ReferenceStorybook CSF3 stories with play() interaction tests and Chromatic visual regression — HIGHStorybook Component Documentation (2026)CSF3 Story Formatplay() Functions for Interaction TestingChromatic CI Visual RegressionIncorrect -- No stories, manual visual testing onlyCorrect -- Story per state, play() for interactions, Chromatic in CIKey RulesTailwind v4 CSS-first configuration and native container queries — HIGHTailwind v4 Patterns (2026)CSS-First ConfigurationNative Container QueriesBasic UsageNamed ContainersMax-Width Container QueriesIncorrect -- Using tailwind.config.js in v4Correct -- CSS-first @theme and native @containerKey RulesApply numeric typography thresholds for line length, line height, and font scale — HIGHTypography ThresholdsType Scale (Tailwind — modular, 1.25 Major Third ratio)Threshold ReferenceKey DecisionsDesign visual hierarchy using weight, contrast, and spatial relationships — not color alone — HIGHVisual Hierarchy & LayoutHierarchy PrinciplesLayout RulesKey DecisionsReferences (10)Aschild CompositionasChild Composition PatternWhat is asChild?Basic UsageHow It WorksNested CompositionCommon PatternsLink as ButtonIcon ButtonMenu Item as LinkWhen to UseRequirements for Child ComponentsCn Utility Patternscn() Utility PatternsSetupWhat It SolvesProblem: Tailwind Class ConflictsCommon PatternsConditional ClassesObject SyntaxWith CVA VariantsArray of ClassesReal-World Component ExamplesButton with Override SupportCard with Conditional StylingInput with StatesOrder MattersWith Responsive ClassesPerformance NotesComponent ExtensionComponent Extension PatternsPrinciple: Wrap, Don't ModifyBasic Extension: Adding PropsAdding New VariantsComposition: Combining ComponentsPolymorphic Extension with asChildForm Field WrapperBest PracticesCva Variant SystemCVA Variant SystemCore PatternType-Safe PropsCompound VariantsBoolean VariantsExtending VariantsWith Responsive VariantsBest PracticesAnti-PatternsDark Mode ToggleDark Mode ToggleSetup with next-themes1. Install2. Add ThemeProvider3. Wrap AppToggle Component (Dropdown)Toggle Component (Simple Button)Toggle Component (Switch)Handling HydrationCSS Variables SetupConfiguration OptionsTailwind v4 Dark ModeDialog Modal PatternsDialog and Modal PatternsDialog vs AlertDialogBasic DialogControlled DialogAlertDialog (Destructive Actions)Abstracting Dialog ComponentsAnimation with data-stateAccessibility Built-inDropdown Menu PatternsDropdown Menu PatternsBasic Dropdown MenuWith Checkbox ItemsWith Radio GroupWith SubmenusCustom AbstractionKeyboard NavigationFocus ManagementFocus ManagementBuilt-in Focus FeaturesDialog Focus TrapCustom Initial FocusPreventing Focus ReturnRoving Tabindex (Menu Navigation)RadioGroup FocusTabs FocusFocus Visible StylingEscape Key HandlingKeyboard Shortcuts ReferenceFocus Scope (Advanced)Oklch ThemingOKLCH Theming (2026 Standard)Why OKLCH?Complete Theme StructureTailwind IntegrationChart ColorsCreating Custom Brand ColorsSidebar-Specific ColorsConverting from HSLAccessibility ConsiderationsPopover Tooltip PatternsPopover and Tooltip PatternsTooltip vs Popover vs HoverCardBasic TooltipTooltip ProviderBasic PopoverControlled PopoverHoverCard (Rich Previews)PositioningStyling StatesChecklists (2)Accessibility AuditRadix Accessibility Audit ChecklistKeyboard NavigationFocus ManagementARIA Attributes (Auto-provided by Radix)Screen Reader AnnouncementsDialogs (Dialog, AlertDialog)Menus (DropdownMenu, ContextMenu)TooltipsForms (Select, Checkbox, RadioGroup, Switch)Testing ToolsManual TestingShadcn Setupshadcn/ui Setup ChecklistInitial SetupFile Structure VerificationDependencies InstalledTailwind Configuration (v4 CSS-First)CSS VariablesAdding ComponentsDark Mode SetupTypeScript ConfigurationTesting ChecklistCommon IssuesHydration MismatchMissing CSS VariablesTailwind Classes Not ApplyingRecommended First Components