Skip to content

Build a component the way a library author would. Decide primitive versus hand-rolled, cover the full state matrix, design the API by composition, style through data attributes, keep it controlled or uncontrolled deliberately, and pick the right library for the job from a short trusted list instead of installing whatever is first on npm. Use when creating or refactoring a button, input, menu, dialog, drawer, toast, tooltip, table, command palette, tabs, or any reusable piece of UI.

13
sections
1160
words
Behaviour
Open in
SKILL.mdGitHub

components

A component is a contract: it looks the same everywhere, behaves the same everywhere, and does not need to be looked at to be trusted. Most generated components fail the third part.

Primitive or hand-rolled?

Hand-roll only what has no behaviour: a card, a badge, a divider, a stat. Anything with focus management, keyboard interaction, positioning or ARIA uses a headless primitive.

Need Primitive Why not hand-roll
Dialog, sheet, popover, menu, select, tooltip, tabs, toggle, slider, combobox Base UI, Radix, React Aria, Ark focus trap, escape, outside click, typeahead, ARIA, portal, collision
Drawer with drag Vaul gesture physics, snap points, scroll locking
Toast Sonner stacking, swipe, timers, a11y announcements
Command palette cmdk filtering, keyboard, groups, a11y
Animated numbers NumberFlow digit columns, locale formatting
Data table TanStack Table (headless) sorting, virtualisation, column state
Forms native + a small validation lib constraint API, focus on first error

The rule: if it has a spec in ARIA Authoring Practices, use a primitive that implements it.

The state matrix

Every interactive component covers all of these before it is done. Write them down; check them off.

State Visual Behaviour
Default
Hover (hover devices only) one-step change
Focus-visible ring
Active / pressed scale .97 or darken
Selected / checked / on fill or accent aria-* reflects it
Disabled 50% opacity, no hover not focusable, aria-disabled if it must stay in the tab order
Busy / loading spinner in place, same width ignores input, aria-busy
Error / invalid border + message aria-invalid, aria-describedby to message
Empty (for containers) one line + one action
Read-only text styling, no affordance

Sizes: two or three (sm, md, lg). Variants: primary, secondary (border), ghost, danger. Not seven. Not outline-gradient-xl.

Style from state, not from props

Style reads the element's actual state so the component and its CSS cannot disagree:

<button data-state={open ? "open" : "closed"} data-size={size} data-variant={variant} aria-busy={busy || undefined} />
.button[data-variant="primary"] { ... }
.button[data-state="open"] { ... }
.button[aria-busy] { ... }
.button:where(:disabled, [aria-disabled="true"]) { ... }

Primitives already emit data-state, data-highlighted, data-disabled; style those rather than mirroring them into React state.

API by composition

Small parts that compose beat one component with forty props:

<Dialog.Root>
  <Dialog.Trigger asChild><Button>Rename</Button></Dialog.Trigger>
  <Dialog.Content>
    <Dialog.Title>Rename file</Dialog.Title>
    <Dialog.Description>…</Dialog.Description>
    <Dialog.Close asChild><Button variant="secondary">Cancel</Button></Dialog.Close>
  </Dialog.Content>
</Dialog.Root>

Guidelines:

  • asChild / render to put behaviour on the user's element instead of wrapping.
  • Spread rest props onto the root element; forward className and style; forward refs.
  • Boolean props for states, string unions for variants, no type="..." collisions with native attributes.
  • Controlled and uncontrolled both work: value + onValueChange, or defaultValue. Never half of one.
  • Event handlers are called onX and receive the native event or a plain value, never a synthetic object of your own invention.
  • No isLoading/loading duplicates; one name, used everywhere.

Tokens, not values

A component references tokens for colour, radius, spacing, easing and duration. If a component has a hex, a 0.3s or a cubic-bezier inline, it has forked the system.

Radius is consistent per level: inputs and buttons 6–8px, cards and popovers 8–12px, dialogs 12–16px, pills full. Nested radii shrink: an element inside a 12px card uses 8px, not 12px.

Positioning layers

Popovers, menus and tooltips: portal to body, position from the trigger with collision handling, transform-origin set from the resolved placement, and a scale/opacity entrance of ~160ms. Dialogs: centred, backdrop, scroll lock that does not shift layout (compensate for the scrollbar), focus moved in and returned on close.

Z-index is a scale, not a number: --z-sticky: 10; --z-dropdown: 20; --z-dialog: 30; --z-toast: 40. Anything above 50 is a bug.

Inputs

  • A visible label. Placeholders are hints, not labels.
  • Error message below, linked with aria-describedby, appears after blur or submit, not on every keystroke.
  • Height matches the button height in the same size (32px sm, 36px md, 40px lg).
  • 16px font size on touch, or the browser zooms.
  • inputmode and autocomplete set. enterkeyhint on phones.
  • Icons inside inputs are decorative unless they are buttons; buttons have a 32px hit area even if they draw at 16px.

Lists and rows

  • The whole row is the target (an ::after pseudo-element covering the row on the link), not just the name.
  • One hover highlight that follows the pointer, not per-row backgrounds.
  • Keyboard: j/k or arrows move, Enter activates, and the focused row is visibly outlined.
  • Truncate the description, never the name.
  • Virtualise past ~200 rows.

Icons

One set. One stroke width. One size in a context (14px in dense UI, 16px in comfortable, 20px in touch). currentColor so they follow text. Decorative icons are aria-hidden; icon-only buttons have aria-label.

Tables

  • Header row sticky, with a bottom border.
  • Numbers right-aligned and tabular; text left; never centred.
  • Row height 32–40px; zebra striping only in very dense tables, and at the --hover level.
  • Sort indicator in the header, with the active column visible without hovering.
  • Empty state inside the table region, not replacing it.

Testing a component

Before calling it done, open it and:

  1. Tab through it with the mouse unplugged.
  2. Press it, hold it, drag off it, release.
  3. Resize to 375px.
  4. Toggle dark and light.
  5. Turn on reduced motion.
  6. Fill it with the longest realistic content and the shortest.
  7. Screen reader: does it have a name, a role and a state?

Marks of a generated component

Sign Fix
Custom dropdown with useState and an outside-click hook Menu primitive
variant with eight values four
className not forwarded forward it, merge it
Inline transition: all 0.3s tokens
Spinner replaces the label and the button shrinks spinner overlays, width fixed
Icon-only button with no label aria-label
div onClick button
Modal with no focus trap Dialog primitive

From the catalogue