What a Component Owes Its Caller
Inputs, outputs, slots, behaviour and accessibility are all part of the API. The a11y half is the half that gets left implicit, and that is where components break.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What exactly does a component promise, and which of those promises are written down in its type?
A developer wants to drop in a component and have it work — visually, by keyboard, with a screen reader — without reading its source. That is what a contract is for.
The props interface is the contract. Type the props, export the component, done. Anything else is documentation, and documentation is a nice-to-have.
A props interface says nothing about behaviour. <Select value onChange /> types cleanly and still leaves open whether it closes on select, whether it filters, whether Escape reverts or commits, and whether it is controlled or uncontrolled (Controlled vs Uncontrolled Inputs).
- A props interface says nothing about behaviour.
<Select value onChange />types cleanly and still leaves open whether it closes on select, whether it filters, whether Escape reverts or commits, and whether it is controlled or uncontrolled (Controlled vs Uncontrolled Inputs). - It says nothing about who owns the label. Half the components in a typical codebase render a control with no accessible name because the type made
labeloptional and nobody noticed at the call site (The Accessibility Tree). - It says nothing about focus. A dialog that does not state whether it traps focus and where it restores focus on close will be used both ways, and one of them strands the keyboard user on
body. - It says nothing about announcement. A component that renders an error inline has to decide whether the error is announced, and a caller cannot supply that decision after the fact (Live Regions and Announcement).
- Optional props with defaults become behaviour nobody chose.
debounce = 300inside a search input is a product decision hidden in a parameter default, and it will be discovered during a bug report. - Events without shape become guesswork.
onChangethat passes a raw DOM event in one component and a parsed value in another is the single most common cause of "why is this undefined" in a component library.
What is actually happening
In the browser, not in the framework.
- A component contract has five parts, and only the first two are usually typed. Inputs — the data and configuration it accepts, including what is required, what is controlled and what it validates. Outputs — the events it emits, when, and with what payload. Slots — what the caller may put inside and where (Composition and Slots). Behaviour — what it does on its own: state it holds, requests it makes, keys it handles, defaults it applies. Accessibility — the role, the name, the focus and the announcement, and which side of the boundary owns each.
- The accessibility part is a genuine ownership question with three legitimate answers, and the API must pick one: the component owns it (it renders the label itself and requires text), the caller owns it (the component forwards
aria-labelledbyand documents that a name is required), or it is shared (the component generates an id and wires the association, the caller supplies the words). - Whichever answer you pick becomes a type-level obligation if you want it enforced. A union that requires either
labeloraria-labelis the difference between a contract and a hope. - Behaviour is the part that leaks. Anything a component does that a caller cannot observe or override — a fetch on mount, a
document-level listener, a focus grab, a portal — is behaviour the caller inherits without agreeing to it. - Contracts have versions, whether or not you version them. A default you change is a behaviour change for every existing caller, which is the same problem an HTTP API has (Backward Compatibility: The Real Rules).
What this makes the browser do
And which of it is avoidable.
- The contract itself costs nothing at runtime; it is erased with the types. What it decides costs plenty: whether an id is generated, whether an extra element wraps the control, whether a
documentlistener is attached per instance. - A component that owns its label renders an extra element per instance. Across a form with twenty fields that is twenty nodes you would otherwise not have — correct, and not free.
- Generated ids force the component to produce a stable identifier per instance; frameworks provide a hook for this specifically because deriving it from render order breaks under hydration (Hydration Mismatch).
- A per-instance global listener — Escape, outside-click, resize — multiplies with call sites. Twelve open dropdowns is twelve
keydownlisteners ondocument, all of which run for every keystroke (Event Delegation).
The five parts, and the one that is usually missing
Write the contract out and the gap is obvious. Inputs and outputs are in the type. Slots are at least visible in the JSX or the template. Behaviour is in the source. Accessibility is nowhere — it is a property of the rendered output that nothing in the API surface refers to, which is precisely why it drifts.
The fix is not more documentation; it is putting the obligation somewhere the compiler or the linter can see. A name requirement expressed as a union type is checked on every call site forever. A name requirement expressed as a sentence in a README is checked once, by the person who wrote it.
1// Implicit: compiles, renders, ships nameless.2type IconButtonProps = {3 icon: IconName4 onClick: () => void5 className?: string6}7 8// Explicit: the name is a type-level obligation, and the9// component states which side owns role, focus and announcement.10type Named =11 | { label: string; 'aria-label'?: never }12 | { label?: never; 'aria-label': string }13 14/**15 * Contract16 * Inputs icon, plus exactly one accessible name.17 * Outputs onClick(): fired on click, Enter and Space (native button).18 * Slots none. Use <Button> if you need arbitrary content.19 * Behaviour holds no state, makes no requests, adds no document listeners.20 * A11y OWNS the role (renders a real <button>) and the disabled state.21 * CALLER owns the name and any aria-describedby.22 * NEITHER moves focus. This component never focuses itself.23 */24type IconButtonProps = Named & {25 icon: IconName26 onClick: () => void27 disabled?: boolean28 'aria-describedby'?: string29 className?: string30}The union is the load-bearing line: <IconButton icon="trash" /> stops compiling. Everything else in the doc comment is the part the type system cannot hold, which is why it is written down rather than assumed.
Accessibility as a clause, not a footnote
A component that owns an interaction pattern owes a specification, not an implementation detail. The spec below is what "this is a disclosure" actually means, and every line of it is a thing a caller would otherwise have to guess or reimplement.
Notice how much of it is ownership rather than markup. The component owns the button semantics, the expanded state and the association with the panel. The caller owns the words. Nobody owns focus movement, which is a deliberate decision: this pattern does not move focus, and saying so prevents a caller from adding a focus grab that fights the browser.
semantics A real button with aria-expanded reflecting state and aria-controls pointing at the panel id. The panel is a plain element; it is removed from the accessibility tree by being hidden, not by ARIA.
| Enter | Toggles. Free from the native button; do not reimplement it. |
| Space | Toggles. Also free, and the reason a div with a click handler is not equivalent. |
| Tab | Moves to the next focusable element — into the panel when it is open, past it when it is closed. |
- — Focus stays on the trigger when the panel opens. This pattern does not move focus; moving it is a dialog behaviour and would be wrong here.
- — When the panel closes, focus must already be on the trigger or somewhere still in the document. Never leave focus on a node you are about to remove.
- — The focus ring is the browser's. If the component restyles it, it replaces it with something of at least equal visibility.
- — State changes announce through
aria-expanded— no live region is needed and adding one produces a double announcement. - — The accessible name comes from the trigger's content or the caller's
aria-label. The component requires one of them. - — If content loads asynchronously into the panel, the loading state is the caller's to announce; the component says so rather than guessing.
usually broken by The pattern invites a div with an onClick and a rotating chevron. It looks identical, is not focusable, is not operable by keyboard, has no role and no expanded state — and every one of those failures is invisible to a mouse-driven review (Div Soup: How It Happens and What It Costs).
How contracts actually break
Contract failures are rarely dramatic. They are a default that changed, a prop that was not forwarded, a name that was optional. They surface as one flow being slightly wrong for a subset of users, which is the hardest class of bug to prioritise and the easiest to prevent at the boundary.
The rows below are all failures of an unwritten clause. Each one has a fix in the API rather than in the call site, which is the test for whether something belonged in the contract in the first place.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A required accessible name was typed as optional | Screen reader announces "button"; automated audit flags it months later | The type expressed shape, not obligation | Express the name as a union so a nameless call site fails to compile. |
| A default changed in a minor version | One flow silently needs an extra interaction; no test fails | Defaults are behaviour, and behaviour was never part of the declared surface | Treat default changes as breaking; version and changelog them like any API change (Backward Compatibility: The Real Rules). |
Component holds internal state while also accepting value | Caller-driven reset does not reset; the field keeps stale text | Two sources of truth with no stated precedence | Pick controlled or uncontrolled and enforce it in the type (Controlled vs Uncontrolled Inputs). |
aria-describedby is not forwarded | Visible error text is never announced with the field | The component owns the input element and drops caller ARIA | Forward the ARIA attributes explicitly; list them in the props type (Errors People Can Actually Perceive). |
Every instance adds a document keydown listener | Typing slows as the page grows; nested instances both swallow Escape | Undeclared behaviour that scales with call sites | Attach at a single provider, or document it and give the caller an opt-out (Event Delegation). |
| Component fetches on mount | A list of thirty renders thirty requests; nothing in the parent explains it | Behaviour invisible in the contract | Take data as a prop, or declare the fetch and expose the key (Five Components, One Request). |
How to build it
Most important first.
- Write the accessibility clause first, in words, before the props interface. "This component owns the role and the focus order; the caller must supply a name" is one sentence and it determines half the API.
- Make required things required in the type. If a name is mandatory, express it as a union —
{ label: string } | { 'aria-label': string } | { 'aria-labelledby': string }— so a nameless call site does not compile (Semantics Before ARIA). - Give events a payload shape, not a DOM event.
onChange(value: string)is a contract;onChange(e: Event)outsources parsing to every caller and couples them to your internal element. - State controlled versus uncontrolled explicitly and support one of them properly rather than both badly. If you support both, the switch must be a type-level either/or (Controlled vs Uncontrolled Inputs).
- Forward the escape hatches deliberately:
id,className,ref,aria-*anddata-*. A component that swallowsaria-describedbycannot be used in a form that has errors (Errors People Can Actually Perceive). - Prefer slots to configuration props once variation stops being boolean. Three variants is an enum; three variants with per-variant extras is a slot (Composition and Slots).
- Document behaviour that has no prop: what it fetches, what it listens to on
document, what it portals, what it focuses on mount. Anything invisible in the type belongs in the doc comment.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- The four ownership questions, answered explicitly in the API: who supplies the name, who declares the role, who moves focus and where it returns, and who announces state changes. Every interactive component answers all four, in the type where possible and in the docs otherwise.
- Naming: the component either renders the text itself, or it requires one of
aria-label/aria-labelledby. There is no third option that ends well, and "the caller will remember" is not the second one. - Roles come from elements first. A component whose contract is "renders a button" should render a
button; ARIA is what you reach for when no element carries the semantics you need (Semantics Before ARIA). - Focus is owned by whichever component owns the interaction. A dialog owns the trap and the restore; a menu owns roving tabindex; a leaf button owns nothing and must not steal it (Focus Management).
- Announcement needs a decision at the boundary: does this component render its own live region, or does it emit an event and let the page announce? Two components each rendering a polite live region will interleave unpredictably (Live Regions and Announcement).
- Forward
aria-describedbyandaria-invalidfrom the caller, always. Form components that do not are the reason error messages exist visually and not in the accessibility tree.
What can go wrong
- The nameless control.
<IconButton icon="trash" />compiles, renders, works with a mouse, and is announced as "button" with no further information (Accessible Component Patterns). - The two-source-of-truth prop: a component takes
valueand also holds internal state, so a caller-driven reset does not reset it and nobody can tell which one won (State Synchronization). - The silently-changed default. A minor version changes
closeOnSelectfromtruetofalse; nothing breaks in CI and one flow in production now requires two clicks. - The greedy listener. Every instance attaches an outside-click handler on
documentthat callsstopPropagation, so two of them nested cannot both close. - The unforwarded ref. A caller needs to focus the input after an async validation and cannot, so they reach for a
document.querySelectorand the contract has now been violated from the outside. - The mitigation failing: you added a required
labelprop, and callers passlabel=""to satisfy the type. Enforcement without a lint rule or a runtime warning is a suggestion.
- A controlled component whose
onChangetriggers an async update can receive keystrokes between the emit and the newvaluearriving. If it renders the prop directly, the caret jumps (Controlled vs Uncontrolled Inputs). - A component that both fetches on mount and accepts data as a prop can have the prop arrive after the fetch resolves, or before. The contract must say which wins (Server State Is Not Your State).
- Focus-on-mount races with anything else that focuses in the same frame — two components each politely grabbing focus produces order-dependent behaviour (Focus Management).
- Any prop that can reach
innerHTMLis a contract-level security decision. If a component accepts rich content, take nodes or a slot rather than a string, so escaping is the framework's job and not the caller's (Sanitization and Trusted HTML). - A component that accepts a URL should document what it accepts. Rendering a caller-supplied
hrefunchecked meansjavascript:anddata:URLs are part of your contract whether you meant them to be (Cross-Site Scripting). - Spreading unknown props onto a DOM element (
{...rest}) is convenient and hands callers the ability to setonLoad,srcDoc, orstyleon your internals. Pick the attributes you forward. - A component that renders based on a permission prop is rendering, not authorizing. The contract should say so, out loud, so nobody mistakes a hidden button for an enforced rule (Authorization-Aware UI).
- "TypeScript gives me a contract." It gives you the shape of the inputs. Behaviour, focus, announcement and timing are all outside the type system, and they are where the bugs are.
- "Accessibility is the consumer's responsibility." Then it will be done inconsistently by twenty consumers, which is the exact problem a shared component exists to solve.
- "Optional props are safer than required ones." Optional means "someone will not pass it". For a name or a role, that is not safety, it is a silent defect (The Rules of ARIA).
- "Prop spreading makes components flexible." It makes them unbounded. You cannot reason about, test, or safely change a component whose accepted inputs are "anything".
- "If it renders correctly, the contract is satisfied." Rendering correctly with a mouse on one device says nothing about keyboard, assistive technology, or what happens on the second click.
Measuring it, and what changes in the field
- The accessibility tree in devtools, on a rendered instance: does the node have a name, a role, and a state? This is the fastest possible contract test and takes about four seconds (The Accessibility Tree).
- An automated accessibility check in component tests catches the nameless-control class of failure at the point where it is cheapest to fix (Accessibility Testing).
- Type coverage on the call sites: how many pass
any, how many spread an object, how many cast. Each is a place the contract is not being checked (TypeScript in the Build). - A keyboard walk of every documented behaviour. If the docs say Escape reverts, press Escape (Keyboard Operability).
- For a shared library, the count of callers per prop. A prop with one caller is a leak of a specific screen's need into a general contract.
- On a slow device, behaviour clauses like debounce and transition duration become perceptible differently than they do locally, and any default you baked in is now a fixed decision on hardware you did not test.
- With a screen reader, the contract is exercised in a way visual QA never reaches: name, role, value, state, and the order in which they are announced (Accessible Component Patterns).
- Under a translated locale, a component that assumed its label fits on one line, or that built a sentence out of two props, breaks in ways the type never described (Internationalization).
- In server rendering, a contract that generates ids must generate the same ones on both sides or hydration mismatches; this is why frameworks ship an id hook rather than a counter (Hydration Mismatch).
- Across versions, every caller you do not control is running whatever contract shipped when they installed. A design system contract ages exactly like a public API (Deprecation as a Process, Not a Label).
- A strict contract — required names, typed event payloads, no prop spreading — makes call sites more verbose and occasionally forces a caller to do something the ergonomic version would have done silently. That verbosity is where the accessibility bugs went instead.
- Owning accessibility inside the component means the component renders more, is harder to restyle, and takes opinions the caller may not want. Owning it in the caller means it will sometimes be forgotten. There is no version where nobody owns it.
- Forwarding every escape hatch keeps callers unblocked and makes the component harder to change, because callers will depend on internals you exposed.
- Documenting behaviour that has no prop is real work with no compiler support, and it goes stale. It is still cheaper than the bug report.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe five parts of the contract — inputs, outputs, slots, behaviour, accessibility — and the four accessibility ownership questions apply to any component model, including Web Components, where the shadow boundary makes the naming question sharper rather than different (Shadow DOM and the Composed Tree).
- FRAMEWORK-SPECIFICHow the contract is expressed differs: React uses a props interface with callback props, Vue splits
definePropsfromdefineEmitsso outputs are declared separately, Angular uses@Input/@Outputdecorators with an explicitEventEmitter, and Svelte uses exported props with either callback props or component events depending on version. The obligations are identical; only the declaration site moves.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — a component contract is an interface, and the usual interface discipline applies: narrow it, keep it stable, and do not let a caller depend on something you did not promise.
- — Testing & Reliability Engineering — the contract is the test plan. Every clause above is a test, and the accessibility clauses are the ones automated tooling can only partly check.