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. When multiple elements must work together — sharing state, managing focus, responding to keyboard input, and maintaining accessibility semantics — the complexity can quickly spiral out of control. Composers provide governance through orchestration, ensuring that this complexity is handled once and reused everywhere.
Orchestration Benefits
- Single Source of Truth: Complex behavior lives in one place, not scattered across implementations. When focus management needs to change, you update the composer — not dozens of individual components.
- Consistent Patterns: Every modal, form field, or toolbar behaves identically. Users build muscle memory, and developers build confidence.
- Accessibility by Default: ARIA relationships, focus management, and keyboard behavior are built-in. Teams don't need to remember to add them — they're automatically correct.
- Easier Testing: Test the composer once, trust it everywhere. Integration tests can focus on business logic rather than re-verifying that focus trapping works.
Composition Benefits
- Flexible Content: Slots allow varied content while maintaining consistent behavior. A modal can contain a form, a confirmation message, or rich media — the orchestration remains the same.
- Context Coordination: Children access orchestrated state without prop drilling. A form field's label, input, and error message all know the field's state without explicit wiring.
- Separation of Concerns: Content creators focus on content, not complex behavior. They slot in what they need; the composer handles the rest.
- Reusable Patterns: Same orchestration, infinite content variations. One modal composer supports confirmation dialogs, forms, media previews, and more.
Governance Benefits
- Prevents Drift: Teams can't accidentally build inconsistent versions. The composer defines the rules; variations happen through slots, not reimplementation.
- Enforces Standards: Accessibility and UX patterns are automatic. You can't forget to trap focus in a modal because the composer does it for you.
- Reduces Maintenance: Fix behavior once, it's fixed everywhere. A bug in focus restoration gets patched in one place and deployed to all modals.
- Enables Scale: New team members get consistent behavior “for free.” They learn the slot pattern once and can use any composer in the system.
Case Study: OTP Composer
A one-time passcode (OTP) input is a compelling example of a composer because it demonstrates the full spectrum of orchestration challenges. What appears to be a simple “enter 6 digits” interface actually involves:
- Multi-field coordination: Six separate inputs that must behave as a single logical unit
- Focus management: Auto-advance on valid input, backspace navigation, arrow key movement
- Paste handling: Distributing a pasted code across all fields correctly
- Input validation: Guarding against invalid characters in real-time
- Accessibility: Screen reader announcements, proper labeling, keyboard semantics
- Platform hints: Autofill support, virtual keyboard optimization
Without a composer, each implementation would need to solve these problems independently. With a composer, the logic lives in a headless hook (useOtp), the orchestration lives in a context provider (OTPProvider), and the UI is fully slottable — allowing brands to reskin without touching the behavior.
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
Anatomy of a Modal Composer
The modal example above demonstrates the core principles of composer design. Let's break down what makes it work:
- Centralized orchestration: The
Modalcomponent handles open/close state, overlay click-to-close, escape key handling, and event propagation. This logic is written once and applies to every modal in the system. - Slot-based composition:
Modal.Header,Modal.Body, andModal.Footerprovide semantic areas for content. Teams can omit the footer for simple alerts or add complex forms in the body — the orchestration remains intact. - Accessibility enforcement: Focus trapping, escape key dismissal, and proper stacking context are automatic. Teams can't accidentally break these behaviors because they're not exposed as options.
- Primitive reuse: The modal uses the
Buttonprimitive for its actions. This demonstrates how composers build on lower layers without duplicating their functionality.
Why Composers are Critical
At their core, composers exist to solve a fundamental tension in design systems: the need for consistency versus the need for flexibility. They accomplish this through a clear separation of concerns:
- Behavior is centralized: Focus management, keyboard navigation, state transitions, and accessibility semantics are defined once in the composer. This complexity is solved, tested, and maintained in a single location.
- Content is decentralized: Through slots and context, teams can inject whatever content they need. The composer doesn't care what goes in the modal body — it just ensures the body is properly accessible and focusable.
- Accessibility is automatic: Multi-element accessibility is notoriously difficult. Focus trapping, roving tabindex, ARIA relationships, and screen reader announcements require deep expertise. Composers encode this expertise once and apply it everywhere.
- Testing is tractable: Instead of testing every modal implementation for focus behavior, you test the modal composer once. Product teams can focus their testing on business logic, not interaction mechanics.
- Evolution is safe: When accessibility requirements change or browser behavior shifts, you update the composer. Every consumer automatically gets the fix without code changes.
Summary
Composers are the system's conductors: they coordinate state, focus, and interaction across multiple children. They represent the point where a design system transitions from providing building blocks to providing behavior.
Key Characteristics
- Orchestration: Composers manage state transitions, focus flow, and interaction patterns across their children.
- Slotting: They expose defined areas (header, body, footer, actions) for flexible content injection.
- Context Providers: They share state between sub-parts without requiring prop drilling.
- Variation by Pattern: They encode structural patterns rather than exposing Boolean props for every variation.
Common Examples
- Modal: Orchestrates open/close, focus trapping, escape key handling, and overlay behavior
- Form Field: Coordinates label, input, helper text, and error messaging with proper ARIA relationships
- Toolbar: Manages action priorities, overflow menus, and keyboard navigation patterns
- Pagination: Handles page number rendering, ellipsis logic, and compact vs. full display modes
- Rich Text Editor: Orchestrates schema, commands, plugins, and UI slots
Pitfalls to Avoid
- Prop explosion: Adding Boolean props for every variation instead of encoding patterns
- Leaking internal state: Exposing implementation details that force consumers to work around the composer
- Accessibility drift: Treating a11y as optional rather than core orchestration
- Overgeneralization: Building a “super composer” that tries to handle every variant
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 occupy the middle of the component complexity spectrum. They build on primitives (the atomic building blocks) and compounds (the molecular combinations), adding orchestration and state management.
When composers need to work together to create complete user flows — like a checkout process with modals, forms, and navigation — they combine into assemblies. Assemblies represent the final layer of the component hierarchy, where system components meet product-specific requirements.