UI Components — Best Practices & Patterns
UI components are the visible contract between your application and its users. A well-built component is accessible, composable, themeable, and predictable across every page it appears on. Poor components create inconsistency, keyboard traps, screen-reader failures, and maintenance debt that spreads through every feature that depends on them.
This guide distills production-tested patterns for building components in plain HTML/CSS, Tailwind CSS, Bootstrap, and modern frameworks. The recommendations cover design tokens, component architecture, accessibility, state management, documentation, testing, and performance — from a single button to a full design system.
info
Most component bugs come from a small set of recurring mistakes. The table below maps common anti-patterns to recommended alternatives. Internalize the left column as a warning, and the right column as the default choice.
| Avoid | Prefer | Why |
|---|---|---|
| Hard-coded colors | Design tokens (CSS variables) | Enables theming, dark mode, and consistency |
| Divs for buttons | Real <button> or <a> elements | Native keyboard and screen-reader support |
| Icon-only controls | Visible text label or aria-label | Icon meaning is not universal |
| Magic numbers | Token-based spacing and sizing scales | Predictable rhythm and easier refactoring |
| Tight parent coupling | Composition via children / slots / props | Reusable components survive layout changes |
| Focus theft on mount | Focus only when user action requires it | Unexpected focus movement disorients users |
| Custom select menus | Native <select> unless custom UX is required | Native selects handle mobile, a11y, and forms |
| Blocking the main thread | requestAnimationFrame + passive listeners | Keeps animations and interactions responsive |
| Inline SVG without titles | <title> and role="img" on decorative icons | Screen readers need a text alternative |
| Hiding with display:none | hidden attribute or conditional rendering | Declarative hiding is clearer and easier to animate |
warning
Design tokens are the single source of truth for colors, typography, spacing, shadows, radii, and motion. They let components share a visual language without hard-coding values, and they make dark mode, brand theming, and white-label products possible.
| 1 | :root { |
| 2 | /* Color tokens */ |
| 3 | --color-bg: #0a0a0b; |
| 4 | --color-surface: #111113; |
| 5 | --color-surface-raised: #18181b; |
| 6 | --color-border: rgba(255, 255, 255, 0.08); |
| 7 | --color-text: #f3efe6; |
| 8 | --color-text-muted: #8b877e; |
| 9 | --color-primary: #00ff41; |
| 10 | --color-primary-hover: #33ff66; |
| 11 | --color-danger: #ef4444; |
| 12 | --color-warning: #e7c14f; |
| 13 | --color-info: #3b9eff; |
| 14 | |
| 15 | /* Spacing scale */ |
| 16 | --space-1: 0.25rem; |
| 17 | --space-2: 0.5rem; |
| 18 | --space-3: 0.75rem; |
| 19 | --space-4: 1rem; |
| 20 | --space-6: 1.5rem; |
| 21 | --space-8: 2rem; |
| 22 | |
| 23 | /* Radii */ |
| 24 | --radius-sm: 0.25rem; |
| 25 | --radius-md: 0.5rem; |
| 26 | --radius-lg: 0.75rem; |
| 27 | --radius-xl: 1rem; |
| 28 | |
| 29 | /* Motion */ |
| 30 | --transition-fast: 150ms ease; |
| 31 | --transition-base: 200ms ease; |
| 32 | --transition-slow: 300ms ease; |
| 33 | |
| 34 | /* Typography */ |
| 35 | --font-sans: 'Inter', system-ui, sans-serif; |
| 36 | --font-mono: 'IBM Plex Mono', monospace; |
| 37 | --text-xs: 0.75rem; |
| 38 | --text-sm: 0.875rem; |
| 39 | --text-base: 1rem; |
| 40 | } |
Tokens should be referenced by semantic role, not by raw value. A component should ask for --color-primary, not #00ff41. This indirection lets you change the brand color globally or override it for a theme without touching component code.
| 1 | .btn { |
| 2 | /* Good — semantic token references */ |
| 3 | background: var(--color-primary); |
| 4 | color: var(--color-bg); |
| 5 | padding: var(--space-2) var(--space-4); |
| 6 | border-radius: var(--radius-md); |
| 7 | font-family: var(--font-sans); |
| 8 | font-size: var(--text-sm); |
| 9 | transition: background var(--transition-fast); |
| 10 | } |
| 11 | |
| 12 | .btn:hover { |
| 13 | background: var(--color-primary-hover); |
| 14 | } |
| 15 | |
| 16 | /* Bad — hard-coded values scattered through components */ |
| 17 | .btn { |
| 18 | background: #00ff41; |
| 19 | color: #0a0a0b; |
| 20 | padding: 0.5rem 1rem; |
| 21 | border-radius: 0.5rem; |
| 22 | font-family: 'Inter', sans-serif; |
| 23 | font-size: 0.875rem; |
| 24 | } |
best practice
A component's architecture determines how reusable, testable, and maintainable it is. The best components are small, stateless where possible, and explicit about what they accept and emit. Composition beats configuration: prefer slots or children over long prop lists.
| 1 | // Good — composable, explicit, minimal props |
| 2 | function Card({ children, className }: { children: React.ReactNode; className?: string }) { |
| 3 | return ( |
| 4 | <div className={cn('rounded-lg border border-[#222222] bg-[#111113] p-4', className)}> |
| 5 | {children} |
| 6 | </div> |
| 7 | ); |
| 8 | } |
| 9 | |
| 10 | function CardHeader({ title, action }: { title: string; action?: React.ReactNode }) { |
| 11 | return ( |
| 12 | <div className="flex items-center justify-between mb-3"> |
| 13 | <h3 className="text-sm font-medium text-[#E0E0E0]">{title}</h3> |
| 14 | {action} |
| 15 | </div> |
| 16 | ); |
| 17 | } |
| 18 | |
| 19 | // Usage |
| 20 | <Card> |
| 21 | <CardHeader title="Project settings" action={<Button variant="ghost">Edit</Button>} /> |
| 22 | <p className="text-xs text-[#808080]">Manage your project configuration.</p> |
| 23 | </Card> |
Avoid prop explosion by separating layout concerns from content concerns. If a component has more than seven props, consider splitting it or using composition. If a prop only changes layout, it may belong in a parent wrapper rather than the component itself.
| 1 | // Bad — too many props, tight coupling |
| 2 | function UserCard({ |
| 3 | name, |
| 4 | avatar, |
| 5 | role, |
| 6 | bio, |
| 7 | showBio, |
| 8 | showRole, |
| 9 | buttonText, |
| 10 | onButtonClick, |
| 11 | bordered, |
| 12 | shadow, |
| 13 | size, |
| 14 | }: UserCardProps) { |
| 15 | /* ... */ |
| 16 | } |
| 17 | |
| 18 | // Good — composition lets the consumer decide layout and actions |
| 19 | function UserCard({ children, className }: CardProps) { |
| 20 | return <Card className={className}>{children}</Card>; |
| 21 | } |
| 22 | |
| 23 | <UserCard> |
| 24 | <Avatar src="/avatar.jpg" name="Ada Lovelace" /> |
| 25 | <h3>Ada Lovelace</h3> |
| 26 | <p>Software Engineer</p> |
| 27 | <Button onClick={handleMessage}>Message</Button> |
| 28 | </UserCard> |
info
Accessible components are not a special case — they are the baseline. Most accessibility requirements are satisfied by using the right native element and adding a few ARIA attributes only when semantics are not enough. Test with keyboard-only navigation and a screen reader, not just visual inspection.
| 1 | <!-- Accessible button with loading state --> |
| 2 | <button type="button" disabled aria-label="Saving changes" aria-busy="true"> |
| 3 | <span class="spinner" aria-hidden="true"></span> |
| 4 | Saving… |
| 5 | </button> |
| 6 | |
| 7 | <!-- Accessible icon-only button --> |
| 8 | <button type="button" aria-label="Close dialog"> |
| 9 | <svg aria-hidden="true" focusable="false" width="16" height="16">…</svg> |
| 10 | </button> |
| 11 | |
| 12 | <!-- Accessible modal trigger + dialog --> |
| 13 | <button type="button" aria-haspopup="dialog" aria-controls="confirm-dialog"> |
| 14 | Delete account |
| 15 | </button> |
| 16 | <dialog id="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-desc"> |
| 17 | <h2 id="confirm-title">Delete account?</h2> |
| 18 | <p id="confirm-desc">This action cannot be undone.</p> |
| 19 | <button type="button" autofocus>Cancel</button> |
| 20 | <button type="button">Delete</button> |
| 21 | </dialog> |
Keyboard interaction is non-negotiable. Every interactive component must be reachable with Tab, operable with Enter or Space, and escapable with Escape where appropriate. Focus must be visible, and focus order must match the visual order.
| 1 | // Focus trap for modal content |
| 2 | function useFocusTrap(active: boolean, containerRef: RefObject<HTMLElement>) { |
| 3 | useEffect(() => { |
| 4 | if (!active || !containerRef.current) return; |
| 5 | const container = containerRef.current; |
| 6 | const focusable = container.querySelectorAll<HTMLElement>( |
| 7 | 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' |
| 8 | ); |
| 9 | const first = focusable[0]; |
| 10 | const last = focusable[focusable.length - 1]; |
| 11 | |
| 12 | function handleKeyDown(e: KeyboardEvent) { |
| 13 | if (e.key !== 'Tab') return; |
| 14 | if (focusable.length === 1) { |
| 15 | e.preventDefault(); |
| 16 | return; |
| 17 | } |
| 18 | if (e.shiftKey && document.activeElement === first) { |
| 19 | e.preventDefault(); |
| 20 | last.focus(); |
| 21 | } else if (!e.shiftKey && document.activeElement === last) { |
| 22 | e.preventDefault(); |
| 23 | first.focus(); |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | container.addEventListener('keydown', handleKeyDown); |
| 28 | first?.focus(); |
| 29 | return () => container.removeEventListener('keydown', handleKeyDown); |
| 30 | }, [active, containerRef]); |
| 31 | } |
danger
Components communicate through props, events, and state. Keep state as close to where it is used as possible. Lift state up only when multiple components need to share it. Derived state should be computed, not stored. Async states — loading, error, empty, success — deserve first-class UI treatment.
| 1 | // Async state machine for a button |
| 2 | function SaveButton({ onSave }: { onSave: () => Promise<void> }) { |
| 3 | const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); |
| 4 | |
| 5 | async function handleClick() { |
| 6 | setStatus('loading'); |
| 7 | try { |
| 8 | await onSave(); |
| 9 | setStatus('success'); |
| 10 | } catch { |
| 11 | setStatus('error'); |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | return ( |
| 16 | <button |
| 17 | type="button" |
| 18 | onClick={handleClick} |
| 19 | disabled={status === 'loading'} |
| 20 | aria-busy={status === 'loading'} |
| 21 | aria-live="polite" |
| 22 | > |
| 23 | {status === 'loading' && <Spinner aria-hidden="true" />} |
| 24 | {status === 'success' && <CheckIcon aria-hidden="true" />} |
| 25 | {status === 'error' && 'Retry'} |
| 26 | {status === 'idle' && 'Save changes'} |
| 27 | </button> |
| 28 | ); |
| 29 | } |
Avoid inconsistent state by making components controlled when their value matters outside the component. Uncontrolled components are fine for isolated forms, but controlled components are easier to validate, reset, and persist.
| 1 | // Controlled input — source of truth is the parent |
| 2 | function TextField({ |
| 3 | value, |
| 4 | onChange, |
| 5 | error, |
| 6 | }: { |
| 7 | value: string; |
| 8 | onChange: (value: string) => void; |
| 9 | error?: string; |
| 10 | }) { |
| 11 | return ( |
| 12 | <div> |
| 13 | <input |
| 14 | type="text" |
| 15 | value={value} |
| 16 | onChange={(e) => onChange(e.target.value)} |
| 17 | aria-invalid={!!error} |
| 18 | aria-describedby={error ? 'text-error' : undefined} |
| 19 | /> |
| 20 | {error && <span id="text-error" role="alert" className="text-red-500">{error}</span>} |
| 21 | </div> |
| 22 | ); |
| 23 | } |
best practice
Form components have the highest accessibility stakes because they must work with labels, validation, autocomplete, and submission. Always use native form elements as the foundation, and only wrap them when the wrapper preserves their native behavior.
| 1 | <!-- Accessible field wrapper --> |
| 2 | <div class="field"> |
| 3 | <label for="email" class="label"> |
| 4 | Email <span aria-label="required">*</span> |
| 5 | </label> |
| 6 | <input |
| 7 | id="email" |
| 8 | name="email" |
| 9 | type="email" |
| 10 | autocomplete="email" |
| 11 | required |
| 12 | aria-required="true" |
| 13 | aria-invalid="true" |
| 14 | aria-describedby="email-error" |
| 15 | > |
| 16 | <span id="email-error" class="error" role="alert"> |
| 17 | Please enter a valid email address. |
| 18 | </span> |
| 19 | </div> |
| 20 | |
| 21 | <!-- Checkbox group with fieldset --> |
| 22 | <fieldset> |
| 23 | <legend>Notification preferences</legend> |
| 24 | <label><input type="checkbox" name="notify" value="marketing"> Marketing</label> |
| 25 | <label><input type="checkbox" name="notify" value="product"> Product updates</label> |
| 26 | </fieldset> |
Error messages should be associated with their controls using aria-describedby or aria-errormessage. Required fields should use the native required attribute for built-in validation and announce their required state.
| 1 | :user-invalid:not(:focus) { |
| 2 | border-color: var(--color-danger); |
| 3 | } |
| 4 | |
| 5 | .field:has(:user-invalid:not(:focus)) .error { |
| 6 | display: block; |
| 7 | } |
| 8 | |
| 9 | /* Disable native invalid styling until the user has interacted */ |
| 10 | input:invalid:not(:user-invalid) { |
| 11 | border-color: var(--color-border); |
| 12 | } |
warning
Overlays break out of the normal document flow and require careful focus, dismissal, and stacking management. A modal must trap focus, return focus on close, and block interaction with the rest of the page. Toasts should not steal focus but should be announced. Tooltips must appear on hover and focus, and hide on Esc.
| 1 | // Modal scaffold with dialog element |
| 2 | function Modal({ open, onClose, title, children }: ModalProps) { |
| 3 | const ref = useRef<HTMLDialogElement>(null); |
| 4 | |
| 5 | useEffect(() => { |
| 6 | const dialog = ref.current; |
| 7 | if (!dialog) return; |
| 8 | if (open && !dialog.open) dialog.showModal(); |
| 9 | if (!open && dialog.open) dialog.close(); |
| 10 | }, [open]); |
| 11 | |
| 12 | return ( |
| 13 | <dialog ref={ref} onClose={onClose} aria-labelledby="modal-title"> |
| 14 | <header> |
| 15 | <h2 id="modal-title">{title}</h2> |
| 16 | <button type="button" onClick={onClose} aria-label="Close">×</button> |
| 17 | </header> |
| 18 | {children} |
| 19 | </dialog> |
| 20 | ); |
| 21 | } |
Toasts and notifications live in a live region. Use aria-live="polite" so they are announced without interrupting the user. Do not put focus in a toast. Tooltips should be appended to a portal so they are not clipped by overflow containers.
| 1 | <!-- Live region for toasts --> |
| 2 | <div id="toast-region" aria-live="polite" aria-atomic="true"></div> |
| 3 | |
| 4 | <!-- Tooltip with keyboard access --> |
| 5 | <button type="button" aria-describedby="tip-copy"> |
| 6 | Copy |
| 7 | </button> |
| 8 | <div id="tip-copy" role="tooltip" class="tooltip"> |
| 9 | Copy to clipboard |
| 10 | </div> |
best practice
Data components must prioritize readability and semantics over decoration. Tables should use proper header scopes, lists should use the right list type, and card grids should be navigable by keyboard. Empty states are part of the component, not an afterthought.
| 1 | <!-- Accessible data table --> |
| 2 | <table class="docs-table"> |
| 3 | <caption>Monthly revenue by region</caption> |
| 4 | <thead> |
| 5 | <tr> |
| 6 | <th scope="col">Region</th> |
| 7 | <th scope="col">Revenue</th> |
| 8 | <th scope="col">Growth</th> |
| 9 | </tr> |
| 10 | </thead> |
| 11 | <tbody> |
| 12 | <tr> |
| 13 | <th scope="row">North America</th> |
| 14 | <td>$120,000</td> |
| 15 | <td>+12%</td> |
| 16 | </tr> |
| 17 | </tbody> |
| 18 | </table> |
| 19 | |
| 20 | <!-- Empty state --> |
| 21 | <div class="empty-state" role="status"> |
| 22 | <p>No projects yet.</p> |
| 23 | <button type="button">Create your first project</button> |
| 24 | </div> |
For long tables, add sticky headers and consider row striping sparingly. For card grids, ensure the reading order in the DOM matches the visual order. Responsive tables can scroll horizontally or switch to a card layout, but never drop semantic headers.
| 1 | .table-wrapper { |
| 2 | overflow-x: auto; |
| 3 | max-width: 100%; |
| 4 | } |
| 5 | |
| 6 | .table-wrapper table { |
| 7 | min-width: 640px; |
| 8 | } |
| 9 | |
| 10 | .card-grid { |
| 11 | display: grid; |
| 12 | grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); |
| 13 | gap: var(--space-4); |
| 14 | } |
info
Component performance is mostly about reducing re-renders, avoiding layout thrashing, and lazy-loading heavy pieces. Profile before optimizing, but follow these defaults: render lists with keys, debounce fast events, use passive listeners for scroll and touch, and defer below-the-fold components.
| 1 | // Memoize expensive child components |
| 2 | const ExpensiveChart = memo(function ExpensiveChart({ data }: { data: DataPoint[] }) { |
| 3 | return <svg>{/* ... */}</svg>; |
| 4 | }); |
| 5 | |
| 6 | // Debounce search input |
| 7 | function SearchField({ onSearch }: { onSearch: (q: string) => void }) { |
| 8 | const [value, setValue] = useState(''); |
| 9 | const debounced = useDebouncedCallback(onSearch, 300); |
| 10 | |
| 11 | return ( |
| 12 | <input |
| 13 | type="search" |
| 14 | value={value} |
| 15 | onChange={(e) => { |
| 16 | setValue(e.target.value); |
| 17 | debounced(e.target.value); |
| 18 | }} |
| 19 | /> |
| 20 | ); |
| 21 | } |
| 22 | |
| 23 | // Lazy load below-the-fold components |
| 24 | const HeavyMap = lazy(() => import('./HeavyMap')); |
| 25 | |
| 26 | function Dashboard() { |
| 27 | return ( |
| 28 | <Suspense fallback={<Skeleton height={400} />}> |
| 29 | <HeavyMap /> |
| 30 | </Suspense> |
| 31 | ); |
| 32 | } |
Animations should use transform and opacity only. Properties like width, height, top, and margin trigger layout recalculations and are expensive to animate. Use will-change sparingly and remove it after animations complete.
| 1 | .modal-enter { |
| 2 | opacity: 0; |
| 3 | transform: scale(0.96); |
| 4 | transition: opacity 200ms ease, transform 200ms ease; |
| 5 | } |
| 6 | |
| 7 | .modal-enter-active { |
| 8 | opacity: 1; |
| 9 | transform: scale(1); |
| 10 | } |
| 11 | |
| 12 | /* Avoid animating layout properties */ |
| 13 | .accordion-bad { |
| 14 | transition: height 300ms ease; /* triggers layout */ |
| 15 | } |
| 16 | |
| 17 | .accordion-good { |
| 18 | transition: transform 300ms ease, opacity 300ms ease; |
| 19 | } |
best practice
Components should adapt to their container, not just the viewport. Container queries, fluid typography, and intrinsic layouts let components reflow inside sidebars, modals, and dashboards. Touch targets must be at least 44×44 CSS pixels, and hover-dependent interactions need tap fallbacks.
| 1 | .card { |
| 2 | container-type: inline-size; |
| 3 | } |
| 4 | |
| 5 | @container (min-width: 400px) { |
| 6 | .card-inner { |
| 7 | display: flex; |
| 8 | gap: var(--space-4); |
| 9 | } |
| 10 | } |
| 11 | |
| 12 | /* Touch targets */ |
| 13 | button, |
| 14 | [role="button"], |
| 15 | a { |
| 16 | min-height: 44px; |
| 17 | min-width: 44px; |
| 18 | } |
| 19 | |
| 20 | /* Fluid type */ |
| 21 | .title { |
| 22 | font-size: clamp(1.25rem, 4cqi, 2rem); |
| 23 | } |
Avoid hover-only interactions on mobile. Dropdown menus should open on click; tooltips should appear on focus as well as hover; and drag handles should also support tap or keyboard reordering.
warning
A component is not finished until another engineer can use it correctly without reading the source. Document every prop, slot, and CSS custom property. Include usage examples for common, edge, and invalid states. Storybook, Ladle, or a simple docs page all work — the important part is consistency.
| 1 | /** |
| 2 | * Button — primary call-to-action trigger. |
| 3 | * |
| 4 | * @param variant - visual intent: primary | secondary | ghost | danger |
| 5 | * @param size - fits surrounding density: sm | md | lg |
| 6 | * @param loading - shows spinner and disables the button |
| 7 | * @param children - visible label; prefer text over icon-only |
| 8 | * |
| 9 | * @example |
| 10 | * <Button variant="primary" onClick={save}>Save changes</Button> |
| 11 | */ |
| 12 | export function Button({ |
| 13 | variant = 'primary', |
| 14 | size = 'md', |
| 15 | loading = false, |
| 16 | children, |
| 17 | ...rest |
| 18 | }: ButtonProps) { |
| 19 | return ( |
| 20 | <button |
| 21 | className={cn('btn', `btn--${variant}`, `btn--${size}`)} |
| 22 | disabled={loading} |
| 23 | aria-busy={loading} |
| 24 | {...rest} |
| 25 | > |
| 26 | {loading && <Spinner aria-hidden="true" />} |
| 27 | {children} |
| 28 | </button> |
| 29 | ); |
| 30 | } |
Show every state in documentation: default, hover, focus, active, disabled, loading, error, empty, and overflow. States that are not visible in docs will be forgotten in implementation.
| 1 | # Storybook stories for a single component |
| 2 | stories/ |
| 3 | Button/ |
| 4 | Default.stories.tsx |
| 5 | Variants.stories.tsx |
| 6 | Sizes.stories.tsx |
| 7 | Loading.stories.tsx |
| 8 | WithIcon.stories.tsx |
| 9 | Disabled.stories.tsx |
| 10 | Accessible.stories.tsx |
info
Component tests should verify behavior, not markup. Test what the user can do: click the button, type in the field, open the modal, select an option. Accessibility-first testing frameworks encourage this by default because they query the accessible role and name rather than CSS selectors.
| 1 | import { render, screen, fireEvent } from '@testing-library/react'; |
| 2 | import userEvent from '@testing-library/user-event'; |
| 3 | |
| 4 | test('modal traps focus and closes on Escape', async () => { |
| 5 | render(<DeleteDialog onConfirm={jest.fn()} onCancel={jest.fn()} />); |
| 6 | |
| 7 | await userEvent.click(screen.getByRole('button', { name: /delete/i })); |
| 8 | expect(screen.getByRole('dialog')).toBeInTheDocument(); |
| 9 | |
| 10 | await userEvent.keyboard('{Escape}'); |
| 11 | expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); |
| 12 | }); |
| 13 | |
| 14 | test('button shows loading state and disables', async () => { |
| 15 | render(<Button loading>Save</Button>); |
| 16 | expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled(); |
| 17 | }); |
Visual regression tests catch unintended style changes, but they are noisy. Use them for stable components after design freeze. Interaction tests and accessibility tests catch more functional bugs and should run in CI on every pull request.
| Test type | Best for | When to run |
|---|---|---|
| Unit / interaction | Props, events, state transitions | On save and in CI |
| Accessibility | axe-core, keyboard navigation | Per component and in CI |
| Visual regression | Unintended style changes | Before design releases |
| E2E | Critical user journeys | Release candidates |
The same component principles apply everywhere, but each framework has idiomatic patterns. React favors composition with children and hooks. Vue uses slots and emits. Svelte leans on bindings and stores. Web components work across frameworks but require more manual accessibility wiring.
| 1 | <!-- Vue: slots + emits for composition --> |
| 2 | <template> |
| 3 | <div class="card"> |
| 4 | <div v-if="$slots.header" class="card__header"> |
| 5 | <slot name="header" /> |
| 6 | </div> |
| 7 | <div class="card__body"> |
| 8 | <slot /> |
| 9 | </div> |
| 10 | </div> |
| 11 | </template> |
| 12 | |
| 13 | <script setup> |
| 14 | defineEmits(['close']); |
| 15 | </script> |
| 1 | <!-- Svelte: forwarded props and slots --> |
| 2 | <script> |
| 3 | export let variant = 'primary'; |
| 4 | </script> |
| 5 | |
| 6 | <button class="btn btn--{variant}" on:click {...$$restProps}> |
| 7 | <slot /> |
| 8 | </button> |
In React, Server Components can render static UI without client JavaScript. Use them for presentational components that do not need interaction. Move interactivity — state, effects, event handlers — into Client Components and keep them as small as possible.
info
Use this checklist for every component pull request. Automate the mechanical checks and reserve human review for semantics, accessibility intent, and API design.
| Check | Automated? | Details |
|---|---|---|
| Native element used | Review | Button, link, input, select, dialog before custom markup |
| Keyboard support | Tests + review | Tab order, Enter/Space, Escape, arrow keys |
| Labels & ARIA | axe-core + review | Names, roles, states, and describedby links |
| Focus visible | Review | Never remove outlines without replacement |
| Tokens only | Stylelint | No hard-coded colors, spacing, or motion values |
| Loading/error/empty states | Review | Async UI is designed, not improvised |
| Prop API surface | Review | Fewer than ~7 props; composition preferred |
| Responsive behavior | Manual + visual | Mobile, touch targets, reflow |
| Tests pass | CI | Interaction, accessibility, and visual tests |
| Docs updated | Review | Props, examples, and states documented |
These references are the authoritative sources behind the recommendations in this guide. Bookmark them for deep dives into specific component topics.
| Resource | Topic |
|---|---|
| WAI-ARIA Authoring Practices | w3.org/WAI/ARIA/apg — patterns for accessible components |
| MDN Web Components | developer.mozilla.org — native component APIs |
| Radix UI / Headless UI | Unstyled accessible component primitives |
| Storybook Docs | storybook.js.org — component documentation and testing |
| Inclusive Components | inclusive-components.design — accessible pattern library |
| Design Tokens Community Group | w3.org/community/design-tokens — token standards |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.