Component Architecture & API Design (headless, slots)
Master the structural decisions that determine whether a component library scales across teams and products — from headless patterns to slot-based composition.
9 min read
The full lesson
How you build a component determines how far it can travel. A tightly-coupled, opinionated component might be perfect for one product. But three months later, when a second team adopts your system, that same component becomes a problem — impossible to restyle, impossible to extend, and full of props that were workarounds for edge cases you no longer remember.
Component architecture is about designing for reuse before you know all the reuse cases. That requires deliberate decisions about where logic lives, where styling lives, and where consumers can inject their own content.
Why Component API Design Is a Design Discipline
Most teams treat component APIs as an engineering concern. That framing is wrong. The API surface of a component is the contract between the system and every product that consumes it.
A poorly designed API erodes adoption — teams work around it, fork it, or abandon it. A well-designed API lets product teams build confidently without knowing the internals, the way a well-designed tool fits your hand without needing to understand how it was made.
The decisions made at the API layer ripple outward:
- Accessibility. A component that forces consumers to re-implement ARIA management will be implemented incorrectly in every product that uses it.
- Theming. A component hardcoded to a specific color token cannot participate in multi-brand theming or dark mode.
- Composability. A component that bundles layout concerns with interactive behavior prevents consumers from adapting it to different contexts.
Design systems architects should treat component APIs with the same care they give to visual language. Version bumps that break consumer code are just as disruptive as a brand refresh.
The Coupled Component Anti-Pattern
The dominant pattern in early design systems (roughly 2015–2020) was the coupled component: a single React (or Angular, or Vue) component that bundled behavior, markup structure, styling, and accessibility into one self-contained unit. This pattern shipped fast and worked well for uniform, single-brand products.
The problems appear at scale:
- A
Cardcomponent with a hardcoded internal structure cannot adapt when one product needs a horizontal card and another needs a media-first card. - A
Selectcomponent that renders a nativeselectelement and wraps it in custom CSS cannot support list virtualization for a consumer with 10,000 options. - A
Modalwith a built-in title and close button cannot satisfy an enterprise product that needs a different close affordance or a custom header layout.
The tempting response is to add props — isHorizontal, isVirtualized, hasCustomHeader — until the component becomes a configuration maze. This is called “prop-explosion,” and it is a symptom of confusing flexibility with configurability.
Headless Components: Separating Behavior from Presentation
The headless component pattern was born from this frustration. A headless component (one that manages behavior and accessibility but renders no visible UI itself) lets the consumer own all markup and styling.
A headless Combobox manages:
- Open/close state
- Keyboard navigation (arrow keys, Home/End, Escape)
- Type-ahead filtering
- ARIA attributes (
role="combobox",aria-expanded,aria-activedescendant) - Focus management when opening and closing
It does not manage:
- What the trigger button looks like
- What the option items look like
- Which design tokens apply to the list
- Whether the list appears above or below the trigger
Libraries like Radix UI, Headless UI (by Tailwind Labs), React Aria (by Adobe), and Ark UI (by Chakra) have popularized this pattern. The same pattern has been independently adopted in Vue via VueUse and Floating UI primitives, and in Web Components via the ARIA-AT community’s accessibility primitives work.
The Render Props and Compound Components Models
Before hooks, headless patterns used render props — a component accepted a function as its children prop and called it with state. This worked, but produced deep nesting and hard-to-read JSX.
// Older render-prop pattern — functional but noisy
<Combobox
value={value}
onChange={setValue}
render={({ isOpen, getInputProps, getMenuProps, getItemProps }) => (
<div>
<input {...getInputProps()} />
{isOpen && (
<ul {...getMenuProps()}>
{items.map((item, index) => (
<li key={item.id} {...getItemProps({ item, index })}>
{item.label}
</li>
))}
</ul>
)}
</div>
)}
/>
The modern replacement is the compound component pattern combined with React Context (or equivalent framework primitives). The headless root component provides behavior and state via context. Child components subscribe to that context and render the appropriate accessible markup.
// Modern compound component pattern
<Combobox value={value} onChange={setValue}>
<Combobox.Input placeholder="Search..." />
<Combobox.Options>
{items.map((item) => (
<Combobox.Option key={item.id} value={item}>
{item.label}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
The compound pattern is readable, tree-shakeable (consumers only import the sub-components they use), and naturally discoverable because the namespace (Combobox.*) documents what pieces are available.
Slots: Injecting Content Without Prop-Explosion
Slots are a composition mechanism. They let consumers inject arbitrary content into predefined regions of a component, without the component needing to know what that content is. The concept is native to Web Components (via the slot element) and has been adapted across frameworks as named children, named slots, and render slots.
Named Slots in Practice
A Card component might expose three slots: a header region, a body region, and a footer region. The component handles layout, spacing, border-radius, and elevation. The consumer decides what goes in each region.
// Named slot pattern using slotted children or render props
<Card>
<Card.Header>
<Avatar src={user.avatar} />
<Heading level={3}>{user.name}</Heading>
</Card.Header>
<Card.Body>
<Text>{user.bio}</Text>
</Card.Body>
<Card.Footer>
<Button variant="secondary">Message</Button>
<Button variant="primary">Follow</Button>
</Card.Footer>
</Card>
The component API now has zero props dedicated to “what content goes inside” — that burden shifts entirely to the composition layer.
Slots vs. Render Props vs. Children
| Pattern | Best for | Tradeoff |
|---|---|---|
children (default slot) | Simple, single-region content injection | No control over multiple distinct regions |
| Named slots / compound children | Multi-region layouts (headers, footers, sidebars) | Slightly more API surface to document |
| Render props | Sharing state with consumer’s render | Can produce deep nesting; hooks often cleaner today |
asChild / polymorphic | Changing the root rendered element | Requires careful type safety in TypeScript |
Controlled vs. Uncontrolled State
Every stateful component needs a clear answer to: who owns the state? There are two models:
- Uncontrolled: the component manages its own state internally. The consumer can read the final value via a ref or callback, but does not drive the state. Easy to use for simple cases; harder to synchronize with external state.
- Controlled: the consumer provides the current value via a prop and updates it via a callback. The component holds no internal state for that dimension. Works well with forms, URL state, and global stores.
Modern best practice is to support both modes at once via an “optionally controlled” pattern — sometimes called the uncontrolled-with-callback or “semi-controlled” model. If the consumer provides value and onChange, the component runs in controlled mode. If they omit value, the component manages state internally but still fires onChange for observability.
Libraries like Radix and React Aria call this the “default value” approach: defaultValue sets the uncontrolled initial state, and value enables controlled mode.
Do
- Export compound sub-components under a shared namespace so the API is self-documenting.
- Support both controlled and uncontrolled state for all interactive components.
- Build headless primitives with full ARIA management baked in, then layer your styled components on top.
- Use named slots to separate layout regions from content concerns.
- Expose data attributes (like
data-state="open") for styling hooks instead of forcing consumers to use class overrides.
Don't
- Accumulate behavioral props until a component has 10+ config flags — that signals you need composition, not configuration.
- Hardcode visual structure (specific heading levels, icon positions, internal spacing) inside a shared component — that structure will need to diverge across products.
- Mix layout and behavior in the same component layer; a Dialog should not also enforce the padding of its content.
- Force consumers to re-implement keyboard navigation or focus trapping — these are accessibility-critical behaviors that must live in the shared primitive.
- Use
anytypes or overly broad prop types for slots — TypeScript-first APIs catch consumer errors at build time.
Accessibility as a First-Class API Concern
Accessibility is hard to implement correctly. Centralizing it in a shared layer means a single fix benefits every product. The flip side is equally true: a component that does not manage its own accessibility forces every consumer team to implement it independently, and that guarantees inconsistency.
Key behaviors that belong in the component layer — not the consumer layer:
- Focus management. When a modal opens, focus must move to the first focusable element inside it (or a specified focus target). When it closes, focus must return to the trigger. The
inertattribute (now broadly supported) is the modern way to trap focus inside a layer, replacing the brittle tabindex-walk hacks of older implementations. - ARIA roles and states.
role="dialog",aria-modal="true",aria-expanded,aria-haspopup,aria-activedescendant— these are semantic contracts with assistive technology. Getting them right requires knowledge most product teams do not have. - Keyboard interaction patterns. The ARIA Authoring Practices Guide (APG) specifies expected keyboard behavior for patterns like Accordion, Menu, Combobox, and Tabs. Headless components should implement these patterns precisely.
WCAG 2.2 introduced new criteria (Focus Not Obscured — SC 2.4.11/2.4.12; Target Size — SC 2.5.8) that are easier to meet when the component layer controls its own layout and focus behavior rather than delegating to consumer styles.
Designing the Public API Surface
The public API surface is everything a consumer can see: props, sub-components, context hooks, CSS custom properties, and data attributes. Everything else is an implementation detail that can change between minor versions.
A well-designed public API surface:
- Is minimal but complete. Expose what consumers need; keep internals private. A smaller surface is easier to document, easier to version, and easier to reason about.
- Uses semantic naming.
variant="destructive"is more durable thanvariant="red". Token names should describe role, not appearance. - Is predictable. Similar components should follow similar conventions. If
DialogusesopenandonOpenChange, thenDrawer,Popover, andTooltipshould too. - Provides escape hatches. Even the most opinionated component should give consumers a way to override styles in rare edge cases. CSS custom properties on the component root (like
--card-border-radius) are less brittle than relying on deep CSS selectors.
/* Design system component exposes a scoped custom property */
.ds-card {
border-radius: var(--card-border-radius, var(--radius-md));
}
/* Consumer overrides for a single context */
.product-feature .ds-card {
--card-border-radius: 0;
}
This pattern respects the cascade, is discoverable through DevTools, and does not require a prop for every visual tweak.
Versioning and Breaking Changes
A component API is a promise. Every breaking change — a renamed prop, a removed sub-component, a changed keyboard behavior — requires coordinated work from every consuming team. Design systems that do not treat their component API as a public interface fail at scale.
Practical conventions:
- Mark unstable APIs explicitly. A prop or sub-component prefixed with an underscore, or annotated with
@experimental, signals that it is not covered by semver guarantees. - Deprecate before removing. Add a runtime warning (development-only) for deprecated props. Keep them functional for at least one major version cycle.
- Codemods for mechanical migrations. When a prop is renamed or a component signature changes, provide an AST transform (jscodeshift, ts-morph) that consuming teams can run automatically. Writing a codemod costs one engineer-day. Without one, every team does the same mechanical search-and-replace themselves.
- Align with design tokens. If a component API references token names directly (e.g.,
colorScheme="brand-primary"), then token renames become component API breaking changes. Use semantic role names in APIs; let the token layer handle the mapping.