Accessibility in design systems and SPA frameworks
Component contracts that encode accessibility, testing components in isolation with React, Vue and Angular, and preventing regressions across a design system.
Making accessibility part of the component API
A design system is where accessibility can be solved once. If the button component renders a real button and requires an accessible name, every consumer inherits the fix - and a component that makes the wrong markup impossible is worth more than a page of guidelines.
// React: the prop types encode the accessibility requirement
function IconButton({ label, icon, onClick, ...rest }) {
if (!label) {
// fail loudly in development rather than shipping an unnamed button
throw new Error("IconButton requires a label prop for its accessible name");
}
return (
<button type="button" onClick={onClick} aria-label={label} {...rest}>
<span aria-hidden="true">{icon}</span>
</button>
);
}
// a form field that cannot be rendered without a label
function TextField({ id, label, hint, error, ...rest }) {
const hintId = hint ? id + "-hint" : undefined;
const errorId = error ? id + "-error" : undefined;
return (
<div>
<label htmlFor={id}>{label}</label>
{hint && <p id={hintId}>{hint}</p>}
<input
id={id}
aria-invalid={error ? true : undefined}
aria-describedby={[hintId, errorId].filter(Boolean).join(" ") || undefined}
{...rest}
/>
{error && <p id={errorId} role="alert">{error}</p>}
</div>
);
}| Component | Contract the system should enforce | Failure it prevents |
|---|---|---|
| Button | Renders button; icon-only requires a label | Div buttons and unnamed icons |
| Link | Renders a with an href | Buttons that navigate and links that do not |
| TextField | Requires a label; wires hint and error ids | Placeholder-as-label, unlinked errors |
| Dialog | Traps focus, returns it, names itself | Focus lost on close |
| Tabs | Correct roles and a single tab stop | Every tab in the tab order |
| Toast | Announced politely, dismissible | Silent updates |
| Icon | Decorative by default | Unlabelled graphics |
Testing components in isolation
// React Testing Library: query the way a user and a screen reader would
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("the field is described by its hint and its error", async () => {
render(<TextField id="email" label="Email address" hint="We only email order updates." />);
const input = screen.getByRole("textbox", { name: /email address/i });
expect(input).toHaveAccessibleDescription(/order updates/i);
});
test("the icon button has an accessible name", () => {
render(<IconButton label="Search" icon={<SearchIcon />} />);
expect(screen.getByRole("button", { name: "Search" })).toBeInTheDocument();
});
test("the dialog returns focus to its trigger", async () => {
const user = userEvent.setup();
render(<DialogExample />);
await user.click(screen.getByRole("button", { name: /open/i }));
await user.keyboard("{Escape}");
expect(screen.getByRole("button", { name: /open/i })).toHaveFocus();
});- Query by role and accessible name. A test that uses a test id will pass while the component is inaccessible to a real user.
- Assert the accessible description, not just the label. Hint and error wiring is where fields usually break.
- Test the keyboard path explicitly: open, navigate, escape, and where focus lands afterwards.
- Run one automated scan (axe or similar) against each component in its own test, so a regression fails a unit test rather than an annual audit.
- Keep one integration test per composed screen. Component tests catch most regressions; the screen test catches the ones that come from composition.
// Vue: the same contract, enforced with a required prop
defineProps({
label: { type: String, required: true },
hint: { type: String, default: "" },
error: { type: String, default: "" }
});
// Angular: a required input and a template that cannot omit the association
@Component({ selector: "app-text-field", template: `
<label [attr.for]="id">{{ label }}</label>
<input [id]="id" [attr.aria-describedby]="describedBy" />
` })
export class TextFieldComponent {
@Input({ required: true }) label!: string;
@Input() id = "field-" + Math.random().toString(36).slice(2, 8);
get describedBy() { return this.hint ? this.id + "-hint" : null; }
}Preventing regressions
- Put the accessibility checks in CI, so a pull request that breaks a component fails before review.
- Publish the keyboard and screen reader behaviour of each component in its documentation page, alongside the props.
- Provide the accessible example as the default in the docs. Copy-paste is how components get used, so the default example is the real API.
- Version accessibility fixes as carefully as visual changes. A fix that changes focus behaviour is a breaking change for consumers.
- Keep a short list of known gaps with owners. Pretending a component is complete when it is not is how gaps survive for years.
- Re-audit the whole system on a schedule, not once at launch. New components are added constantly.
# a CI job that fails a pull request on an accessibility regression
- name: Component accessibility tests
run: npm run test -- --coverage=false a11y
- name: Scan built pages
run: npx axe ./dist --exit
continue-on-error: false⚠️
A component library is not a guarantee. If consumers can pass arbitrary children into a button, or override the rendered element with a prop, they can still produce inaccessible output - and they will. Keep the escape hatches narrow, document the consequences, and test the composed screens as well as the components.
FAQ
Should accessibility tests be unit tests or end-to-end?
Both, weighted towards component tests. They run in milliseconds and point straight at the broken component. A small end-to-end set catches composition and routing problems.
What about third-party components?
Treat them as untested until you have tested them. Wrap them in your own component so you can fix, replace or patch the behaviour without touching every call site.
Related
Live regions and dynamic content announcements Auditing, remediation and compliance reporting
Last refreshed 2026-09-18.