Deep Dive: Composers
Why Composers Exist
Primitives give us atoms, compounds give us molecules — but product interfaces demand more. A composer is where a design system stops bundling parts and starts orchestrating interaction and state.
Think of a modal, a toolbar, pagination, or a form fieldset. These aren’t just bundles of primitives — they coordinate:
- Multiple states (open/closed, selected/unselected, error/valid).
- Multiple flows (keyboard vs mouse, small vs large screen, logged in vs logged out).
- Multiple roles (what happens to focus, what gets announced to a screen reader, what rules apply when contents vary).
Composers exist because user interactions don’t stop at a single element — they span across elements.
Characteristics of Composers
- Orchestration: manage focus, context, and state for child primitives/compounds.
- Slotting: expose defined areas (header, body, footer, actions) for flexible composition.
- Variation by pattern, not prop: handle families of behavior (e.g., ellipses in pagination) rather than a Boolean soup of configuration.
- Context Providers: share state between sub-parts without forcing prop-drilling.
Examples of Composers
- Modal: orchestrates open/close, traps focus, provides slots for header/body/footer.
- Form Field: orchestrates label, input, error messaging across multiple input types.
- Toolbar / Filter Bar: orchestrates a dynamic set of actions, priorities, and overflow menus.
- Pagination: orchestrates page numbers, overflow ellipses, compact vs full modes.
- Rich Text Editor: orchestrates schema, commands, plugins, and UI slots.
The Work of the System at the Composer Layer
1. Orchestration
- Control state transitions (modal open → trap focus → restore focus on close).
- Govern keyboard interaction models (arrow key navigation in toolbars, tab order in forms).
- Provide context for sub-parts (form state, toolbar action registry).
2. Variation by Pattern
- Instead of adding a prop for every variant, encode structural patterns.
- Example: Pagination doesn’t expose showEllipses: boolean; it defines a policy for when ellipses appear.
3. Slots for Composition
- Provide places for product-specific content without breaking orchestration.
- Example: Modal slots for header/body/footer let teams add what they need while the system enforces a11y and focus rules.
Pitfalls of Composers
- Prop Explosion as a Lazy Shortcut
- Composers often start with props for each variation: hasCloseButton, showFooter, isInline, isSticky.
- Guardrail: encode variations as structural patterns, not toggles.
- Leaking Internal State
- If a form composer exposes internal validation state poorly, teams may hack around it.
- Guardrail: provide a clean context/hook API for internal orchestration.
- Breaking Accessibility in the Orchestration
- Example: a modal that doesn’t trap focus or a toolbar without roving tabindex.
- Guardrail: accessibility rules must be first-class orchestration, not optional add-ons.
- Overgeneralization
- Composers aren’t universal solutions. A “SuperModal” that tries to handle every drawer/alert/dialog variant will be brittle.
- Guardrail: scope composers to a pattern family, not the entire design problem space.
The Problem: Without Composers
Before we see composers in action, let's understand what happens without them. Consider building modals across a large application:
The Solution: Modal Composer
Now let's see how a composer centralizes this complexity into a reliable, reusable orchestration layer:
Advanced Example: Form Field Composer
Let's see a more complex composer that demonstrates context-based orchestration, managing multiple children and coordinating validation state:
Why Composers Are Essential
Composers solve the “coordination problem” that emerges in design systems at scale. They provide governance through orchestration:
🎯 Orchestration Benefits
- Single Source of Truth: Complex behavior lives in one place, not scattered across implementations
- Consistent Patterns: Every modal, form field, or toolbar behaves identically
- Accessibility by Default: ARIA relationships, focus management, and keyboard behavior built-in
- Easier Testing: Test the composer once, trust it everywhere
🧩 Composition Benefits
- Flexible Content: Slots allow varied content while maintaining consistent behavior
- Context Coordination: Children access orchestrated state without prop drilling
- Separation of Concerns: Content creators focus on content, not complex behavior
- Reusable Patterns: Same orchestration, infinite content variations
⚖️ Governance Benefits
- Prevents Drift: Teams can't accidentally build inconsistent versions
- Enforces Standards: Accessibility and UX patterns are automatic
- Reduces Maintenance: Fix behavior once, it's fixed everywhere
- Enables Scale: New team members get consistent behavior “for free”
Case Study: OTP Composer
A one-time passcode (OTP) input is a great example of a composer: it coordinates multiple input fields, manages paste behavior, advances focus, and exposes slots for labels, separators, and errors — all while remaining brand-agnostic and token-driven.
API (minimal, orchestration-first)
- length: number of digits; default 6
- mode: ‘numeric’ | ‘alphanumeric’ | RegExp
- value / defaultValue: controlled or uncontrolled
- onChange / onComplete: callbacks for progress and completion
- mask: visual masking (•) only; logic remains accessible
- separator: ‘none’ | ‘space’ | ‘dash’ | React node
- autocomplete / inputMode: platform hints for keyboards and auto-fill
Meta-patterns
- Headless logic hook: core OTP behaviors live in
useOtp - Context provider:
OTPProviderexposes orchestrated state - Slotting & substitution: UI parts are replaceable (field, label, error, separator)
- Token-driven visuals: no hard-coded colors; styles map to tokens
Folder structure
OTPInput/ ├── index.tsx ├── OTPInput.tsx # visual scaffolding (slots) ├── OTPProvider.tsx # context + orchestration ├── useOtp.ts # headless logic (state, focus, paste) ├── OTPField.tsx # default field primitive (swappable) ├── OTPSeparator.tsx # optional slot ├── OTPLabel.tsx # optional slot ├── OTPError.tsx # optional slot ├── OTPInput.module.scss ├── OTPInput.tokens.json ├── OTPInput.tokens.generated.scss └── README.md
Accessibility Notes
- Group semantics: wrap fields in a role="group" and associate labels/errors via described-by
- Paste: allow multi-character paste and distribute across slots
- Backspace: backspace moves focus left when empty; clears when filled
- Virtual keyboards: autocomplete="one-time-code", inputMode="numeric" or "tel"
- Screen readers: each field has an aria-label (e.g., “Digit 1”)
- Reduced motion: advance focus only on valid entry; deterministic arrow navigation
Why this travels well
- Headless logic: products can reskin without re-implementing paste/focus/validation
- Slotting: replace
OTPField, separators, and text slots freely - Tokenized visuals: map typography, radius, spacing, and colors to tokens
- Clear boundaries: OTP is a system composer; flows like checkout live as assemblies
Quick verification
- Composer invariants:
useOtp+OTPProviderexist - Exports include provider, group, field, and slots
- README-style guidance covers usage, props, and a11y
- No prop explosion; variations derive from slots + tokens
Explicit props interface
export interface OTPInputProps {
/** Number of OTP digits (3–12 typical). Default: 6 */
length?: number;
/** Numeric-only, alphanumeric, or custom regex guard. Default: "numeric" */
mode?: 'numeric' | 'alphanumeric' | RegExp;
/** Autofill hints for platforms that support it. Default: 'one-time-code' */
autocomplete?: 'one-time-code' | 'otp' | string;
/** Controlled value as a string of length N (optional) */
value?: string;
/** Uncontrolled default value (optional) */
defaultValue?: string;
/** Called when all N slots are filled with valid characters */
onComplete?(code: string): void;
/** Called on any change (partial codes included) */
onChange?(code: string): void;
/** Disabled / readOnly semantics */
disabled?: boolean;
readOnly?: boolean;
/** Ids for a11y grouping & descriptions (label, error, help) */
id?: string;
'aria-describedby'?: string;
/** Optional mask (e.g., show • instead of digits) */
mask?: boolean;
/** Optional separator render strategy ('space' by default) */
separator?: 'none' | 'space' | 'dash' | React.ReactNode;
/** Inputmode hint to virtual keyboards; defaults inferred from mode */
inputMode?: React.InputHTMLAttributes<HTMLInputElement>['inputMode'];
}Tokens and styles
Example token JSON and SCSS usage:
// OTPInput.tokens.json
{
"component": {
"otpInput": {
"field": {
"size": { "minWidth": "{size.12}", "height": "{size.12}" },
"typo": { "fontSize": "{font.size.300}", "fontWeight": "{font.weight.semibold}" },
"radius": "{radius.md}",
"gap": "{space.200}"
},
"color": {
"text": "{color.foreground.default}",
"bg": "{color.background.surface}",
"border": "{color.border.subtle}",
"focus": "{color.border.focus}",
"invalid": "{color.border.danger}"
}
}
}
}
// OTPInput.module.scss
@import './OTPInput.tokens.generated.scss';
.root {
display: inline-flex;
align-items: center;
gap: var(--component-otp-field-gap);
}
.field {
min-width: var(--component-otp-field-min-width);
height: var(--component-otp-field-height);
text-align: center;
font-size: var(--component-otp-field-font-size);
font-weight: var(--component-otp-field-font-weight);
border-radius: var(--component-otp-field-radius);
color: var(--component-otp-color-text);
background: var(--component-otp-color-bg);
border: 1px solid var(--component-otp-color-border);
outline: none;
}
.field:focus-visible {
border-color: var(--component-otp-color-focus);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--component-otp-color-focus) 30%, transparent);
}Usage (slots + defaults)
import {
OTPProvider,
OTPInput,
OTPField,
OTPSeparator,
OTPLabel,
OTPError,
} from '@/ui/components/OTPInput';
export function CheckoutOtpExample() {
return (
<form onSubmit={(e) => { e.preventDefault(); }}>
<OTPProvider length={6} mode="numeric" onComplete={(code) => console.log('OTP:', code)}>
<OTPLabel>Enter the 6-digit code</OTPLabel>
<OTPInput>
{Array.from({ length: 6 }).map((_, i) => <OTPField key={i} index={i} />)}
</OTPInput>
<OTPError id="otp-error">{/* show error when server rejects */}</OTPError>
</OTPProvider>
<button type="submit">Verify</button>
</form>
);
}README starter
# OTPInput A composer for one-time passcodes (OTP). Headless logic + slot-based UI for multi-brand reuse. ## When to use - Login, 2FA, device verification, high-risk actions. ## Key ideas - Headless logic in useOtp (paste, focus, completion) - Slotting: replace OTPField, OTPSeparator, OTPLabel, OTPError freely - Tokenized visuals for brand theming ## Props See OTPInputProps. Minimal surface: length, mode, onComplete, onChange, a11y ids, mask, separator. ## Accessibility - Grouped with role="group", labeled and described - autocomplete="one-time-code", inputMode hints for keyboards - Backspace & arrow navigation semantics included - Paste distribution supported
Original Modal Example
- Orchestration: open/close, overlay click, focus trap handled once.
- Slots: Header, Body, Footer as sub-components.
- Composition: teams can put whatever primitives inside, but accessibility and focus rules are enforced.
Why Composers are Critical
- They channel complexity into predictable patterns rather than scattered workarounds.
- They protect accessibility models at the multi-element level (focus, ARIA roles, keyboard models).
- They enable flexibility without chaos: slots allow teams to insert or omit, but orchestration keeps rules consistent.
- They free product teams from rebuilding orchestration logic (which is hard, error-prone, and often missed).
Summary
Composers are the system’s conductors: they coordinate state, focus, and interaction across multiple children.
- Examples: Modal, Form Field, Toolbar, Pagination, Rich Text Editor
- Work of the system: orchestration, variation by pattern, slotting, context providers
- Pitfalls: prop explosion, leaking state, accessibility drift, overgeneralization
If primitives are the boring DNA, and compounds are the grammar rules, then composers are the syntax that makes the grammar work in practice. They're where design systems prove their worth — not just in how things look, but in how they behave.
Next Steps
Composers often contain compounds and primitives, and can be combined into assemblies for complete user flows.