Canonical reference for design system engineering, accessibility, and UX patterns. This skill absorbs and expands component design, form handling, and accessibility guidance — these topics live here, not in language-specific skills.
For responsive CSS and Tailwind patterns, see /css-responsive. For SaaS-specific product patterns, see /saas-product. For React hooks and state, see /react.
1. Design Philosophy
| Principle | Application |
|---|---|
| Consistency > aesthetics | A consistent but plain UI outperforms an inconsistent beautiful one |
| Constraints enable creativity | Tokens and scales prevent visual entropy and speed up decisions |
| Accessibility is design | A11y isn't a bolt-on — it shapes hierarchy, contrast, spacing, and interaction |
| Composable over configurable | Combine small, focused components instead of building mega-components with 30 props |
| Content drives layout | Design components around real data lengths and edge cases, not lorem ipsum |
2. Design Token Architecture
Token layers
Tokens flow through three layers: primitive → semantic → component.
/* Primitive: raw values, never used directly in components */
:root {
--color-gray-50: #f9fafb;
--color-gray-900: #111827;
--color-blue-600: #2563eb;
--space-1: 0.25rem;
--space-4: 1rem;
--radius-md: 0.375rem;
}
/* Semantic: purpose-based, theme-switchable */
:root {
--color-bg-primary: var(--color-gray-50);
--color-text-primary: var(--color-gray-900);
--color-interactive: var(--color-blue-600);
--space-component-gap: var(--space-4);
--radius-default: var(--radius-md);
}
/* Component: scoped to specific components */
.button {
--button-bg: var(--color-interactive);
--button-radius: var(--radius-default);
--button-padding: var(--space-1) var(--space-4);
}
Token categories
| Category | Examples | Scale |
|---|---|---|
| Color | bg-primary, text-muted, border-default |
Semantic names, not color names |
| Spacing | space-1 through space-16 |
4px base scale (0.25rem increments) |
| Typography | text-sm, text-base, text-xl |
Modular scale (1.25 ratio) |
| Radius | radius-sm, radius-md, radius-lg |
3-4 values max |
| Elevation | shadow-sm, shadow-md, shadow-lg |
3-4 levels, tied to z-index |
| Duration | duration-fast, duration-normal, duration-slow |
100ms / 200ms / 300ms |
Rules:
- Components reference only semantic tokens, never primitives
- Semantic tokens reference primitives — this is the only place primitives appear
- New tokens need a clear semantic purpose — don't add tokens speculatively
3. Visual Hierarchy
Typography scale
Use a modular scale (ratio 1.25) anchored at 16px base:
| Token | Size | Use |
|---|---|---|
text-xs |
12px | Captions, labels, timestamps |
text-sm |
14px | Secondary text, descriptions |
text-base |
16px | Body text (minimum for readability) |
text-lg |
18px | Subheadings, card titles |
text-xl |
20px | Section headings |
text-2xl |
24px | Page titles |
text-3xl |
30px | Hero headings |
Whitespace rhythm
Consistent spacing creates visual rhythm. Use the 4px grid:
- Within components:
gap-2(8px) between related elements - Between components:
gap-4(16px) between sibling components - Between sections:
gap-8(32px) between page sections - Page padding:
p-4mobile,p-6tablet,p-8desktop
Color as hierarchy
| Level | Treatment | Example |
|---|---|---|
| Primary | Full contrast (text-foreground) |
Headlines, primary actions |
| Secondary | Reduced contrast (text-muted-foreground) |
Descriptions, labels |
| Tertiary | Lowest contrast (text-muted-foreground/60) |
Timestamps, metadata |
| Interactive | Accent color (text-primary) |
Links, active states |
| Danger | Red (text-destructive) |
Errors, delete actions |
4. Component API Design
Composition over prop drilling
// WRONG: drilling user through 4 levels
<Layout user={user}><Sidebar user={user}><Nav user={user} /></Sidebar></Layout>
// CORRECT: compose with children, read from context/hooks
<Layout><Sidebar><Nav /></Sidebar></Layout>
Compound components
Use React Context to share implicit state between a parent and its children:
<Select>
<Select.Trigger>Choose...</Select.Trigger>
<Select.Content>
<Select.Item value="a">Option A</Select.Item>
</Select.Content>
</Select>
Each child renders itself; the parent manages shared state. This is the Radix UI / shadcn/ui pattern.
Headless component libraries
Prefer accessible behavior primitives with zero styling opinions:
- Radix UI — shadcn/ui foundation, comprehensive
- React Aria (Adobe) — extensive accessibility primitives
- Ariakit — lightweight alternative
These handle keyboard navigation, focus trapping, ARIA attributes, and screen reader announcements automatically.
Props interface rules
- Use
interfacefor component props (supports declaration merging) - Use explicit prop types — never
React.FC(it adds implicitchildren) - Destructure props in the function signature
- Use
ComponentPropsWithoutRef<"button">when extending native elements
interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
variant?: "primary" | "secondary";
isLoading?: boolean;
}
function Button({ variant = "primary", isLoading, children, ...rest }: ButtonProps) {
return <button {...rest}>{isLoading ? <Spinner /> : children}</button>;
}
Polymorphic as prop
Allow consumers to change the rendered element:
interface BoxProps<T extends ElementType = "div"> {
as?: T;
children: ReactNode;
}
function Box<T extends ElementType = "div">({ as, children, ...props }: BoxProps<T> & ComponentPropsWithoutRef<T>) {
const Component = as ?? "div";
return <Component {...props}>{children}</Component>;
}
// Usage
<Box as="section" className="p-4">Content</Box>
<Box as="article" className="p-4">Content</Box>
Variant systems with CVA
Use class-variance-authority for type-safe variant management:
import { cva, type VariantProps } from "class-variance-authority";
const button = cva("rounded font-medium transition-colors", {
variants: {
intent: {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
danger: "bg-red-600 text-white hover:bg-red-700",
},
size: {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
},
},
defaultVariants: { intent: "primary", size: "md" },
});
Single responsibility
A component should do one thing. If a component fetches data, transforms it, validates it, and renders a form, break it into:
- A route loader for data fetching
- A custom hook for transformation logic
- A form component for rendering and validation
Composability test
A component is well-designed if it can be:
- Used in isolation (storybook, test)
- Composed with siblings without knowing about them
- Extended without modifying its source
5. Form UX
Server-validated forms
The server is the single source of validation truth. Client-side validation is progressive enhancement only.
Validation layers (in order of authority):
- HTML5 attributes —
required,pattern,min,max,maxLength,type="email". Browser-native, works without JS, instant feedback - Server validation — Zod or equivalent runs inside the route action. Returns field-level errors via
Response.json({ fieldErrors }) - Database constraints —
CHECK,NOT NULL,UNIQUE. Last line of defense
// Action validates and returns field errors
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return Response.json(
{ fieldErrors: result.error.flatten().fieldErrors },
{ status: 400 },
);
}
await saveResource(result.data);
return Response.json({ success: true });
}
// Runtime type guard — useActionData returns unknown in RRv7
function hasFieldErrors(data: unknown): data is { fieldErrors: Record<string, string[]> } {
if (typeof data !== "object" || data === null) return false;
const d = data as Record<string, unknown>;
return typeof d.fieldErrors === "object" && d.fieldErrors !== null && !Array.isArray(d.fieldErrors);
}
// Component displays server errors via useActionData
function ResourceForm() {
const actionData = useActionData<typeof action>();
const errors = hasFieldErrors(actionData) ? actionData.fieldErrors : undefined;
return (
<Form method="post">
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
required
aria-invalid={!!errors?.email}
aria-describedby={errors?.email ? "email-error" : undefined}
/>
{errors?.email && (
<p id="email-error" role="alert" className="text-sm text-red-600 mt-1">
{errors.email[0]}
</p>
)}
</div>
<button type="submit">Save</button>
</Form>
);
}
Cross-field validation
Use Zod .refine() on the server. Return the error on the relevant field's path:
const dateRangeSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}).refine(
(data) => data.endDate > data.startDate,
{ message: "End date must be after start date", path: ["endDate"] },
);
Error placement
- Display errors below the field, not above or in a summary at the top
- Use
aria-describedbyto associate the error with the input - Use
role="alert"on the error container - Never clear a server error until the next form submission
Multi-step forms
For wizard-style forms with 3+ steps, use server-managed progression:
- Store current step in session or database — not in
useState - Each step is a
<Form>submission. The action validates and advances the step - The loader returns the current step and any previously submitted data
- Works without JavaScript — each step is a full page transition
- Allow backward navigation (loader serves previous step data with
defaultValue) - Show a progress indicator (step 2 of 4)
- Submit all collected data in the final step's action
Disabled vs read-only
- Disabled — user cannot interact AND the value is not submitted. Use for actions not currently available
- Read-only — user cannot edit BUT the value IS submitted. Use for pre-filled fields the user should see but not change
- Never disable a field to "prevent errors" — use validation instead
Auto-save patterns
Use useFetcher for non-blocking server saves:
useFetcher.Formwithmethod="post"on blur or after a debounce- Show subtle "Saving..." / "Saved" indicator via
fetcher.state - No manual
fetch()calls — the fetcher handles serialization and error states - Handle conflicts: if the server rejects a stale save, show a merge/overwrite choice
6. Accessibility
Semantic HTML first, ARIA second
If a native HTML element provides the behavior, use it:
// WRONG
<div role="button" onClick={handleClick} tabIndex={0}>Submit</div>
// CORRECT
<button onClick={handleClick}>Submit</button>
Focus management
- Modals: Trap focus inside, return focus to trigger on close
- Route changes: Move focus to the main content heading
- Dynamic content: Use
aria-liveregions for toast notifications - Error messages: Associate with inputs via
aria-describedby
<input aria-describedby="name-error" />
{error && <p id="name-error" role="alert">{error}</p>}
Keyboard navigation
- All interactive elements reachable via Tab
- Custom widgets implement arrow key navigation per WAI-ARIA patterns
- Visible focus indicators — never remove
:focus-visibleoutlines - Escape closes modals, popovers, and dropdowns
ARIA widget patterns
Dialog (modal):
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
<button>Cancel</button>
<button>Delete</button>
</div>
Keyboard: Tab cycles within dialog. Escape closes. Focus trapped.
Combobox (autocomplete):
<input
role="combobox"
aria-expanded={isOpen}
aria-controls="listbox-id"
aria-activedescendant={activeOptionId}
/>
<ul id="listbox-id" role="listbox">
<li role="option" id="opt-1" aria-selected={selected === "opt-1"}>Option 1</li>
</ul>
Keyboard: Arrow keys navigate. Enter selects. Escape closes.
Tabs:
<div role="tablist" aria-label="Settings">
<button role="tab" aria-selected={active === 0} aria-controls="panel-0">General</button>
<button role="tab" aria-selected={active === 1} aria-controls="panel-1">Security</button>
</div>
<div role="tabpanel" id="panel-0">General content</div>
Keyboard: Arrow keys move between tabs. Home/End jump to first/last.
Menu:
<button aria-haspopup="true" aria-expanded={isOpen}>Actions</button>
<ul role="menu">
<li role="menuitem">Edit</li>
<li role="menuitem">Delete</li>
</ul>
Keyboard: Arrow keys navigate. Enter activates. Escape closes.
Live regions
// Polite: announced after current speech finishes
<div aria-live="polite" aria-atomic="true">
{status === "saving" ? "Saving..." : "All changes saved"}
</div>
// Assertive: interrupts current speech
<div aria-live="assertive" role="alert">
{error && `Error: ${error.message}`}
</div>
Color contrast
| Content type | Minimum ratio (WCAG AA) |
|---|---|
| Body text | 4.5:1 |
| Large text (18px+ or 14px+ bold) | 3:1 |
| UI components & graphical objects | 3:1 |
Never rely on color alone to convey meaning — pair with icons, text, or patterns.
Testing accessibility
- Automated (axe-core): Run in Vitest and Playwright for WCAG violations
- Keyboard-only: Tab through every flow without a mouse
- Screen reader: Test with VoiceOver (macOS) or NVDA (Windows)
- Zoom: Verify layout at 200% and 400% zoom
- Color: Check with simulated color blindness
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
it("has no accessibility violations", async () => {
const { container } = render(<MyComponent />);
expect(await axe(container)).toHaveNoViolations();
});
7. Motion & Animation
Purpose-driven motion
| Purpose | Example | Duration |
|---|---|---|
| Orient | Page transitions, element repositioning | 200-300ms |
| Inform | Loading spinners, progress bars | 100-500ms |
| Delight | Micro-interactions, hover effects | 100-200ms |
If an animation serves none of these purposes, remove it.
Duration scale
:root {
--duration-instant: 50ms; /* Opacity toggles */
--duration-fast: 100ms; /* Hover effects */
--duration-normal: 200ms; /* Dropdowns, tooltips */
--duration-slow: 300ms; /* Modal enter, page transitions */
--duration-slower: 500ms; /* Complex layout animations */
}
Easing
- Entering elements: ease-out (start fast, decelerate)
- Exiting elements: ease-in (start slow, accelerate)
- Never use
linearfor UI motion
prefers-reduced-motion
Respect the user's system preference:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
In Tailwind: motion-safe:animate-fade-in motion-reduce:opacity-100
8. Dark Mode Strategy
Token inversion
Dark mode remaps semantic tokens, not individual components:
:root { --color-bg-primary: var(--color-gray-50); }
[data-theme="dark"] { --color-bg-primary: var(--color-gray-900); }
Rules
- Test every component in both light and dark
- Never hardcode
white,black,#fff, or#000— use semantic tokens - Use
filter: brightness(0.9)on images in dark mode to reduce glare - Replace shadows with subtle borders on dark backgrounds
- Prevent white flash with a blocking
<script>in<head>that reads the stored theme
9. Theming & Runtime Switching
CSS custom property architecture
All theming flows through CSS custom properties:
[data-theme="light"] {
--color-bg-primary: #ffffff;
--color-text-primary: #111827;
}
[data-theme="dark"] {
--color-bg-primary: #0f172a;
--color-text-primary: #f1f5f9;
}
ThemeProvider
type Theme = "light" | "dark" | "system";
function ThemeProvider({ children, defaultTheme = "system" }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(defaultTheme);
const resolvedTheme = useMemo(() => {
if (theme !== "system") return theme;
if (typeof window === "undefined") return "light"; // SSR fallback
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}, [theme]);
useEffect(() => {
document.documentElement.setAttribute("data-theme", resolvedTheme);
localStorage.setItem("theme", theme);
}, [theme, resolvedTheme]);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
Theme inheritance
Nested data-theme attributes override the parent — CSS custom properties inherit down the DOM tree. Use this for forced-dark regions (e.g., a dark sidebar in a light app).
10. Icon & Illustration Systems
Consistent library
Choose one icon library and use it everywhere:
| Library | Style | Notes |
|---|---|---|
| Lucide | Outlined, consistent stroke | Tree-shakeable React components |
| Heroicons | Outline + solid variants | From the Tailwind team |
| Phosphor | 6 weight variants | Most flexible |
Accessibility
Decorative icons (next to a text label) — hide from screen readers:
<button><TrashIcon aria-hidden="true" /> Delete</button>
Meaningful icons (standalone) — provide an accessible name:
<button aria-label="Delete item"><TrashIcon aria-hidden="true" /></button>
SVG approach
- Use inline SVGs (React components) for icons that need
currentColorstyling - Set
fill="currentColor"orstroke="currentColor"to inherit text color - Never use icon fonts — they fail when fonts fail to load
11. Data Visualization UX
Chart type by data relationship
| Relationship | Chart type | Rule |
|---|---|---|
| Part-to-whole | Donut (max 5 segments) | Never pie with 6+ segments |
| Change over time | Line / area | Continuous x-axis required |
| Comparison | Horizontal bar | Best for ranked categories |
| Distribution | Histogram | Value ranges, outliers |
Color accessibility
- 3:1 contrast between adjacent data series
- Never rely on color alone — add patterns, labels, or markers
- Limit to 6-8 colors per chart
Responsive charts
- Use
ResizeObserverfor container width, not window width - Below 480px: simplify (hide legend, rotate labels)
- Below 320px: replace chart with summary number or sparkline
- Always set minimum height (200px)
Empty and loading states
Every chart needs three states: loading (skeleton), empty (illustration + CTA), error (message + retry). Never a blank space.
12. Anti-Patterns
| Anti-pattern | Problem | Fix |
|---|---|---|
Magic numbers (padding: 13px) |
Breaks consistency | Use spacing tokens |
| Color by hex everywhere | Impossible to theme | Use semantic tokens |
div with click handler |
No keyboard/a11y support | Use <button> or <a> |
| Hiding focus outlines | Keyboard users lost | Style :focus-visible |
| Toast-only form errors | Users miss them | Inline errors with aria-describedby |
| Disabled submit without explanation | User doesn't know what to fix | Show validation errors |
| Icon-only buttons without labels | Invisible to screen readers | aria-label or hidden text |
z-index: 9999 |
Arms race, layering bugs | Managed stacking context |
| Animation without reduced-motion | Motion sickness | motion-safe: or media query |
| Hardcoded light/dark colors | Breaks theming | CSS custom property tokens |
| Client-side only validation | Security risk | Always validate on server |
| Pixel font sizes | Breaks user zoom | Use rem exclusively |
| SPA-era: React Hook Form for all forms | Unnecessary client state, fights server actions | Server validation in action + useActionData |
| SPA-era: Client-side multi-step wizard | Progress lost on refresh, no deep links | Server-managed steps via loader/action |
SPA-era: onBlur + fetch for auto-save |
Manual fetch, no framework integration | useFetcher.Form with method="post" |
Cross-references
/css-responsive— layout, breakpoints, touch targets, Tailwind CSS v4/saas-product— dashboards, empty states, notifications, onboarding/react— hooks, state management, component rendering patterns