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.
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
| Category | Rules | Impact | When to Use |
|---|---|---|---|
| shadcn/ui | 4 | HIGH | CVA variants, component customization, form patterns, data tables, v4 styles |
| Radix Primitives | 3 | HIGH | Dialogs, polymorphic composition, data-attribute styling |
| Design System | 5 | HIGH | W3C tokens, OKLCH theming, spacing scales, typography, component states, animation |
| Design System Components | 1 | HIGH | Atomic design, CVA variants, accessibility, Storybook |
| Forms | 2 | HIGH | React Hook Form v7, Zod validation, Server Actions |
| Modern CSS & Tooling | 3 | HIGH | CSS cascade layers, Tailwind v4, Storybook CSF3 |
| UX Foundations | 4 | HIGH | Visual 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.
| Rule | File | Key Pattern |
|---|---|---|
| Customization | rules/shadcn-customization.md | CVA variants, cn() utility, OKLCH theming, component extension |
| Forms | rules/shadcn-forms.md | Form field wrappers, react-hook-form integration, validation |
| Data Table | rules/shadcn-data-table.md | TanStack Table integration, column definitions, sorting/filtering |
| v4 Styles | rules/shadcn-v4-styles.md | 6 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.
| Style | Character | Best For |
|---|---|---|
| Vega | Balanced radius, clean lines | General purpose (successor to New York) |
| Nova | Compact padding, reduced margins | Dense dashboards, admin panels |
| Maia | Soft, rounded, generous spacing | Consumer-facing, friendly apps |
| Lyra | Sharp, zero radius, monospace pairs | Editorial, developer tools |
| Mira | Ultra-compact, minimal chrome | Spreadsheets, data-heavy interfaces |
| Luma | Extreme rounding (rounded-4xl), soft elevation (shadow-md + ring), breathable layouts | Polished 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 + HugeIconsConfigure 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.
| Rule | File | Key Pattern |
|---|---|---|
| Dialog | rules/radix-dialog.md | Dialog, AlertDialog, controlled state, animations |
| Composition | rules/radix-composition.md | asChild, Slot, nested triggers, polymorphic rendering |
| Styling | rules/radix-styling.md | Data attributes, Tailwind arbitrary variants, focus management |
Key Decisions
| Decision | Recommendation |
|---|---|
| Color format | OKLCH for perceptually uniform theming |
| Class merging | Always use cn() for Tailwind conflicts |
| Extending components | Wrap, don't modify source files |
| Variants | Use CVA for type-safe multi-axis variants |
| Styling approach | Data attributes + Tailwind arbitrary variants |
| Composition | Use asChild to avoid wrapper divs |
| Animation | CSS-only with data-state selectors |
| Form components | Combine 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
asChildto avoid extra DOM elements - Missing Dialog.Title: Every dialog must have an accessible title
- Positive tabindex: Using
tabindex > 0disrupts natural tab order - Color-only states: Use data attributes + multiple indicators
- Manual focus management: Use Radix built-in focus trapping
Detailed Documentation
| Resource | Description |
|---|---|
| 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.
| Rule | File | Key Pattern |
|---|---|---|
| Token Architecture | rules/design-system-tokens.md | W3C tokens, OKLCH colors, Tailwind @theme |
| Spacing Scale | rules/design-system-spacing.md | 8px grid, Tailwind space-1 to space-12 |
| Typography Scale | rules/design-system-typography.md | Font sizes, weights, line heights |
| Component States | rules/design-system-states.md | Hover, focus, active, disabled, loading, animation presets |
Design System Components
Component architecture patterns with atomic design and accessibility.
| Rule | File | Key Pattern |
|---|---|---|
| Component Architecture | rules/design-system-components.md | Atomic design, CVA variants, WCAG 2.1 AA, Storybook |
Forms
React Hook Form v7 with Zod validation and React 19 Server Actions.
| Rule | File | Key Pattern |
|---|---|---|
| React Hook Form | rules/forms-react-hook-form.md | useForm, field arrays, Controller, wizards, file uploads |
| Zod & Server Actions | rules/forms-validation-zod.md | Zod schemas, Server Actions, useActionState, async validation |
Modern CSS & Tooling
Modern CSS patterns, Tailwind v4, and component documentation tooling for 2026.
| Rule | File | Key Pattern |
|---|---|---|
| CSS Cascade Layers | rules/css-cascade-layers.md | @layer ordering, specificity-free overrides, third-party isolation |
| Tailwind v4 | rules/tailwind-v4-patterns.md | CSS-first @theme, native container queries, @max-* variants |
| Storybook Docs | rules/storybook-component-docs.md | CSF3 stories, play() interaction tests, Chromatic visual regression |
UX Foundations
Cognitive-science-grounded UI/UX principles with specific numeric thresholds for production-quality interfaces.
| Rule | File | Key Pattern |
|---|---|---|
| Visual Hierarchy | rules/visual-hierarchy.md | Button tiers, de-emphasis, F/Z scan, Von Restorff, proximity, max-width |
| Typography Thresholds | rules/typography-thresholds.md | 65ch line length, 1.4–1.6 line height, rem units, modular type scale |
| Color System | rules/color-system.md | OKLCH 9-shade scales, semantic categories, no true black, brand-tinted neutrals |
| Empty States | rules/empty-states.md | Skeleton-first, icon + headline + description + CTA, cause-specific tone |
Related Skills
ork:accessibility- WCAG compliance and React Aria patternsork: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
| Category | Purpose | Example Tokens |
|---|---|---|
| Brand/Accent | Primary identity, CTAs | brand-500, brand-600 |
| Semantic | Status communication | success, error, warning, info |
| Neutral | Text, backgrounds, borders | neutral-50 → neutral-950 |
Shade Scale Rules
| Shade | Lightness (OKLCH L) | Usage |
|---|---|---|
| 50 | ~0.97 | Tinted backgrounds |
| 100–200 | 0.90–0.86 | Hover backgrounds |
| 300–400 | 0.76–0.65 | Borders, disabled states |
| 500 | ~0.55 | Base/default (AA on white) |
| 600–700 | 0.46–0.38 | Hover states for base |
| 800–950 | 0.30–0.18 | Dark mode surfaces, deep text |
Key Decisions
| Decision | Recommendation |
|---|---|
| Color notation | OKLCH — perceptually uniform, easy to adjust programmatically |
| Shade count | 9 shades per hue (50, 100, 200, 300, 400, 500, 600, 700, 800, 950) |
| True black | Never #000000 — use neutral-950 (oklch ~0.18) |
| Neutral tinting | Tint greys with brand hue (cool brand = cool neutrals) |
| Hue rotation | Shift hue 2–6° darker as lightness decreases to maintain saturation |
| Background | Off-white (neutral-50) not pure white — reduces eye strain |
| Max contrast | Body 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.
Recommended Layer Order
/* Declare layer order once at the top of your entry CSS file */
@layer reset, base, tokens, components, utilities, overrides;| Layer | Purpose | Example |
|---|---|---|
reset | Normalize browser defaults | *, *::before \{ box-sizing: border-box; margin: 0; \} |
base | Element-level defaults | body \{ font-family: var(--font-sans); \} |
tokens | Design tokens / CSS custom properties | :root \{ --color-primary: oklch(0.6 0.2 250); \} |
components | Component-scoped styles | .card \{ border-radius: var(--radius-md); \} |
utilities | Tailwind or utility classes | .sr-only \{ position: absolute; ... \} |
overrides | Page-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
@layerstatement at the top of your entry CSS - Later layers beat earlier layers regardless of selector specificity
- Assign third-party CSS to early layers (
resetorbase) for clean overrides - Never use
!importantto 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
| Level | Description | Examples |
|---|---|---|
| Atoms | Indivisible primitives | Button, Input, Label, Icon |
| Molecules | Simple compositions | FormField, SearchBar, Card |
| Organisms | Complex compositions | Navigation, Modal, DataTable |
| Templates | Page layouts | DashboardLayout, AuthLayout |
| Pages | Specific instances | HomePage, SettingsPage |
WCAG 2.1 Level AA Requirements
| Requirement | Threshold |
|---|---|
| Normal text contrast | 4.5:1 minimum |
| Large text contrast | 3:1 minimum |
| UI components | 3: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
<button>,<nav>,<main>instead of generic divs - ARIA Attributes:
aria-label,aria-expanded,aria-controls,aria-live - No positive tabindex: Using
tabindex > 0disrupts natural tab order
Key Decisions
| Decision | Recommendation |
|---|---|
| Component architecture | Atomic Design (scalable hierarchy) |
| Variant management | CVA (Class Variance Authority) |
| Documentation | Storybook (interactive component playground) |
| Composition | Use asChild to avoid wrapper divs |
| Extending components | Wrap, don't modify source files |
| Class merging | Always 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
| Token | Value | Tailwind | Use Case |
|---|---|---|---|
micro | 4px | p-1, gap-1 | Icon-to-label gaps, inline element spacing |
tight | 8px | p-2, gap-2 | Compact lists, tight form fields, badge padding |
compact | 12px | p-3, gap-3 | Card padding (small), button group gaps |
default | 16px | p-4, gap-4 | Standard padding, form field gaps, paragraph spacing |
comfortable | 24px | p-6, gap-6 | Card padding (large), section gaps within a panel |
loose | 32px | p-8, gap-8 | Page section separation, modal padding |
section | 48px | p-12, gap-12 | Major 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
pxvalues - 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
gapandspace-y/space-xover 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 indicatorCorrect -- button with all 6 states plus animation:
Required States for Interactive Components
| State | Visual Indicator | Purpose |
|---|---|---|
| Default | Base styling | Resting appearance |
| Hover | Subtle background shift, cursor change | Indicates interactivity |
| Focus | Visible ring (2px offset) | Keyboard navigation feedback |
| Active/Pressed | Scale down or darken | Confirms click/tap registered |
| Disabled | Reduced opacity, no pointer events | Shows unavailability |
| Loading | Spinner + disabled interaction | Async 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
| Context | Animation Name | CSS/Tailwind | Duration | Easing |
|---|---|---|---|---|
| Page transitions | pageFade | animate-in fade-in | 200ms | ease-out |
| Modals | modalContent | animate-in zoom-in-95 fade-in | 200ms | ease-out |
| List items | staggerItem | animate-in slide-in-from-bottom-2 | 150ms | ease-out |
| Card hover | cardHover | hover:-translate-y-1 hover:shadow-lg | 200ms | ease-in-out |
| Button tap | tapScale | active:scale-[0.98] | 100ms | ease-in |
| Toast enter | toastSlideIn | animate-in slide-in-from-right | 300ms | ease-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
| Element | Minimum Ratio | Target Ratio | WCAG Level |
|---|---|---|---|
| Body text | 4.5:1 | 7:1 | AA / AAA |
| Large text (18px+ or 14px bold) | 3:1 | 4.5:1 | AA / AAA |
| UI components (borders, icons) | 3:1 | 4.5:1 | AA |
| Focus indicators | 3:1 | 4.5:1 | AA |
State Checklist for New Components
Every interactive component must define:
- Default -- base visual appearance
- Hover --
hover:modifier with subtle visual shift - Focus --
focus-visible:ring-2(never remove focus outlines) - Active --
active:feedback (scale, darken, or both) - Disabled --
disabled:opacity-50 disabled:pointer-events-none - Loading -- spinner icon, disabled interaction, aria-busy="true"
Key decisions:
- Always use
focus-visible(notfocus) to avoid showing rings on mouse click - Keep transitions under 200ms for interactive feedback (longer feels sluggish)
- Use
prefers-reduced-motionmedia 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
| Category | Examples | Scale |
|---|---|---|
| Colors | blue.500, text.primary, feedback.error | 50-950 |
| Typography | fontSize.base, fontWeight.semibold | xs-5xl |
| Spacing | spacing.4, spacing.8 | 0-24 (4px base) |
| Border Radius | borderRadius.md, borderRadius.full | none-full |
| Shadows | shadow.sm, shadow.lg | xs-xl |
Design System Layers
| Layer | Description | Examples |
|---|---|---|
| Design Tokens | Foundational design decisions | Colors, spacing, typography |
| Components | Reusable UI building blocks | Button, Input, Card, Modal |
| Patterns | Common UX solutions | Forms, Navigation, Layouts |
| Guidelines | Rules and best practices | Accessibility, naming, APIs |
Key Decisions
| Decision | Recommendation |
|---|---|
| Token format | W3C Design Tokens (industry standard) |
| Color format | OKLCH for perceptually uniform theming |
| Styling approach | Tailwind @theme directive |
| Spacing base | 4px system |
| Dark mode | Tailwind @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
| Token | Size | Tailwind | Semantic Role |
|---|---|---|---|
caption | 12px | text-xs | Captions, timestamps, helper text |
secondary | 14px | text-sm | Secondary text, labels, metadata |
body | 16px | text-base | Body copy, primary content |
large | 18px | text-lg | Large body, lead paragraphs |
subheading | 20px | text-xl | Section subheadings |
heading | 24px | text-2xl | Card/panel headings |
page-title | 30px | text-3xl | Page titles, hero headings |
Weight Pairing Guide
| Weight | Tailwind | Use With |
|---|---|---|
| Normal (400) | font-normal | Body text, paragraphs, descriptions |
| Medium (500) | font-medium | Labels, nav items, subtle emphasis |
| Semibold (600) | font-semibold | Headings, card titles, table headers |
| Bold (700) | font-bold | Primary emphasis, key metrics, CTAs |
Line Height Rules
| Context | Tailwind | Ratio | When |
|---|---|---|---|
| Headings | leading-tight | 1.25 | Short, large text (h1-h3) |
| Body | leading-normal | 1.5 | Standard paragraphs, lists |
| Long-form | leading-relaxed | 1.625 | Articles, 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-tightfor headings,leading-normalfor body,leading-relaxedfor long-form - Maximum 2 font families per project (1 sans-serif + 1 monospace is ideal)
- Responsive scaling:
text-2xl md:text-3xl lg:text-4xlfor 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
| Cause | Tone | CTA Example |
|---|---|---|
| First-time (onboarding) | Encouraging, welcoming | "Create your first project" |
| No search results | Neutral, helpful | "Try different keywords or clear filters" |
| Error / failed to load | Reassuring, recovery | "Something went wrong — try again" |
| Permission / access | Clear, 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 docsKey Decisions
| Decision | Recommendation |
|---|---|
| Blank screen | Never acceptable — always design an empty state |
| Loading order | Skeleton first → empty state if truly empty (no flash) |
| Headline tone | Encouraging and action-oriented, never clinical |
| CTA count | One primary action maximum per empty state |
| Role attribute | role="status" on the container for screen reader announcement |
| Cause-specific | Different 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.idas key in field arrays, never index - Use
Controllerfor non-native inputs (date pickers, selects, rich text) - Add
noValidateto form element when using Zod (disable browser validation) - Use
aria-invalidandrole="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(notparse) for server actions to return errors instead of throwing - Use
z.infer<typeof schema>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):
- Props merging: Parent props spread to child
- Ref forwarding: Refs correctly forwarded
- Event combining: Both onClick handlers fire
- 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
Link as Button
<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>Menu Item as Link
<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 Case | Use asChild? |
|---|---|
| Link styled as button | Yes |
| Combining triggers | Yes |
| Custom element with Radix behavior | Yes |
| Default element is fine | No |
| Adds complexity without benefit | No |
Requirements for Child Components
The child component MUST:
- Forward refs with
React.forwardRef - Spread props to underlying element
- 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
| Primitive | Use Case |
|---|---|
| Dialog | Modal dialogs, forms, confirmations |
| AlertDialog | Destructive action confirmations |
| Sheet | Side panels, mobile drawers |
Popover Components
| Primitive | Use Case |
|---|---|
| Popover | Rich content on trigger |
| Tooltip | Simple text hints |
| HoverCard | Preview cards on hover |
| ContextMenu | Right-click menus |
Menu Components
| Primitive | Use Case |
|---|---|
| DropdownMenu | Action menus |
| Menubar | Application menubars |
| NavigationMenu | Site navigation |
Form Components
| Primitive | Use Case |
|---|---|
| Select | Custom select dropdowns |
| RadioGroup | Single selection groups |
| Checkbox | Boolean toggles |
| Switch | On/off toggles |
| Slider | Range 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
| Primitive | Use Case |
|---|---|
| Accordion | Expandable sections |
| Collapsible | Single toggle content |
| Tabs | Tabbed 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
| Feature | Dialog | AlertDialog |
|---|---|---|
| Close on overlay click | Yes | No |
| Close on Escape | Yes | Requires explicit action |
| Use case | Forms, content | Destructive 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">Dropdown Menu Styling
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
| Component | Key | Action |
|---|---|---|
| Dialog | Escape | Close |
| Menu | Arrow Up/Down | Navigate |
| Menu | Enter/Space | Select |
| Menu | Right Arrow | Open submenu |
| Menu | Left Arrow | Close submenu |
| Tabs | Arrow Left/Right | Switch tab |
| RadioGroup | Arrow Up/Down | Change selection |
| Select | Arrow Up/Down | Navigate options |
| Select | Enter | Select 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 labelIncorrect — 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
- Keep variants focused: Each variant should have a single responsibility
- Always forward refs: Components may need ref access
- Use compound variants sparingly: Only for complex combinations
- Type exports: Export
VariantPropstype for consumers - 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 inputCreate 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 # Iconsshadcn/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
| Style | Radius | Elevation | Spacing | Best For |
|---|---|---|---|---|
| Vega | rounded-lg | shadow-sm | Balanced | General purpose |
| Nova | rounded-md | None | Compact | Dense dashboards |
| Maia | rounded-xl | shadow-sm | Generous | Consumer apps |
| Lyra | rounded-none | None | Balanced | Editorial, dev tools |
| Mira | rounded-sm | None | Ultra-dense | Spreadsheets, data |
| Luma | rounded-4xl | shadow-md + ring | Breathable | Polished 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 reviewIncorrect -- 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 statesCorrect -- 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<typeof Component>) 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
@themein CSS for all configuration — notailwind.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/<name>for targeted queries - Use
/namesuffix on variants to target specific named containers - Migrate existing
tailwind.config.jsto@themeblock 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
| Property | Threshold | Notes |
|---|---|---|
| Line length — print | 50–75 ch | Use max-w-prose (65ch) |
| Line length — screen | 60–100 ch | UI panels can go wider |
| Line height — body | 1.4–1.6× | leading-relaxed = 1.625 |
| Line height — headings | 1.2–1.3× | leading-tight = 1.25 |
| Line height — minimum | 1.2× | Never below this |
| Font weight — body | 400 (regular) | font-normal |
| Font weight — emphasis | 600+ (semibold) | font-semibold minimum |
| Font weight — headings | 700 (bold) | font-bold |
| Font size units | rem only | Never em for font-size |
Key Decisions
| Decision | Recommendation |
|---|---|
| Font size units | rem only — em cascades multiplicatively |
| Line length | max-w-prose (65ch) on all paragraph containers |
| Link underlines | Always underlined for inline links — no exceptions |
| Type scale | Derive all sizes from a modular scale ratio (1.25 or 1.333) |
| UI font | Sans-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
| Principle | Rule | Rationale |
|---|---|---|
| Button tiers | primary → outline → ghost | One primary per view maximum |
| De-emphasis | Mute secondary content | Easier than boosting everything |
| F/Z scan path | Critical info top-left → right | Matches natural eye movement |
| Von Restorff | Isolate ONE element per view | Uniqueness signals importance |
| Proximity | Group related elements closely | Spacing communicates relationship |
| Max-width | Contain layout, don't fill screen | Prevents 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
| Decision | Recommendation |
|---|---|
| Grayscale test | Design in grayscale first — hierarchy must work without color |
| Button count | Maximum ONE primary button per view |
| Emphasis strategy | De-emphasize secondary; don't over-emphasize primary |
| Von Restorff | Use isolation sparingly — max one "different" element per view |
| Readability cap | max-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:
- Props merging: Parent props spread to child
- Ref forwarding: Refs correctly forwarded
- Event combining: Both onClick handlers fire
- 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
Link as Button
<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>Menu Item as Link
<DropdownMenu.Item asChild>
<Link href="/profile">Profile</Link>
</DropdownMenu.Item>When to Use
| Use Case | Use asChild? |
|---|---|
| Link styled as button | Yes |
| Combining triggers | Yes |
| Custom element with Radix behavior | Yes |
| Default element is fine | No |
| Adds complexity without benefit | No |
Requirements for Child Components
The child component MUST:
- Forward refs with
React.forwardRef - Spread props to underlying element
- 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 outObject 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
twMergeis 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 memoizationComponent 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:
- Wrap the original component
- Forward refs correctly
- Preserve the variant system
- 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
- Always forward refs - Components may need ref access
- Preserve displayName - Helps with debugging
- Type props explicitly - Use ComponentPropsWithoutRef
- Keep variants compatible - Don't break existing API
- 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
- Keep variants focused: Each variant should have a single responsibility
- Use compound variants sparingly: Only for complex combinations
- Default variants: Always set sensible defaults
- Type exports: Export
VariantPropstype for consumers - Consistent naming:
variantfor style,sizefor 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-themes2. 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
| Feature | Dialog | AlertDialog |
|---|---|---|
| Close on overlay click | Yes | No |
| Close on Escape | Yes | Requires explicit action |
| Use case | Forms, content | Destructive 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.DescriptionAnimation 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
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:
| Feature | Description |
|---|---|
| Focus trap | Focus stays within component |
| Focus return | Focus returns to trigger on close |
| Visible focus | Clear focus indicators |
| Roving tabindex | Arrow 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
| Component | Key | Action |
|---|---|---|
| Dialog | Escape | Close |
| Menu | Arrow Up/Down | Navigate |
| Menu | Enter/Space | Select |
| Menu | Right Arrow | Open submenu |
| Menu | Left Arrow | Close submenu |
| Tabs | Arrow Left/Right | Switch tab |
| RadioGroup | Arrow Up/Down | Change selection |
| Select | Arrow Up/Down | Navigate options |
| Select | Enter | Select 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?
| Feature | OKLCH | HSL |
|---|---|---|
| Perceptual uniformity | Yes | No |
| Wide gamut support | Yes | Limited |
| Predictable lightness | Yes | No |
| Dark mode conversion | Easier | Manual |
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 */
}Sidebar-Specific Colors
: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 shiftUse 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
| Component | Trigger | Content | Use Case |
|---|---|---|---|
| Tooltip | Hover/Focus | Text only | Icon hints, abbreviations |
| Popover | Click | Interactive | Forms, rich content |
| HoverCard | Hover | Rich preview | User 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)
-
roleappropriate for component type -
aria-expandedon triggers for expandable content -
aria-controlslinks trigger to content -
aria-haspopupon menu triggers -
aria-selectedon selected tabs/items -
aria-checkedon checkboxes/radios -
aria-disabledon 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)
Menus (DropdownMenu, ContextMenu)
- 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.Providerfor 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 InsightsManual Testing
- Keyboard-only: Unplug mouse, navigate entire flow
- Screen reader: Test with VoiceOver (Mac), NVDA (Windows), or Orca (Linux)
- Zoom: Test at 200% and 400% zoom levels
- High contrast: Test with OS high contrast mode
- 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.jsongenerated
File Structure Verification
-
components.jsoncreated at root -
lib/utils.tscreated withcn()function -
components/ui/directory created - CSS variables added to
globals.cssorapp.css - Dark mode configured via CSS (Tailwind v4 CSS-first approach)
Dependencies Installed
-
class-variance-authorityfor variants -
clsxfor conditional classes -
tailwind-mergefor class merging -
radix-uiunified package (or individual@radix-ui/react-*) -
lucide-reactfor 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.jsneeded (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
suppressHydrationWarningto<html> - 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 nullMissing CSS Variables
- Check
globals.cssis 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
Recommended First Components
button- Foundation for CTAsinput+label- Form basicscard- Content containersdialog- Modalsdropdown-menu- Actions menutoast- Notifications
Testing Unit
Unit testing patterns for isolated business logic tests — AAA pattern, parametrized tests (test.each, @pytest.mark.parametrize), fixture scoping (function/module/session), mocking with MSW/VCR at network level, and test data management with factories (FactoryBoy, faker-js). Use when writing unit tests, setting up mocks, structuring test data, optimizing test speed, choosing fixture scope, or reducing test boilerplate. Covers Vitest, Jest, pytest.
Upgrade Assessment
Assess platform upgrade readiness for Claude model and CC version changes. Use when evaluating upgrades.
Last updated on