Part 2 · 2 chapters · ~20 min

Primitives and Components

The component stack as behaviour once and appearance on top (headless primitives, styled components, composition over configuration, variants generated from axes, counted escape hatches, borrowing a headless library), and accessibility baked in: names required by the type, keyboard completeness, a focus token, contrast by construction, motion preferences, and the three kinds of test.

5

Primitives, components and composition

code
// a styled Button over a headless primitive: variants generated, names required, focus from a token, escape hatch counted
import { Button as Primitive } from '@system/primitives'          // behaviour: disabled, pressed, focus-visible, keyboard, role
import { cva, type VariantProps } from 'class-variance-authority'

const button = cva('btn', {                                        // the axes, enumerated; the combinations generated
  variants: {
    intent: { primary: 'btn-primary', secondary: 'btn-secondary', danger: 'btn-danger', ghost: 'btn-ghost' },
    size:   { sm: 'btn-sm', md: 'btn-md', lg: 'btn-lg' },
  },
  compoundVariants: [{ intent: 'ghost', size: 'lg', className: 'btn-ghost-lg' }],
  defaultVariants: { intent: 'primary', size: 'md' },
})
type Base = VariantProps<typeof button> & { loading?: boolean; asChild?: boolean }
type Props = Base & ({ children: React.ReactNode; 'aria-label'?: string } | { children?: never; icon: React.ReactNode; 'aria-label': string })   // icon-only requires a name

export function Button({ intent, size, loading, asChild, ...rest }: Props) {
  const label = 'icon' in rest ? rest['aria-label'] : undefined
  return (
    <Primitive asChild={asChild} className={button({ intent, size })} aria-busy={loading || undefined} aria-label={label} disabled={loading || rest.disabled}>
      {'icon' in rest ? rest.icon : rest.children}
    </Primitive>
  )
}
/* tokens only; the lint forbids hex, primitives and spacing literals here */
/* .btn { padding: var(--space-inset-sm) var(--space-inset-md); border-radius: var(--radius-md); font: var(--type-label); transition: background var(--motion-duration-fast) var(--motion-easing-standard) }
   .btn:focus-visible { outline: var(--focus-ring-width) solid var(--color-focus); outline-offset: var(--focus-ring-offset) }
   .btn-primary { background: var(--button-primary-background); color: var(--button-primary-text) }
   @media (prefers-reduced-motion: reduce) { .btn { transition: none } } */
// stories: every intent × size × state (default, hover, active, disabled, loading, focus) in every theme; axe on each; a keyboard script: Tab, Space, Enter
behaviour once, appearance on top
  1. The primitive owns focus management, keyboard interaction, ARIA, pointer and touch behaviour and the state machine (a Dialog's trap, Escape, aria-modal, focus return, scroll lock), with no colours, spacing or copy. The WAI-ARIA Authoring Practices are its spec; the Disciplines course's accessibility is written here once.
  2. The styled component applies tokens, layout, motion (reduced-motion aware) and sizes over the primitive and exposes an API shaped for the product, with the parts still reachable for custom layouts; it never re-implements what the primitive owns.
  3. Composition over configuration: a layout component exposes parts (Card.Root, Card.Media, Card.Header, Card.Body, Card.Footer) so the consumer can arrange any screen; a control (Button, Input, Toggle) exposes props. Forty props cannot do the next layout.
  4. Variants without explosion: enumerate the axes (intent × size × state × icon) and generate (CVA, vanilla-extract recipes); forbid non-combining axes in the type; watch the variant count, because every variant is a state to design and test.
  5. Escape hatches, documented and counted: asChild to render behaviour onto the consumer's element, className for layout only (never for tokens: the lint), a render prop for the rare part, an unstyled export of the primitive. Their use is the signal of a missing component (part 3).
  6. The library choice: build on a headless library (Radix, React Aria, Headless UI, Ark) that has done the behaviour and the ARIA patterns; the system's value is the styled layer, the tokens and the opinions. Writing primitives is a year of accessibility work and a maintenance burden most systems should not take on.
PRIMITIVES, COMPONENTS AND COMPOSITION
behaviour once, appearance on top, and composition over configuration
swipe the figure sideways, or tap expand for full screen
1/6
the primitive
The primitive: Dialog.Root holds open state; Dialog.Trigger is a button that opens it and receives aria-haspopup and aria-expanded; Dialog.Content is the dialog with role="dialog", aria-modal, the focus trap, Escape to close, focus returned to the trigger on close, and scroll locking; Dialog.Title is wired as the accessible name. No colours, no spacing, no copy: the behaviour and the accessibility, written once, tested once.
6

Accessibility baked in

make the accessible use the only use
  1. Names required by the type: Input requires a label or a labelledby id; IconButton requires aria-label; Image requires alt (an explicit empty string for decorative). A missing name is a compile error, not an audit finding.
  2. Keyboard-complete through the primitive: DOM-order Tab, roving focus inside composites (arrow keys, Home, End, typeahead), Escape closes what Enter opened, nothing reachable by pointer only; the APG patterns, tested by a keyboard script per component.
  3. Focus visible by a token: one focus ring (width, colour, offset tokens) under :focus-visible on every focusable component in every theme, never removed; the lint forbids outline: none, the most common regression.
  4. Contrast by construction: the token pairs that may appear together are enumerated and checked per theme at build (4.5:1 body, 3:1 large text and UI); a component cannot pick an unlisted pair.
  5. Motion and preferences: motion tokens resolve to zero under prefers-reduced-motion; no autoplay beyond five seconds without a control; no flashing; forced-colors and high-contrast modes honoured. Tokens and primitive behaviours, not per-component decisions.
  6. The tests: axe-core on every story; a keyboard script per primitive with focus assertions; a human screen-reader pass per release on the key patterns, because axe cannot judge sense; a11y findings per surface on the adoption dashboard (part 3). A component that fails any does not ship.
the exercise
Take your system's most used component and try to use it inaccessibly: no label, no name, outline removed, a bad colour pair, pointer-only. Every attempt that compiles and ships is a guarantee the component does not yet make.
ACCESSIBILITY BAKED IN
what a component must guarantee so that it cannot be used inaccessibly by accident
swipe the figure sideways, or tap expand for full screen
1/6
names by the type
Names required by the type: Input takes label: string (or an aria-labelledby id) and will not compile without one; IconButton takes "aria-label" as a required prop; Image takes alt (an empty string for decorative, declared explicitly, never omitted). The type is the first line of accessibility: a missing name is a compile error, not an audit finding.