ComponentsGENERALFRAMEWORK-SPECIFIC

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.

The question

What exactly does a component promise, and which of those promises are written down in its type?

The user intent

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 obvious build

The props interface is the contract. Type the props, export the component, done. Anything else is documentation, and documentation is a nice-to-have.

Why it breaks

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).

How it breaks in a real browser
  • 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 label optional 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 = 300 inside 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. onChange that 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.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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-labelledby and 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 label or aria-label is 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 document listener 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 keydown listeners on document, 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.

The same component, contract implicit and contract explicit
1// Implicit: compiles, renders, ships nameless.
2type IconButtonProps = {
3 icon: IconName
4 onClick: () => void
5 className?: string
6}
7
8// Explicit: the name is a type-level obligation, and the
9// 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 * Contract
16 * 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: IconName
26 onClick: () => void
27 disabled?: boolean
28 'aria-describedby'?: string
29 className?: string
30}

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.

accessibility specDisclosure (show/hide a section of content)Disclosure — the contract, written as a spec

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.

EnterToggles. Free from the native button; do not reimplement it.
SpaceToggles. Also free, and the reason a div with a click handler is not equivalent.
TabMoves to the next focusable element — into the panel when it is open, past it when it is closed.
Focus
  • 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.
Announces
  • 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.

Unwritten clauses, and what they cost
TriggerSymptomCauseResponse
A required accessible name was typed as optionalScreen reader announces "button"; automated audit flags it months laterThe type expressed shape, not obligationExpress the name as a union so a nameless call site fails to compile.
A default changed in a minor versionOne flow silently needs an extra interaction; no test failsDefaults are behaviour, and behaviour was never part of the declared surfaceTreat default changes as breaking; version and changelog them like any API change (Backward Compatibility: The Real Rules).
Component holds internal state while also accepting valueCaller-driven reset does not reset; the field keeps stale textTwo sources of truth with no stated precedencePick controlled or uncontrolled and enforce it in the type (Controlled vs Uncontrolled Inputs).
aria-describedby is not forwardedVisible error text is never announced with the fieldThe component owns the input element and drops caller ARIAForward the ARIA attributes explicitly; list them in the props type (Errors People Can Actually Perceive).
Every instance adds a document keydown listenerTyping slows as the page grows; nested instances both swallow EscapeUndeclared behaviour that scales with call sitesAttach at a single provider, or document it and give the caller an opt-out (Event Delegation).
Component fetches on mountA list of thirty renders thirty requests; nothing in the parent explains itBehaviour invisible in the contractTake 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-* and data-*. A component that swallows aria-describedby cannot 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-describedby and aria-invalid from 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

Failure modes
  • 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 value and 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 closeOnSelect from true to false; nothing breaks in CI and one flow in production now requires two clicks.
  • The greedy listener. Every instance attaches an outside-click handler on document that calls stopPropagation, 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.querySelector and the contract has now been violated from the outside.
  • The mitigation failing: you added a required label prop, and callers pass label="" to satisfy the type. Enforcement without a lint rule or a runtime warning is a suggestion.
What can arrive out of order
  • A controlled component whose onChange triggers an async update can receive keystrokes between the emit and the new value arriving. 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).
Security
  • Any prop that can reach innerHTML is 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 href unchecked means javascript: and data: 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 set onLoad, srcDoc, or style on 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).
Misreads
  • "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

How you would see this
  • 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.
Slow device, slow network, large data, old tab
  • 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).
What this costs
  • 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 defineProps from defineEmits so outputs are declared separately, Angular uses @Input/@Output decorators with an explicit EventEmitter, 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.

Domains that do not exist yet
  • 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.