112: Component Architecture, Headless Patterns, Styling, Animation, and Internationalization
Learning objectives
By the end of this lesson, you should be able to:
- design reusable component APIs through composition;
- identify compound components and understand when they are useful;
- recognize render props and higher-order components in existing code;
- separate headless behavior from visual styling;
- choose a styling approach deliberately rather than by habit;
- use component libraries without giving up semantic HTML or accessibility;
- add motion without ignoring reduced-motion preferences;
- design interfaces for localization instead of assuming that English layout rules will always hold.
Component API design
Treat a reusable component as a small library with a public API. The component may be only a few lines of JSX, but other code will depend on the decisions that API exposes.
Its API should make the following questions answerable:
- What does the caller control?
- What does the component own?
- Which child structures are valid?
- How are events reported?
- Which accessibility responsibilities are built in?
If those answers are unclear, the component will tend to accumulate flags, special cases, and undocumented assumptions. Good API design makes normal usage straightforward and makes invalid combinations difficult to express.
There is also an ownership question behind each prop. A component should expose a prop when the caller has a legitimate reason to make that decision. If a prop exists only to reach into an implementation detail, the boundary needs another look.
The API should make state transitions observable as well. A caller should be able to tell whether it owns the state, receives notifications about changes, or is simply rendering a snapshot. That distinction matters when a component is reused in forms, dialogs, and URL-driven interfaces.
Compound components
Compound components expose a group of related components that work together. A typical API might look like this:
<Tabs
value={tab}
onValueChange={
setTab
}
>
<Tabs.List
aria-label="Task views"
>
<Tabs.Trigger
value="open"
>
Open
</Tabs.Trigger>
<Tabs.Trigger
value="done"
>
Done
</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel
value="open"
>
<OpenTasks />
</Tabs.Panel>
<Tabs.Panel
value="done"
>
<CompletedTasks />
</Tabs.Panel>
</Tabs>
The useful property here is not the dot notation by itself. Tabs.List, Tabs.Trigger, and Tabs.Panel are recognizable parts of one API, while the caller still controls the content and overall composition. Related pieces are discoverable without forcing one rigid markup template.
Internally, compound components often use Context to share the selected value and event handlers. That implementation detail lets the child components coordinate without requiring every value to be threaded through the entire tree.
Do not turn every two-element relationship into a compound-component API. The pattern earns its complexity when several related pieces need shared behavior, shared semantics, or a meaningful structure.
Headless components
A headless component or hook owns behavior and accessibility while allowing its consumers to choose the visual presentation. This separation is useful when the interaction model is complicated but the product needs its own markup or visual language.
Typical examples include:
- combobox behavior;
- menu keyboard interaction;
- dialog focus behavior;
- the selection model for tabs.
Rebuilding this behavior in every application is risky. Keyboard navigation, focus management, and ARIA relationships contain enough edge cases that a mature implementation can save more than it costs.
Headless libraries should still be evaluated rather than adopted blindly. Audit at least:
- the keyboard model;
- ARIA semantics;
- bundle impact;
- portal behavior;
- controlled and uncontrolled support;
- integration with your styling approach.
"Headless" does not mean "automatically accessible." It means that behavior and presentation are separated; you still need to understand the behavior contract and use the primitives correctly.
This separation also changes what you test. Test the behavior contract with keyboard and focus interactions, then test the consuming markup for the visual and semantic requirements of the product. A headless primitive can pass its own tests while an incorrect wrapper still produces a broken accessible name or focus order.
Render props
In existing React code, you may encounter a render prop such as this:
<DataLoader>
{({
data,
pending,
}) => (
...
)}
</DataLoader>
The component owns the loading behavior and calls the function supplied by the consumer with the current state. The consumer owns the rendered result.
Render props were a common composition mechanism before Hooks and remain useful in some libraries. Do not rewrite them solely because Hooks exist. If the API is clear, stable, and does not create a real maintenance problem, replacing it may add churn without improving the design.
Higher-order components
Legacy and library code may contain a higher-order component, or HOC, like this:
const Enhanced =
withPermissions(
TaskPage,
);
An HOC receives a component and returns another component. The returned component may add data, behavior, or rendering constraints around the original.
Modern application code often prefers Hooks and direct composition, but understanding HOCs remains necessary when maintaining older React ecosystems. When debugging one, look for:
- stacks of wrappers;
- static properties that were not copied;
- prop name collisions;
- a confusing component tree in DevTools.
The wrapper may be the source of a missing prop or an unexpected render boundary, even when the visible component appears innocent.
Controlled component API
A reusable controlled component commonly follows this API:
value
onValueChange
An uncontrolled version may instead accept an initial value through:
defaultValue
This mirrors browser control conventions: the caller owns the current value in controlled mode, while the component manages it internally in uncontrolled mode.
Avoid supporting both modes without a deliberate contract. Define what happens when value is present, whether switching modes is allowed, and how changes are reported. Ambiguous controlled/uncontrolled behavior is a frequent source of warnings and difficult-to-reproduce state bugs.
For example, a controlled component should not silently update its displayed value without the parent accepting the change. Conversely, an uncontrolled component should not require the parent to mirror every internal update just to remain usable. Write the contract down before implementing both paths.
Slot/composition pattern
Boolean props often create a component that has too many combinations to reason about:
<Card
showHeader
showActions
showFooter
/>
Composition usually expresses the structure more directly:
<Card>
<Card.Header>
Tasks
</Card.Header>
<TaskList />
<Card.Footer>
<TaskCount />
</Card.Footer>
</Card>
The caller can see which regions exist and can place real content in them. This reduces prop explosion and avoids adding a new boolean every time the component needs one more layout variation.
Styling options
React does not prescribe one styling system. The right choice depends on the team's conventions, rendering environment, design-system needs, and the cost of changing the system later.
Plain CSS
Plain CSS is a strong baseline:
import './TaskCard.css';
It gives you the platform's complete styling model and works well with global design tokens and simple applications. You need naming conventions and boundaries when many components share the same stylesheet.
CSS Modules
CSS Modules provide scoped class names at build time:
import styles
from './TaskCard.module.css';
function TaskCard() {
return (
<article
className={
styles.card
}
>
...
</article>
);
}
The local name styles.card is mapped to a generated class name, reducing accidental collisions while keeping the styling model close to CSS.
Utility CSS
Libraries such as Tailwind can make design tokens and responsive utility composition convenient. The trade-offs include class-heavy markup and the need to follow the library's tooling and project conventions.
CSS-in-JS
CSS-in-JS is useful in some design systems, but evaluate it against the actual rendering and build environment. Consider:
- runtime cost;
- server-rendering behavior;
- style insertion;
- compiler and build integration.
Do not select a styling system because it is currently fashionable. Select it because its trade-offs fit the application and the team can apply it consistently.
Component libraries
Component libraries can accelerate work on:
- accessible primitives;
- complex form controls;
- data display;
- theming.
They do not replace product semantics. A library can provide a technically sound primitive, but the application still has to choose the right element and communicate what the control means.
Inspect the generated DOM when evaluating an abstraction. If a visual "button" renders as a non-interactive <div>, the abstraction is wrong for that use, regardless of how much it resembles a button visually.
Also inspect labels, relationships, focus targets, and state attributes rather than stopping at the outer element. The browser and assistive technology consume the DOM contract, not the component name used in source code.
Styling state
Prefer reflecting state through attributes or classes instead of embedding every visual rule in React:
<button
aria-pressed={
selected
}
data-state={
selected
? 'selected'
: 'idle'
}
>
CSS can then own the visual rule:
button[data-state="selected"] {
font-weight: 700;
}
This gives React responsibility for state and CSS responsibility for presentation. The aria-pressed attribute communicates the interaction state to assistive technology; data-state provides a convenient styling hook.
Animation
Start with CSS for simple transitions. A React animation library becomes more appropriate when you need capabilities such as:
- enter and exit orchestration;
- layout animations;
- gesture animation;
- spring physics;
- complex coordinated sequences.
Do not animate merely because an animation library is already installed. Motion adds behavior, lifecycle concerns, and accessibility obligations. Use it when it improves the interaction and when the application can support those obligations.
Keep the non-animated path correct first. If a transition is interrupted, if JavaScript is unavailable, or if the user has disabled motion, the underlying state change should still happen in the expected order.
Reduced motion
Respect the user's reduced-motion preference. A CSS baseline can look like this:
@media (
prefers-reduced-motion:
reduce
) {
*,
*::before,
*::after {
animation-duration:
0.001ms !important;
animation-iteration-count:
1 !important;
scroll-behavior:
auto !important;
}
}
For important JavaScript-driven motion, detect the preference through an appropriate media-query abstraction and adjust the behavior there as well.
Reduced motion means reducing unnecessary motion, not removing every visual indication of change. A state change still needs to be understandable; avoid only the movement that is not needed to communicate it.
Accessibility architecture
Accessibility should be part of a component's contract, not a cleanup task applied after the visual implementation.
A button that represents a toggle may need native button semantics and an aria-pressed state:
<button
type="button"
aria-pressed={
active
}
>
A field needs a programmatically associated label:
<label htmlFor={id}>
...
</label>
A dialog contract includes:
- an accessible name;
- focus entry;
- focus return;
- dismiss behavior;
- handling for interaction with the background.
Do not add ARIA when native HTML already supplies the correct semantics. Native elements generally provide keyboard behavior and accessibility behavior that a custom element would have to recreate.
Internationalization
String concatenation that looks harmless in English can encode an invalid assumption about grammar and word order:
<p>
You have
{' '}
{count}
{' '}
tasks
</p>
Plural rules differ across languages, and some languages do not put the count and noun in this order. Use an i18n formatter or library that supports message pluralization instead of assembling translated sentences from fixed English fragments.
Also design for:
- longer translated strings;
- right-to-left layout;
- locale-sensitive dates and numbers;
- time zones;
- language changes while the interface is in use.
Use Intl for locale-sensitive primitives where appropriate. Formatting is presentation logic and should not be replaced with assumptions based on one locale.
Message translation has a similar boundary. Store a message key and the values needed by the message, then let the formatter choose word order and plural form. Do not use a translated sentence as a data field that other code has to parse.
Component ownership and design system boundaries
A design-system component should not import business API code. For example, this dependency points in the wrong direction:
shared/ui/Button
→ imports taskApi
The feature should compose the shared primitive instead:
feature task action
→ composes shared Button
Dependencies should point from feature code toward reusable primitives, not from the reusable primitives back into a particular feature. That direction keeps the design system reusable and prevents a supposedly generic component from quietly becoming part of one application's data layer.
Common mistakes
- giant configurable components with dozens of boolean props;
- rebuilding complex ARIA widgets from scratch without understanding keyboard patterns;
- mixing server calls into shared UI primitives;
- styling every state inline;
- assuming English text length;
- animation that ignores reduced-motion preference;
- using divs as buttons;
- adopting multiple styling systems without design rules.
Exercises
- Build controlled Tabs with compound components.
- Convert a prop-heavy Card to composition.
- Style a component using CSS Modules.
- Audit a headless dialog for focus and accessible name.
- Add reduced-motion behavior.
- Format task dates using locale-aware
Intl.DateTimeFormat. - Draw a dependency diagram for shared UI versus feature code.
Exit questions
- What makes a component "headless"?
- Why are compound components useful?
- What are render props and HOCs?
- How should styling-system choice be evaluated?
- Why is reduced motion a component responsibility?
- Why should design-system components not import business APIs?
Official references
- https://react.dev/learn/passing-props-to-a-component
- https://react.dev/learn/passing-data-deeply-with-context
- https://react.dev/reference/react-dom/components/common
- https://www.w3.org/WAI/ARIA/apg/
Deep dive: reusable component design is about invalid states
A good component API makes common valid usage easy and invalid combinations difficult to express. This is more useful than merely making a component configurable: the API itself should guide callers toward coherent states.
Consider a modal API with overlapping switches:
<Modal
open
closed={false}
hasHeader
noHeader={false}
dismissable
noOverlay={false}
type="confirmation"
destructive
/>
There are too many ways for the flags to disagree. open and closed duplicate one decision, and hasHeader and noHeader duplicate another.
A composed API gives the structure meaningful names:
<Dialog open={open} onOpenChange={setOpen}>
<Dialog.Content>
<Dialog.Title>Delete task?</Dialog.Title>
<Dialog.Description>
This cannot be undone.
</Dialog.Description>
<Dialog.Actions>
<Button variant="ghost">Cancel</Button>
<Button variant="danger">Delete</Button>
</Dialog.Actions>
</Dialog.Content>
</Dialog>
Composition constrains the structure through meaningful parts. It does not eliminate every possible misuse, but it removes several contradictory states from the API and makes the intended document structure visible in the call site.
Polymorphic components
Design systems sometimes allow a component to change its host element:
<Button asChild>
<Link to="/tasks">Tasks</Link>
</Button>
Some systems expose the same idea through an as prop.
This is a powerful escape hatch, so use it carefully. Changing the host element can change:
- semantics;
- keyboard behavior;
- required props;
- ref type;
- accessibility.
Do not make every component polymorphic by default. A <button> and an <a> are not interchangeable merely because CSS can make them look alike. A button performs an action; a link navigates. The API should preserve that distinction unless the abstraction has a clear and tested contract for both cases.
Headless state machines
A headless menu may manage much more than whether a popup is visible:
closed
open
active item
keyboard navigation
typeahead
focus return
outside click
escape
portal
disabled items
That is substantial behavior. The state transitions, focus rules, and DOM relationships all need to remain coherent as the user moves through the menu in different ways.
Using Radix, React Aria, Headless UI, Ark UI, or similar mature primitives can be safer than reimplementing complex ARIA patterns. Even then, audit the specific library and version you are adopting, and understand its DOM contract. A headless primitive is only safe when your markup and integration preserve the assumptions it makes.
Component library evaluation checklist
Before adopting a component library, evaluate:
- React 19 compatibility;
- SSR and hydration behavior;
- accessibility claims and supporting evidence;
- bundle size and tree shaking;
- controlled and uncontrolled APIs;
- styling model;
- portal layering;
- RTL support;
- form integration;
- animation and reduced motion;
- maintenance and security health.
Do not choose based only on screenshot appearance. A polished screenshot says little about keyboard interaction, hydration behavior, generated DOM, or the cost of changing the library later.
Styling architecture
Plain/global CSS
Plain or global CSS works well for:
- design tokens;
- layout primitives;
- small apps.
Its main risk is global naming collisions when the project does not use conventions.
CSS Modules: architecture details
CSS Modules are a good fit for local component styles:
import styles from './TaskCard.module.css';
The important boundary is that the class names are scoped by the build system, while the actual declarations remain ordinary CSS.
Utility classes
Utility classes are useful for consistent token-based composition and rapid UI work. Establish conventions for:
- long conditional class lists;
- reusable variants;
- design tokens.
Without those conventions, the same visual decision can be expressed in many slightly different ways and the class lists become difficult to review.
CSS-in-JS: architecture details
Evaluate runtime versus build-time approaches. Runtime style insertion can interact with:
- SSR;
- streaming;
- style ordering;
useInsertionEffect.
Modern tooling may extract styles at build time, which changes the runtime trade-off. Check what the selected tool actually does rather than reasoning from the CSS-in-JS label alone.
Variant management
When utility styling is used, avoid scattering string concatenation for variants across many components. A reusable variant helper can make the allowed visual combinations explicit:
buttonClass({
variant: 'danger',
size: 'sm',
loading: true,
});
Keep visual variant logic separate from business authorization. Whether a button is hidden or disabled because the current user lacks permission is business UI state. Whether the button uses a danger color is design-system state. They may affect the same rendered element, but they are different decisions and should not be conflated in a generic styling helper.
Design tokens
CSS variables are a natural place for static design tokens:
:root {
--color-surface: ...;
--color-danger: ...;
--space-2: .5rem;
--radius-md: .5rem;
}
React should not own static token state. Theme switching can toggle an attribute or class:
<html data-theme="dark">
CSS can then resolve the actual values for that theme. This keeps static visual configuration in the styling layer instead of causing React state and renders for values that do not represent application data.
Animation and presence
Animating a mount or unmount is harder than animating a static element because the DOM node normally disappears as soon as state changes. An exit animation therefore needs a presence mechanism that keeps the node mounted long enough for the animation to complete.
Animation libraries may provide presence primitives for this purpose. When using them, answer the lifecycle questions explicitly:
- Should the exit animation delay unmounting?
- What happens if state changes again during the exit?
- Where does focus go if a dialog is closing?
- What is the reduced-motion behavior?
- What happens if route navigation interrupts the animation?
Motion is behavior, not decoration only. It changes timing, focus, and sometimes whether a user can interact with a node, so it belongs in the component's behavioral design.
View Transitions and React ecosystem
Modern browsers and the React ecosystem increasingly expose view-transition capabilities. Use them as progressive enhancement.
Navigation correctness must not depend on an animation API. If the API is unavailable, fails, or is disabled, the navigation and content update still need to work. Users who prefer reduced motion should receive a stable experience rather than a degraded version of the application.
Internationalization details
Never store a formatted display string as canonical data. Store the underlying values instead:
ISO timestamp / numeric value / message key/domain state
Format those values at presentation time. For example:
new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
Currency formatting follows the same principle:
new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount);
Do not hard-code:
₹${amount}
when the product supports multiple locales or currencies. The currency symbol, placement, separators, and number of fractional digits can all vary.
RTL
Use logical CSS properties when layout should adapt to writing direction:
margin-inline-start
padding-inline
inset-inline-end
Prefer those over always specifying left and right for directional layout. Also test icons whose direction carries meaning:
- back arrow;
- next chevron.
Some icons should mirror in RTL; others, such as play icons and logos, may not. The correct behavior depends on the meaning of the icon rather than on a blanket mirroring rule.
Composition and data dependencies
A shared UI component should receive data and behavior; it should not fetch a business resource on its own. This design-system component is tightly coupled to an application data layer:
function UserAvatar({ userId }) {
const query = useQuery(...);
}
That is a poor design for a shared Avatar because every consumer now inherits the feature's query and caching assumptions.
Pass presentation data to the generic component instead:
<Avatar
src={user.avatarUrl}
name={user.name}
/>
A feature-specific component may deliberately wrap loading:
<UserAvatar userId={...} />
That can be a sound boundary when the feature owns the data concern. Name the boundaries clearly so callers know whether they are using a presentational primitive or a feature component with application behavior.
Compound Context performance
Compound components often coordinate through Context:
<TabsContext value={...}>
If that context contains rapidly changing, large state, all consumers that read it may rerender when the value changes. For complex component libraries, consider split contexts or external-store patterns when profiling shows that this matters.
Do not prematurely optimize a small component set. Context rerenders are a design consideration, not proof that every compound component needs a more elaborate state architecture.
When performance does become a problem, measure which consumers rerender and why. Splitting context is useful only when it matches the actual access patterns; it is not a substitute for identifying unnecessary state changes or oversized values.
Escape hatches in component APIs
Some components need to expose a ref:
<TextField ref={fieldRef} />
Modern React 19 supports ref as a prop. Expose imperative APIs only where declarative props cannot express the required behavior. A ref is useful for a concrete focus or measurement boundary; it should not become a general-purpose way for consumers to reach into every internal detail.
Error and loading slots
Reusable data panels can accept presentation slots for states they do not own visually:
<DataPanel
loading={<PanelSkeleton />}
error={(error) => <PanelError error={error} />}
>
...
</DataPanel>
This allows the caller to provide appropriate UI while the panel coordinates the display of those states. Avoid turning the abstraction into a generic component that "handles every async thing" and duplicates the semantics of TanStack Query or Suspense.
Failure clinic
div with onClick styled as button
The element's semantics and keyboard behavior are broken. Use a native button when the interaction is a button action.
Shared UI fetches feature API
Dependency inversion is broken. Move the data loading to a feature boundary and pass the resulting data into the shared primitive.
Every component accepts className and arbitrary props without policy
This can be useful, but without an extension policy the design-system API becomes uncontrolled. Decide which props are forwarded, which styling hooks are supported, and which variants are part of the contract.
Hard-coded English width
Translated UI can overflow when a longer string is inserted. Test with expanded text and avoid treating English measurements as universal.
Animation ignores focus
Focus can land on a node that is unmounted or hidden. Define focus entry and return behavior as part of the animation and presence lifecycle.
Deep-dive exercises
- Redesign an invalid-state-heavy modal API.
- Build an accessible Tabs compound API.
- Compare one headless library primitive against the WAI-ARIA APG.
- Theme with CSS custom properties.
- Add locale-aware currency and date formatting.
- Test the layout in RTL and with 2× text.
- Audit an exit animation for focus and reduced motion.
- Draw the dependency boundary between the design system and the feature API.
Mastery check
Explain:
- invalid-state API design;
- headless behavior;
- styling-system trade-offs;
- design tokens;
- motion lifecycle;
- i18n and RTL concerns;
- data dependency direction.
Production case study: designing a reusable DataTable boundary
A DataTable is an easy place to create a 60-prop monster. Resist the temptation to put every concern that appears near a table into the table component itself.
Avoid mixing all of these responsibilities:
query fetching
pagination API
row selection
column rendering
permissions
CSV export
modal state
styling
into one universal component.
A better split gives the feature the application decisions and leaves the generic table with presentational responsibilities:
OrdersTableFeature
├─ owns Query + URL + permissions
└─ composes
DataTable
├─ columns
├─ rows
├─ selection callbacks
└─ presentational states
The generic component can then have an API such as:
<DataTable
columns={columns}
rows={orders}
getRowKey={(order) => order.id}
selectedKeys={selectedIds}
onSelectionChange={setSelectedIds}
/>
The feature owns the decisions that are specific to the application:
where rows came from
what selected orders mean
whether export is allowed
which route opens
This keeps the design-system component reusable without turning it into a hidden application framework. The boundary is not about minimizing the number of props at any cost; it is about keeping ownership of business decisions in the feature that understands them.
This split also makes testing more local. The generic table can test rows, columns, selection callbacks, and presentational states without inventing an orders API. The feature can test query parameters, permissions, export rules, and routing without requiring the table to know what an order means.
Additional depth: React ecosystem choices without turning the course into library memorization
The React ecosystem has many optional branches. You do not need to memorize every library, but you should recognize the categories and know how to evaluate a choice against the project.
Component systems
Examples in the ecosystem include:
MUI
Chakra UI
Mantine
Ant Design
These libraries provide styled components and design systems. Their value and cost depend on how closely their primitives, styling model, and accessibility behavior fit the application.
Headless primitives
Examples include:
Radix
React Aria
Headless UI
Ark UI
These emphasize behavior and accessibility primitives while leaving more of the visual presentation to the application.
Animation
Examples include:
Motion
GSAP
React Spring
Choose based on interaction complexity and bundle/runtime needs. A simple CSS transition does not become a better solution just because one of these libraries is available.
Frameworks
Examples include:
Next.js
React Router Framework Mode
Astro with React islands
Frameworks add rendering, routing, server, and deployment architecture. They are not merely component libraries, so evaluate them at the application-architecture level.
Mobile
React Native uses React's component and state mental model, but it targets a different host platform with different component primitives.
Knowing React DOM does not mean you automatically know React Native layout, native modules, navigation, performance, or platform UX. Transfer the mental model carefully and learn the platform-specific boundaries.
GraphQL
Apollo, urql, and other clients may own a GraphQL server cache.
If a project standardizes TanStack Query for REST, do not add Apollo merely because GraphQL exists. A GraphQL-aware client can be justified when the project adopts GraphQL architecture and needs its cache and data-fetching model, but adding a second data layer without that need increases complexity.
The course teaches categories and decision criteria. Specialist libraries should become separate advanced modules when the project actually uses them.
