Tailwind Design System (v4)
Build production-ready design systems with Tailwind CSS v4, including CSS-first configuration, design tokens, component variants, responsive patterns, and accessibility.
Note: This skill targets Tailwind CSS v4 (2024+). For v3 projects, refer to the upgrade guide.
When to Use This Skill
- Creating a component library with Tailwind v4
- Implementing design tokens and theming with CSS-first configuration
- Building responsive and accessible components
- Standardizing UI patterns across a codebase
- Migrating from Tailwind v3 to v4
- Setting up dark mode with native CSS features
Key v4 Changes
| v3 Pattern | v4 Pattern |
|---|---|
tailwind.config.ts | @theme in CSS |
@tailwind base/components/utilities | @import "tailwindcss" |
darkMode: "class" | @custom-variant dark (&:where(.dark, .dark *)) |
theme.extend.colors | @theme { --color-*: value } |
require("tailwindcss-animate") | CSS @keyframes in @theme + @starting-style for entry animations |
Quick Start
css1/* app.css - Tailwind v4 CSS-first configuration */ 2@import "tailwindcss"; 3 4/* Define your theme with @theme */ 5@theme { 6 /* Semantic color tokens using OKLCH for better color perception */ 7 --color-background: oklch(100% 0 0); 8 --color-foreground: oklch(14.5% 0.025 264); 9 10 --color-primary: oklch(14.5% 0.025 264); 11 --color-primary-foreground: oklch(98% 0.01 264); 12 13 --color-secondary: oklch(96% 0.01 264); 14 --color-secondary-foreground: oklch(14.5% 0.025 264); 15 16 --color-muted: oklch(96% 0.01 264); 17 --color-muted-foreground: oklch(46% 0.02 264); 18 19 --color-accent: oklch(96% 0.01 264); 20 --color-accent-foreground: oklch(14.5% 0.025 264); 21 22 --color-destructive: oklch(53% 0.22 27); 23 --color-destructive-foreground: oklch(98% 0.01 264); 24 25 --color-border: oklch(91% 0.01 264); 26 --color-ring: oklch(14.5% 0.025 264); 27 28 --color-card: oklch(100% 0 0); 29 --color-card-foreground: oklch(14.5% 0.025 264); 30 31 /* Ring offset for focus states */ 32 --color-ring-offset: oklch(100% 0 0); 33 34 /* Radius tokens */ 35 --radius-sm: 0.25rem; 36 --radius-md: 0.375rem; 37 --radius-lg: 0.5rem; 38 --radius-xl: 0.75rem; 39 40 /* Animation tokens - keyframes inside @theme are output when referenced by --animate-* variables */ 41 --animate-fade-in: fade-in 0.2s ease-out; 42 --animate-fade-out: fade-out 0.2s ease-in; 43 --animate-slide-in: slide-in 0.3s ease-out; 44 --animate-slide-out: slide-out 0.3s ease-in; 45 46 @keyframes fade-in { 47 from { 48 opacity: 0; 49 } 50 to { 51 opacity: 1; 52 } 53 } 54 55 @keyframes fade-out { 56 from { 57 opacity: 1; 58 } 59 to { 60 opacity: 0; 61 } 62 } 63 64 @keyframes slide-in { 65 from { 66 transform: translateY(-0.5rem); 67 opacity: 0; 68 } 69 to { 70 transform: translateY(0); 71 opacity: 1; 72 } 73 } 74 75 @keyframes slide-out { 76 from { 77 transform: translateY(0); 78 opacity: 1; 79 } 80 to { 81 transform: translateY(-0.5rem); 82 opacity: 0; 83 } 84 } 85} 86 87/* Dark mode variant - use @custom-variant for class-based dark mode */ 88@custom-variant dark (&:where(.dark, .dark *)); 89 90/* Dark mode theme overrides */ 91.dark { 92 --color-background: oklch(14.5% 0.025 264); 93 --color-foreground: oklch(98% 0.01 264); 94 95 --color-primary: oklch(98% 0.01 264); 96 --color-primary-foreground: oklch(14.5% 0.025 264); 97 98 --color-secondary: oklch(22% 0.02 264); 99 --color-secondary-foreground: oklch(98% 0.01 264); 100 101 --color-muted: oklch(22% 0.02 264); 102 --color-muted-foreground: oklch(65% 0.02 264); 103 104 --color-accent: oklch(22% 0.02 264); 105 --color-accent-foreground: oklch(98% 0.01 264); 106 107 --color-destructive: oklch(42% 0.15 27); 108 --color-destructive-foreground: oklch(98% 0.01 264); 109 110 --color-border: oklch(22% 0.02 264); 111 --color-ring: oklch(83% 0.02 264); 112 113 --color-card: oklch(14.5% 0.025 264); 114 --color-card-foreground: oklch(98% 0.01 264); 115 116 --color-ring-offset: oklch(14.5% 0.025 264); 117} 118 119/* Base styles */ 120@layer base { 121 * { 122 @apply border-border; 123 } 124 125 body { 126 @apply bg-background text-foreground antialiased; 127 } 128}
Core Concepts
1. Design Token Hierarchy
Brand Tokens (abstract)
└── Semantic Tokens (purpose)
└── Component Tokens (specific)
Example:
oklch(45% 0.2 260) → --color-primary → bg-primary
2. Component Architecture
Base styles → Variants → Sizes → States → Overrides
Patterns
Pattern 1: CVA (Class Variance Authority) Components
typescript1// components/ui/button.tsx 2import { Slot } from '@radix-ui/react-slot' 3import { cva, type VariantProps } from 'class-variance-authority' 4import { cn } from '@/lib/utils' 5 6const buttonVariants = cva( 7 // Base styles - v4 uses native CSS variables 8 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 9 { 10 variants: { 11 variant: { 12 default: 'bg-primary text-primary-foreground hover:bg-primary/90', 13 destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 14 outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground', 15 secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 16 ghost: 'hover:bg-accent hover:text-accent-foreground', 17 link: 'text-primary underline-offset-4 hover:underline', 18 }, 19 size: { 20 default: 'h-10 px-4 py-2', 21 sm: 'h-9 rounded-md px-3', 22 lg: 'h-11 rounded-md px-8', 23 icon: 'size-10', 24 }, 25 }, 26 defaultVariants: { 27 variant: 'default', 28 size: 'default', 29 }, 30 } 31) 32 33export interface ButtonProps 34 extends React.ButtonHTMLAttributes<HTMLButtonElement>, 35 VariantProps<typeof buttonVariants> { 36 asChild?: boolean 37} 38 39// React 19: No forwardRef needed 40export function Button({ 41 className, 42 variant, 43 size, 44 asChild = false, 45 ref, 46 ...props 47}: ButtonProps & { ref?: React.Ref<HTMLButtonElement> }) { 48 const Comp = asChild ? Slot : 'button' 49 return ( 50 <Comp 51 className={cn(buttonVariants({ variant, size, className }))} 52 ref={ref} 53 {...props} 54 /> 55 ) 56} 57 58// Usage 59<Button variant="destructive" size="lg">Delete</Button> 60<Button variant="outline">Cancel</Button> 61<Button asChild><Link href="/home">Home</Link></Button>
Pattern 2: Compound Components (React 19)
typescript1// components/ui/card.tsx 2import { cn } from '@/lib/utils' 3 4// React 19: ref is a regular prop, no forwardRef 5export function Card({ 6 className, 7 ref, 8 ...props 9}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { 10 return ( 11 <div 12 ref={ref} 13 className={cn( 14 'rounded-lg border border-border bg-card text-card-foreground shadow-sm', 15 className 16 )} 17 {...props} 18 /> 19 ) 20} 21 22export function CardHeader({ 23 className, 24 ref, 25 ...props 26}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { 27 return ( 28 <div 29 ref={ref} 30 className={cn('flex flex-col space-y-1.5 p-6', className)} 31 {...props} 32 /> 33 ) 34} 35 36export function CardTitle({ 37 className, 38 ref, 39 ...props 40}: React.HTMLAttributes<HTMLHeadingElement> & { ref?: React.Ref<HTMLHeadingElement> }) { 41 return ( 42 <h3 43 ref={ref} 44 className={cn('text-2xl font-semibold leading-none tracking-tight', className)} 45 {...props} 46 /> 47 ) 48} 49 50export function CardDescription({ 51 className, 52 ref, 53 ...props 54}: React.HTMLAttributes<HTMLParagraphElement> & { ref?: React.Ref<HTMLParagraphElement> }) { 55 return ( 56 <p 57 ref={ref} 58 className={cn('text-sm text-muted-foreground', className)} 59 {...props} 60 /> 61 ) 62} 63 64export function CardContent({ 65 className, 66 ref, 67 ...props 68}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { 69 return ( 70 <div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> 71 ) 72} 73 74export function CardFooter({ 75 className, 76 ref, 77 ...props 78}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { 79 return ( 80 <div 81 ref={ref} 82 className={cn('flex items-center p-6 pt-0', className)} 83 {...props} 84 /> 85 ) 86} 87 88// Usage 89<Card> 90 <CardHeader> 91 <CardTitle>Account</CardTitle> 92 <CardDescription>Manage your account settings</CardDescription> 93 </CardHeader> 94 <CardContent> 95 <form>...</form> 96 </CardContent> 97 <CardFooter> 98 <Button>Save</Button> 99 </CardFooter> 100</Card>
Pattern 3: Form Components
typescript1// components/ui/input.tsx 2import { cn } from '@/lib/utils' 3 4export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { 5 error?: string 6 ref?: React.Ref<HTMLInputElement> 7} 8 9export function Input({ className, type, error, ref, ...props }: InputProps) { 10 return ( 11 <div className="relative"> 12 <input 13 type={type} 14 className={cn( 15 'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background 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 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 16 error && 'border-destructive focus-visible:ring-destructive', 17 className 18 )} 19 ref={ref} 20 aria-invalid={!!error} 21 aria-describedby={error ? `${props.id}-error` : undefined} 22 {...props} 23 /> 24 {error && ( 25 <p 26 id={`${props.id}-error`} 27 className="mt-1 text-sm text-destructive" 28 role="alert" 29 > 30 {error} 31 </p> 32 )} 33 </div> 34 ) 35} 36 37// components/ui/label.tsx 38import { cva, type VariantProps } from 'class-variance-authority' 39 40const labelVariants = cva( 41 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 42) 43 44export function Label({ 45 className, 46 ref, 47 ...props 48}: React.LabelHTMLAttributes<HTMLLabelElement> & { ref?: React.Ref<HTMLLabelElement> }) { 49 return ( 50 <label ref={ref} className={cn(labelVariants(), className)} {...props} /> 51 ) 52} 53 54// Usage with React Hook Form + Zod 55import { useForm } from 'react-hook-form' 56import { zodResolver } from '@hookform/resolvers/zod' 57import { z } from 'zod' 58 59const schema = z.object({ 60 email: z.string().email('Invalid email address'), 61 password: z.string().min(8, 'Password must be at least 8 characters'), 62}) 63 64function LoginForm() { 65 const { register, handleSubmit, formState: { errors } } = useForm({ 66 resolver: zodResolver(schema), 67 }) 68 69 return ( 70 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> 71 <div className="space-y-2"> 72 <Label htmlFor="email">Email</Label> 73 <Input 74 id="email" 75 type="email" 76 {...register('email')} 77 error={errors.email?.message} 78 /> 79 </div> 80 <div className="space-y-2"> 81 <Label htmlFor="password">Password</Label> 82 <Input 83 id="password" 84 type="password" 85 {...register('password')} 86 error={errors.password?.message} 87 /> 88 </div> 89 <Button type="submit" className="w-full">Sign In</Button> 90 </form> 91 ) 92}
Pattern 4: Responsive Grid System
typescript1// components/ui/grid.tsx 2import { cn } from '@/lib/utils' 3import { cva, type VariantProps } from 'class-variance-authority' 4 5const gridVariants = cva('grid', { 6 variants: { 7 cols: { 8 1: 'grid-cols-1', 9 2: 'grid-cols-1 sm:grid-cols-2', 10 3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', 11 4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4', 12 5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5', 13 6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6', 14 }, 15 gap: { 16 none: 'gap-0', 17 sm: 'gap-2', 18 md: 'gap-4', 19 lg: 'gap-6', 20 xl: 'gap-8', 21 }, 22 }, 23 defaultVariants: { 24 cols: 3, 25 gap: 'md', 26 }, 27}) 28 29interface GridProps 30 extends React.HTMLAttributes<HTMLDivElement>, 31 VariantProps<typeof gridVariants> {} 32 33export function Grid({ className, cols, gap, ...props }: GridProps) { 34 return ( 35 <div className={cn(gridVariants({ cols, gap, className }))} {...props} /> 36 ) 37} 38 39// Container component 40const containerVariants = cva('mx-auto w-full px-4 sm:px-6 lg:px-8', { 41 variants: { 42 size: { 43 sm: 'max-w-screen-sm', 44 md: 'max-w-screen-md', 45 lg: 'max-w-screen-lg', 46 xl: 'max-w-screen-xl', 47 '2xl': 'max-w-screen-2xl', 48 full: 'max-w-full', 49 }, 50 }, 51 defaultVariants: { 52 size: 'xl', 53 }, 54}) 55 56interface ContainerProps 57 extends React.HTMLAttributes<HTMLDivElement>, 58 VariantProps<typeof containerVariants> {} 59 60export function Container({ className, size, ...props }: ContainerProps) { 61 return ( 62 <div className={cn(containerVariants({ size, className }))} {...props} /> 63 ) 64} 65 66// Usage 67<Container> 68 <Grid cols={4} gap="lg"> 69 {products.map((product) => ( 70 <ProductCard key={product.id} product={product} /> 71 ))} 72 </Grid> 73</Container>
Pattern 5: Native CSS Animations (v4)
css1/* In your CSS file - native @starting-style for entry animations */ 2@theme { 3 --animate-dialog-in: dialog-fade-in 0.2s ease-out; 4 --animate-dialog-out: dialog-fade-out 0.15s ease-in; 5} 6 7@keyframes dialog-fade-in { 8 from { 9 opacity: 0; 10 transform: scale(0.95) translateY(-0.5rem); 11 } 12 to { 13 opacity: 1; 14 transform: scale(1) translateY(0); 15 } 16} 17 18@keyframes dialog-fade-out { 19 from { 20 opacity: 1; 21 transform: scale(1) translateY(0); 22 } 23 to { 24 opacity: 0; 25 transform: scale(0.95) translateY(-0.5rem); 26 } 27} 28 29/* Native popover animations using @starting-style */ 30[popover] { 31 transition: 32 opacity 0.2s, 33 transform 0.2s, 34 display 0.2s allow-discrete; 35 opacity: 0; 36 transform: scale(0.95); 37} 38 39[popover]:popover-open { 40 opacity: 1; 41 transform: scale(1); 42} 43 44@starting-style { 45 [popover]:popover-open { 46 opacity: 0; 47 transform: scale(0.95); 48 } 49}
typescript1// components/ui/dialog.tsx - Using native popover API 2import * as DialogPrimitive from '@radix-ui/react-dialog' 3import { cn } from '@/lib/utils' 4 5const DialogPortal = DialogPrimitive.Portal 6 7export function DialogOverlay({ 8 className, 9 ref, 10 ...props 11}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { 12 ref?: React.Ref<HTMLDivElement> 13}) { 14 return ( 15 <DialogPrimitive.Overlay 16 ref={ref} 17 className={cn( 18 'fixed inset-0 z-50 bg-black/80', 19 'data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out', 20 className 21 )} 22 {...props} 23 /> 24 ) 25} 26 27export function DialogContent({ 28 className, 29 children, 30 ref, 31 ...props 32}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { 33 ref?: React.Ref<HTMLDivElement> 34}) { 35 return ( 36 <DialogPortal> 37 <DialogOverlay /> 38 <DialogPrimitive.Content 39 ref={ref} 40 className={cn( 41 'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-background p-6 shadow-lg sm:rounded-lg', 42 'data-[state=open]:animate-dialog-in data-[state=closed]:animate-dialog-out', 43 className 44 )} 45 {...props} 46 > 47 {children} 48 </DialogPrimitive.Content> 49 </DialogPortal> 50 ) 51}
Pattern 6: Dark Mode with CSS (v4)
typescript1// providers/ThemeProvider.tsx - Simplified for v4 2'use client' 3 4import { createContext, useContext, useEffect, useState } from 'react' 5 6type Theme = 'dark' | 'light' | 'system' 7 8interface ThemeContextType { 9 theme: Theme 10 setTheme: (theme: Theme) => void 11 resolvedTheme: 'dark' | 'light' 12} 13 14const ThemeContext = createContext<ThemeContextType | undefined>(undefined) 15 16export function ThemeProvider({ 17 children, 18 defaultTheme = 'system', 19 storageKey = 'theme', 20}: { 21 children: React.ReactNode 22 defaultTheme?: Theme 23 storageKey?: string 24}) { 25 const [theme, setTheme] = useState<Theme>(defaultTheme) 26 const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('light') 27 28 useEffect(() => { 29 const stored = localStorage.getItem(storageKey) as Theme | null 30 if (stored) setTheme(stored) 31 }, [storageKey]) 32 33 useEffect(() => { 34 const root = document.documentElement 35 root.classList.remove('light', 'dark') 36 37 const resolved = theme === 'system' 38 ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') 39 : theme 40 41 root.classList.add(resolved) 42 setResolvedTheme(resolved) 43 44 // Update meta theme-color for mobile browsers 45 const metaThemeColor = document.querySelector('meta[name="theme-color"]') 46 if (metaThemeColor) { 47 metaThemeColor.setAttribute('content', resolved === 'dark' ? '#09090b' : '#ffffff') 48 } 49 }, [theme]) 50 51 return ( 52 <ThemeContext.Provider value={{ 53 theme, 54 setTheme: (newTheme) => { 55 localStorage.setItem(storageKey, newTheme) 56 setTheme(newTheme) 57 }, 58 resolvedTheme, 59 }}> 60 {children} 61 </ThemeContext.Provider> 62 ) 63} 64 65export const useTheme = () => { 66 const context = useContext(ThemeContext) 67 if (!context) throw new Error('useTheme must be used within ThemeProvider') 68 return context 69} 70 71// components/ThemeToggle.tsx 72import { Moon, Sun } from 'lucide-react' 73import { useTheme } from '@/providers/ThemeProvider' 74 75export function ThemeToggle() { 76 const { resolvedTheme, setTheme } = useTheme() 77 78 return ( 79 <Button 80 variant="ghost" 81 size="icon" 82 onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')} 83 > 84 <Sun className="size-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> 85 <Moon className="absolute size-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> 86 <span className="sr-only">Toggle theme</span> 87 </Button> 88 ) 89}
Utility Functions
typescript1// lib/utils.ts 2import { type ClassValue, clsx } from "clsx"; 3import { twMerge } from "tailwind-merge"; 4 5export function cn(...inputs: ClassValue[]) { 6 return twMerge(clsx(inputs)); 7} 8 9// Focus ring utility 10export const focusRing = cn( 11 "focus-visible:outline-none focus-visible:ring-2", 12 "focus-visible:ring-ring focus-visible:ring-offset-2", 13); 14 15// Disabled utility 16export const disabled = "disabled:pointer-events-none disabled:opacity-50";
Advanced v4 Patterns
Custom Utilities with @utility
Define reusable custom utilities:
css1/* Custom utility for decorative lines */ 2@utility line-t { 3 @apply relative before:absolute before:top-0 before:-left-[100vw] before:h-px before:w-[200vw] before:bg-gray-950/5 dark:before:bg-white/10; 4} 5 6/* Custom utility for text gradients */ 7@utility text-gradient { 8 @apply bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent; 9}
Theme Modifiers
css1/* Use @theme inline when referencing other CSS variables */ 2@theme inline { 3 --font-sans: var(--font-inter), system-ui; 4} 5 6/* Use @theme static to always generate CSS variables (even when unused) */ 7@theme static { 8 --color-brand: oklch(65% 0.15 240); 9} 10 11/* Import with theme options */ 12@import "tailwindcss" theme(static);
Namespace Overrides
css1@theme { 2 /* Clear all default colors and define your own */ 3 --color-*: initial; 4 --color-white: #fff; 5 --color-black: #000; 6 --color-primary: oklch(45% 0.2 260); 7 --color-secondary: oklch(65% 0.15 200); 8 9 /* Clear ALL defaults for a minimal setup */ 10 /* --*: initial; */ 11}
Semi-transparent Color Variants
css1@theme { 2 /* Use color-mix() for alpha variants */ 3 --color-primary-50: color-mix(in oklab, var(--color-primary) 5%, transparent); 4 --color-primary-100: color-mix( 5 in oklab, 6 var(--color-primary) 10%, 7 transparent 8 ); 9 --color-primary-200: color-mix( 10 in oklab, 11 var(--color-primary) 20%, 12 transparent 13 ); 14}
Container Queries
css1@theme { 2 --container-xs: 20rem; 3 --container-sm: 24rem; 4 --container-md: 28rem; 5 --container-lg: 32rem; 6}
v3 to v4 Migration Checklist
- Replace
tailwind.config.tswith CSS@themeblock - Change
@tailwind base/components/utilitiesto@import "tailwindcss" - Move color definitions to
@theme { --color-*: value } - Replace
darkMode: "class"with@custom-variant dark - Move
@keyframesinside@themeblocks (ensures keyframes output with theme) - Replace
require("tailwindcss-animate")with native CSS animations - Update
h-10 w-10tosize-10(new utility) - Remove
forwardRef(React 19 passes ref as prop) - Consider OKLCH colors for better color perception
- Replace custom plugins with
@utilitydirectives
Best Practices
Do's
- Use
@themeblocks - CSS-first configuration is v4's core pattern - Use OKLCH colors - Better perceptual uniformity than HSL
- Compose with CVA - Type-safe variants
- Use semantic tokens -
bg-primarynotbg-blue-500 - Use
size-*- New shorthand forw-* h-* - Add accessibility - ARIA attributes, focus states
Don'ts
- Don't use
tailwind.config.ts- Use CSS@themeinstead - Don't use
@tailwinddirectives - Use@import "tailwindcss" - Don't use
forwardRef- React 19 passes ref as prop - Don't use arbitrary values - Extend
@themeinstead - Don't hardcode colors - Use semantic tokens
- Don't forget dark mode - Test both themes