Design-System Accessibility Defaults
Six components accounted for 88 of the 214 findings in the sweep described in Automated Remediation & Accessibility Fixing Patterns, which this guide is part of — and every one of those 88 was fixed twice, once by the codemod that patched the call site and again three sprints later when a new feature added another instance. Work at the component instead and the arithmetic inverts: one review, one changed file, one release, and the rule id stops appearing in reports for every product that consumes the package. This guide covers what has to be true of a component for that to hold, and the five enforcement layers that keep it true after the engineer who designed it has moved teams.
Problem Statement
A design system that merely documents the accessible usage is a style guide with extra steps. Documentation is advisory, and advisory controls decay at a predictable rate: the prop that “should” be passed is optional, so it gets omitted under deadline; the class that “should not” be overridden is a plain CSS selector, so a consumer’s reset wins on specificity; the colour that “should” only sit on a light surface is exported as a raw hex, so it ends up on a dark one. Each of those decays produces a violation in an application repository, where the finding is expensive: a scanner reports it against a route, a triage process assigns it to a product team, and that team fixes it locally without touching the component that emitted it.
The specific technical problem is that a component library has three separate escape routes and most teams only close one. Types are the route most often closed, and they only cover consumers that run tsc. CSS is almost never closed, so a focus indicator that the design system ships correctly can be removed by any consumer with a *:focus { outline: none } in a legacy stylesheet — a WCAG 2.2 SC 2.4.7 (Focus Visible) failure the component library’s own tests will never see. Colour is closed least of all: exporting a palette rather than a set of validated pairings guarantees that some consumer will eventually combine two tokens whose contrast ratio is 3.1:1 and ship a WCAG 2.2 SC 1.4.3 (Contrast Minimum) failure that no type checker has an opinion about.
The failure classes worth designing against are therefore not “the developer forgot” but “the component permitted”. A Button that can render a <div> will render a <div>. An IconButton whose aria-label is optional will ship nameless. A Modal that focuses nothing on open leaves the keyboard user at the top of the document, and one that restores nothing on close leaves them at the top of the page they were halfway down. Each of those is a component-level defect with an application-level report, and the report is the wrong place to fix it.
Key implementation targets:
- A written contract of invariants — native semantics, required accessible name, undefeatable focus indicator, validated colour pairing, focus trap and restore — with one named proof mechanism per invariant.
- An audit that fails the library’s build when a component is exported without being mapped to that contract, so new components cannot arrive unaudited.
- Prop shapes where the accessible path is the default and the inaccessible path is an escape hatch with a name nobody types by accident and a reason string a reviewer can read.
- A component test per invariant that drives the component the way a keyboard user does, rather than asserting on rendered markup.
- A token pipeline that computes contrast at build time and only exports pairings that clear 4.5:1, so an out-of-contract combination is not expressible in consumer code.
- A release gate on the component library itself that runs all of the above before
npm publish, with escape-hatch usage counted and reported per release.
Prerequisites
1. Audit the Current Components Against a Contract
Start by writing down what “accessible by default” means for this library, in enough detail that a reviewer can check a pull request against it without arguing. Five invariants cover the overwhelming majority of component-level findings, and each one has exactly one mechanism that can prove it — which matters, because an invariant with no named proof is an aspiration. A control renders a native interactive element, so keyboard activation, focus order and the accessibility tree come from the platform rather than from ARIA reconstruction. A control with no text child cannot be constructed without an accessible name. A visible focus indicator survives any consumer stylesheet. Every exported colour pairing clears WCAG 2.2 SC 1.4.3. A modal traps focus while open and returns it to the trigger on close, satisfying WCAG 2.2 SC 2.4.3 (Focus Order) across the open-and-close cycle.
Put the contract in the package as data rather than prose, because data can be checked. The file below declares the invariants once, then maps each exported component to the subset that applies to it — Skeleton genuinely has no interactive obligations, and saying so explicitly is different from forgetting to consider it. The proof field is the important column: it names the layer that is allowed to claim the invariant holds, which stops the same guarantee being asserted three times and verified zero.
# packages/ui/a11y-contract.yml — invariants, and the one layer that proves each
version: 3
invariants:
- id: native-semantics
text: Interactive components render a native interactive element
proof: component-test
criterion: SC 4.1.2 Name, Role, Value
- id: required-name
text: A control with no text child cannot be constructed without a name
proof: typecheck
criterion: SC 4.1.2 Name, Role, Value
- id: focus-visible
text: A visible focus indicator survives any consumer stylesheet
proof: story-sweep
criterion: SC 2.4.7 Focus Visible
- id: contrast-pairs
text: Every exported colour pairing clears 4.5:1 for body text
proof: token-build
criterion: SC 1.4.3 Contrast Minimum
- id: dialog-focus
text: An overlay traps focus while open and restores it on close
proof: component-test
criterion: SC 2.4.3 Focus Order
components:
Button: [native-semantics, required-name, focus-visible, contrast-pairs]
IconButton: [native-semantics, required-name, focus-visible, contrast-pairs]
Dialog: [focus-visible, dialog-focus, contrast-pairs]
Field: [required-name, focus-visible, contrast-pairs]
Menu: [native-semantics, focus-visible, dialog-focus]
Tabs: [native-semantics, focus-visible]
Table: [contrast-pairs]
Skeleton: []
The audit is then a fifty-line script that reads the package’s public barrel file, resolves every exported component name, and cross-references it with the contract. Two outcomes fail the build: an export that appears in no contract entry (a component arrived without anyone deciding what it owes), and a contract entry for a component that is no longer exported (dead contract rows hide real gaps). Run it on every pull request in the library, not nightly — the whole point is that the gap is visible while the component is still being written.
// packages/ui/scripts/audit-contract.mjs — usage: node scripts/audit-contract.mjs
import { readFileSync } from 'node:fs';
import { parse } from 'yaml';
import { Project, SyntaxKind } from 'ts-morph';
const contract = parse(readFileSync('a11y-contract.yml', 'utf8'));
const known = new Set(contract.invariants.map((i) => i.id));
const project = new Project({ tsConfigFilePath: 'tsconfig.json' });
const barrel = project.getSourceFileOrThrow('src/index.ts');
// Only components are in scope: exported functions whose name is PascalCase.
const exported = [...barrel.getExportedDeclarations().entries()]
.filter(([name, decls]) =>
/^[A-Z]/.test(name) &&
decls.some((d) =>
d.getKind() === SyntaxKind.FunctionDeclaration ||
d.getKind() === SyntaxKind.VariableDeclaration))
.map(([name]) => name);
const problems = [];
for (const name of exported) {
const assigned = contract.components[name];
if (assigned === undefined) {
problems.push(`${name}: exported but absent from a11y-contract.yml`);
continue;
}
for (const id of assigned) {
if (!known.has(id)) problems.push(`${name}: unknown invariant "${id}"`);
}
}
for (const name of Object.keys(contract.components)) {
if (!exported.includes(name)) {
problems.push(`${name}: in the contract but no longer exported`);
}
}
const covered = exported.filter((n) => (contract.components[n] ?? []).length > 0);
console.log(`contract v${contract.version}: ${covered.length}/${exported.length} ` +
`exports carry at least one invariant`);
for (const problem of problems) console.error(` ${problem}`);
process.exit(problems.length === 0 ? 0 : 1); // non-zero blocks the library build
The first run of this audit on an established library is uncomfortable and useful. A typical result is that half the exports are mapped, a quarter are genuinely presentational, and a quarter are interactive components nobody has thought about — usually the ones added in a hurry for a single product surface, which is also where the findings are. Order the remediation work by the bar chart above rather than by the audit’s own alphabetical output, and feed the resulting call-site list into the report-driven transforms in codemod-driven accessibility fixes once the component itself is fixed.
2. Make the Accessible Path the Default and the Escape Hatch Explicit
Defaults are where most of the leverage is, because the default is what gets used. Three rules make a component’s default accessible without making it inflexible. First, the accessible construction requires no extra props at all: <Button onPress={save}>Save</Button> is already correct, and there is no role, tabIndex or type for the caller to get wrong. Second, the inaccessible construction is not reachable through omission — a missing accessible name is a compile error rather than a silent empty string, which is the mechanism worked through in enforcing accessible component defaults in a design system. Third, when an escape hatch is genuinely needed, it is named so that using it is a visible decision.
Escape-hatch naming is not cosmetic. A boolean called noFocusRing will be passed by a developer who wants a design detail and has no idea what it costs; a prop called unsafelyRemoveFocusRing whose type is { reason: string } cannot be passed without writing a sentence, and that sentence lands in the diff where a reviewer reads it. The same prop is also trivially greppable, which turns “how much of our product has opted out of focus indicators” from a research project into one command. Keep the hatch count in the release notes; a number that only ever goes up is the signal that a component’s default is wrong rather than that its consumers are careless.
The focus ring deserves the most attention because it is the invariant most often broken from outside the library. A ring declared in a plain rule loses to any consumer selector with equal or higher specificity, so declare it inside a named cascade layer that the package documents as ordered last, and pair it with a forced-colors fallback so a Windows high-contrast user still sees an indicator when the authored colour is discarded. The component below combines all of it: native semantics, a platform focus trap via showModal, explicit focus restoration, and a ring the consumer cannot silently drop.
// packages/ui/src/Dialog.tsx — the trap and the restore are not opt-in
import * as React from 'react';
type DialogProps = {
open: boolean;
onClose: () => void;
/** Labels the dialog for SC 4.1.2; rendered as the visible heading too. */
title: string;
children: React.ReactNode;
};
export function Dialog({ open, onClose, title, children }: DialogProps) {
const ref = React.useRef<HTMLDialogElement>(null);
// The element that had focus when the dialog opened. Captured on open so a
// re-render while open cannot overwrite it with something inside the dialog.
const opener = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
const node = ref.current;
if (!node) return;
if (open) {
opener.current = document.activeElement as HTMLElement | null;
// showModal() gives the platform focus trap and the top-layer backdrop;
// it also makes Escape close the dialog without a keydown listener.
if (!node.open) node.showModal();
} else if (node.open) {
node.close();
}
}, [open]);
return (
<dialog
ref={ref}
aria-labelledby="acme-dialog-title"
onCancel={(event) => { event.preventDefault(); onClose(); }}
onClose={() => {
// Restore before the consumer's onClose runs, so a caller that moves
// focus deliberately still wins.
opener.current?.focus({ preventScroll: true });
onClose();
}}
>
<h2 id="acme-dialog-title">{title}</h2>
{children}
</dialog>
);
}
/* packages/ui/src/focus.css — imported by the package entry point */
/* Consumers are documented to place @layer acme.reset before acme.focus, and
the package emits this order itself, so a later consumer rule at equal
specificity still loses to the ring. */
@layer acme.reset, acme.base, acme.focus;
@layer acme.focus {
.acme-focusable:focus-visible {
outline: 2px solid var(--acme-focus-ring);
outline-offset: 2px;
/* A second ring in the opposite tone keeps the indicator visible on both
light and dark surfaces without measuring the surface at run time. */
box-shadow: 0 0 0 4px var(--acme-focus-halo);
}
@media (forced-colors: active) {
.acme-focusable:focus-visible {
/* Authored colours are discarded in forced-colors mode; the system
keyword is the only thing guaranteed to render. */
outline: 3px solid Highlight;
box-shadow: none;
}
}
}
3. Component-Level Accessibility Tests
A component test is the only layer that can prove behaviour, and behaviour is where the interesting invariants live. A scanner run against rendered markup can tell you a <dialog> exists; only a test that presses Escape can tell you focus came back to the button that opened it. Write one test per contract invariant, name the test after the invariant id so a failure maps to a row in the contract file, and drive the component through its states with keyboard and pointer interactions rather than by setting props directly — a test that calls a handler proves the handler works, not that a user can reach it.
Run these in a real browser. HTMLDialogElement.showModal, :focus-visible matching, inert, and the accessibility tree that axe-core walks are all either missing or approximated in jsdom, and an approximated focus trap is worse than no test because it reports success. Vitest’s browser mode or a Playwright component-test runner both work; the important property is that document.activeElement and computed styles are the browser’s.
// packages/ui/src/Dialog.a11y.test.tsx — one test per contract invariant id
import { expect, test } from 'vitest';
import { render } from 'vitest-browser-react';
import { userEvent } from '@vitest/browser/context';
import axe from 'axe-core';
import * as React from 'react';
import { Dialog } from './Dialog';
function Harness() {
const [open, setOpen] = React.useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>Edit profile</button>
<Dialog open={open} onClose={() => setOpen(false)} title="Edit profile">
<button type="button">Save</button>
</Dialog>
</>
);
}
test('dialog-focus: focus moves in on open and returns to the trigger', async () => {
const screen = render(<Harness />);
const trigger = screen.getByRole('button', { name: 'Edit profile' }).element();
trigger.focus();
await userEvent.click(trigger);
const dialog = screen.getByRole('dialog').element();
// showModal moves focus into the dialog; assert containment, not identity,
// because the browser picks the first focusable descendant.
expect(dialog.contains(document.activeElement)).toBe(true);
await userEvent.keyboard('{Escape}');
expect(document.activeElement).toBe(trigger); // SC 2.4.3 across the cycle
});
test('native-semantics: the overlay exposes role dialog and a modal flag', () => {
const screen = render(<Harness />);
screen.getByRole('button', { name: 'Edit profile' }).element().click();
const dialog = screen.getByRole('dialog').element();
expect(dialog.tagName).toBe('DIALOG');
expect(dialog.getAttribute('aria-modal') ?? 'implicit').not.toBe('false');
});
test('no axe violations in the open state', async () => {
const screen = render(<Harness />);
screen.getByRole('button', { name: 'Edit profile' }).element().click();
const results = await axe.run(document.body, {
// Page-level rules have no meaning in a component harness; the story
// sweep and the application scan own those.
rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } },
});
expect(results.violations.map((v) => v.id)).toEqual([]);
});
Two habits keep this suite honest over time. Assert on roles and accessible names, never on class names or DOM structure, so a refactor that preserves the accessibility contract does not produce a red build and a reflexive test edit. And write the failing case first: temporarily change <dialog> to a <div role="dialog"> and confirm the focus-restoration test fails, because a behavioural test that has never failed is not yet evidence of anything. That discipline is the component-level version of the per-fix assertions in regression prevention after fixes, and the two suites answer different questions: this one asks whether the component still meets its contract, that one asks whether a specific historical violation has returned.
4. Contrast-Safe Tokens
A palette is not an interface. Exporting brand.400 and surface.100 as independent tokens hands consumers a combinatorial space in which most combinations fail WCAG 2.2 SC 1.4.3, and no amount of documentation stops someone picking one of them at 4:45pm. Export pairings instead: a token that carries both a background and a foreground, validated at build time, so the only way to colour a surface is to choose a pair that has already been measured. The raw ramp stays internal to the package.
Validation is arithmetic, not judgment, which makes it perfect for a build step. Compute relative luminance for both colours, take the ratio, and compare against the threshold that applies to the pair’s declared use: 4.5:1 for body text, 3:1 for text at 24px or 19px bold and above (WCAG 2.2 SC 1.4.3 permits the lower bar for large text), and 3:1 for the non-text boundaries and state indicators covered by WCAG 2.2 SC 1.4.11 (Non-text Contrast). Declare the use alongside the pair so the script knows which threshold to apply rather than guessing from the token name.
The build script below is the whole mechanism. It reads the internal ramp and a list of declared pairings, computes each ratio, writes the passing pairs to the public token file, and exits non-zero on any declared pair that fails — a designer who adds a pairing that does not clear its threshold learns about it from a red build rather than from an audit eighteen months later.
// packages/ui/scripts/build-tokens.mjs — usage: node scripts/build-tokens.mjs
import { readFileSync, writeFileSync } from 'node:fs';
const { ramp, pairs } = JSON.parse(readFileSync('tokens/source.json', 'utf8'));
// SC 1.4.3 body text, SC 1.4.3 large text, SC 1.4.11 non-text boundaries.
const THRESHOLD = { body: 4.5, large: 3, nonText: 3 };
const channel = (v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
function luminance(hex) {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
function ratio(a, b) {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
const exported = {};
const failures = [];
for (const pair of pairs) {
const fg = ramp[pair.ink];
const bg = ramp[pair.surface];
const measured = ratio(fg, bg);
const required = THRESHOLD[pair.use];
// Round down to one decimal so a 4.497 never reads as a passing 4.5.
const shown = Math.floor(measured * 10) / 10;
if (shown < required) {
failures.push(`${pair.name}: ${shown}:1 < ${required}:1 (${pair.use})`);
continue; // withheld: never reaches the public token file
}
exported[pair.name] = { color: fg, background: bg, ratio: shown, use: pair.use };
}
writeFileSync('src/tokens.generated.ts',
'// GENERATED by scripts/build-tokens.mjs — do not edit.\n' +
`export const pairs = ${JSON.stringify(exported, null, 2)} as const;\n` +
'export type PairToken = keyof typeof pairs;\n');
console.log(`exported ${Object.keys(exported).length} pairs, withheld ${failures.length}`);
for (const failure of failures) console.error(` ${failure}`);
process.exit(failures.length === 0 ? 0 : 1);
Because the generated file exports PairToken as a union of the passing names, a component prop typed as PairToken can only receive a validated pairing, and a consumer who reaches for a withheld combination gets a compile error naming the tokens that do exist. That is the whole trick: the contrast rule stops being a review comment and becomes a property of the type system. Note also that the axe color-contrast rule and this script measure the same thing but see different inputs — axe measures what rendered, including opacity, overlapping elements and images behind text, so keep the axe-core configuration rule enabled in the story sweep even after the token build is green.
5. The Release Gate for the Component Library
The component library needs its own quality gate, distinct from the gates on the applications that consume it, because the failure modes are different: an application ships a bad page, a library ships a bad page to everyone. Order the stages by cost so the cheap ones fail first — the contract audit and the token build are seconds, the type check is tens of seconds, the component tests are a minute or two, and the story sweep across every component and state is the expensive one. A developer who broke a type should not wait four minutes to find out.
# .github/workflows/ui-release-gate.yml
name: ui-release-gate
on:
pull_request:
paths: ['packages/ui/**']
push:
branches: [main]
paths: ['packages/ui/**']
jobs:
contract:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Contract audit
run: node scripts/audit-contract.mjs # exits 1 on an unmapped export
- name: Token build
run: node scripts/build-tokens.mjs # exits 1 on a pair below threshold
- name: Type check
run: npx tsc --noEmit # required names, valid pair tokens
- name: Escape-hatch census
# A count, not a ban: the number is compared against the committed
# baseline and only a rise fails the job.
run: |
COUNT=$(grep -rIo 'unsafelyRemoveFocusRing' src | wc -l | tr -d ' ')
BASE=$(cat .a11y-escape-baseline)
echo "escape hatches: $COUNT (baseline $BASE)"
test "$COUNT" -le "$BASE"
- name: Component accessibility tests
run: npx vitest run --browser.headless # real browser: showModal, focus
- name: Build Storybook
run: npm run build-storybook -- --quiet
- name: Story sweep
run: npx test-storybook --ci --maxWorkers=2 --junit
- name: Upload reports
if: always() # keep artifacts on red too
uses: actions/upload-artifact@v4
with:
name: ui-a11y-reports
path: |
packages/ui/junit.xml
packages/ui/src/tokens.generated.ts
retention-days: 30
Make every step a required status check on the library package, and add one release-notes discipline that costs nothing: when a contract invariant changes, bump the version field in a11y-contract.yml and name it in the changelog. Consumers then have a single number to compare — “we are on contract v2, the package is on v3” — which is a far more useful upgrade signal than a semver range, because it says what got stricter rather than only that something did.
Pipeline Integration
The library’s gate and the applications’ gates meet at the version bump. When a consumer upgrades @acme/ui, its own accessibility scan should report fewer findings, and that delta is the number worth publishing: the pull request that bumps the dependency carries a comment showing rule ids that disappeared and the count per route. Wire it by running the application’s scan twice in the upgrade branch — once at the old resolved version, once at the new — and diffing fingerprints rather than counts, exactly as the verification step in the parent section does. An upgrade that removes 41 button-name findings is then self-evidently worth merging, and an upgrade that removes 38 and introduces 3 is caught before anyone celebrates.
Downstream, a shrinking finding count changes the numbers the application pipelines gate on. Baselines and budgets that were sized around component-emitted violations become stale immediately, and a stale budget is a ratchet that never tightens; re-baseline in the same pull request as the upgrade and drop the budget by the number of findings the upgrade cleared, following the ratchet mechanics in progressive threshold management. Teams that skip this step get the accessibility improvement and keep the old tolerance, which means the next regression fits inside the budget and merges silently.
The story sweep also produces an artifact worth routing beyond the library. Its JUnit output names every component and state that was scanned, which is the closest thing a design system has to a coverage report for accessibility: 14 components, 61 stories, 92 scanned states, 0 serious findings. Publish that alongside the release notes and it becomes the evidence an accessibility conformance statement needs, without anyone re-testing components by hand. The runner configuration, per-story rule overrides and the state coverage that a play function adds are covered in auditing a component library with Storybook and axe.
Troubleshooting and Flaky-Test Mitigation
showModal throws or focus assertions fail under jsdom. HTMLDialogElement.showModal, the top layer, inert and :focus-visible are either unimplemented or approximated outside a real browser, so a focus-trap test in jsdom either throws or passes for the wrong reason. Run the component suite in browser mode. If a project cannot adopt browser mode immediately, mark the focus tests as the browser-only project in the Vitest workspace file rather than polyfilling — a polyfilled trap tests the polyfill.
Contrast findings that appear and disappear between runs. Almost always a web-font swap: the fallback font renders at a different weight, axe samples the text colour mid-swap, and the ratio lands on the other side of 4.5:1. Load fonts with font-display: block in the test build, or preload the font files and wait on document.fonts.ready before the scan. A theme that initialises from prefers-color-scheme produces the same symptom on runners with different defaults, so pin the theme explicitly in the test harness.
A focus-ring story passes locally and fails in CI. :focus-visible matches on keyboard focus and not on programmatic focus in some engines, so a story that calls element.focus() may show no ring in the runner. Drive focus with a real Tab key press through the runner’s keyboard API, or assert on the ring by focusing with focusVisible: true where the engine supports it. The failure is a harness artifact, not a component defect, and disabling the rule to make it green is how the invariant quietly dies.
The escape-hatch census fails on an unrelated pull request. A grep count over source is sensitive to comments and test fixtures that mention the prop name. Exclude test files and stories from the census path, or match the prop in JSX position only. Keep the baseline file in the repository so the diff shows who raised the count and in which pull request — the audit trail is the point, and a census that fails mysteriously gets deleted within a month.
Component tests pass but consumer applications still report the finding. The consumer is not using the component. Resolve the failing selector back to source with the build-stamped source location, and expect one of three answers: hand-rolled markup that predates the component, a copy of the component vendored into the application, or a wrapper that spreads props onto a native element and overrides the ones that matter. All three are call-site problems with a component-shaped fix, and the third is the one type-level guards can be extended to catch.
Common Pitfalls
- Optional accessibility props. An optional
aria-labelon an icon-only control is an unlabelled button on a schedule; the only question is which sprint. - Documenting the invariant instead of enforcing it. A guideline in a Storybook doc page has no exit code, and anything without an exit code is advice.
- Exporting a palette rather than validated pairings. Sixteen colours produce far more failing combinations than passing ones, and consumers will find them.
- A focus ring in an unlayered rule. Any consumer reset at equal specificity wins, and the component library gets blamed for a WCAG 2.2 SC 2.4.7 failure it did not cause.
- Treating the component test suite as a substitute for the story sweep. Tests prove behaviour in a bare harness; the sweep proves the rendered result with real styles, portals and themes applied.
- Shipping a stricter type as a patch release. A required prop is a breaking change even when it fixes a bug; ship it as a major with the old shape deprecated, and let
tscenumerate the call sites. - No escape hatch at all. A contract with no legitimate exit gets bypassed by copying the component into the application, where it is invisible to every layer described here.
FAQ
Is a design system enough on its own to stop these violations? No, and treating it that way is how the effort loses credibility. A component library covers markup authored through it; applications still contain legacy pages, third-party embeds, marketing content and one-off layouts that no primitive touches. What the library changes is the distribution of findings: the repeated, mechanical, high-volume rule ids largely disappear, and what remains is the smaller set of context-dependent failures that needed a human anyway.
How do we stop a stricter component type from breaking every consumer at once? Ship the strictness in two releases. In the first, add the new shape alongside the old one and mark the old one deprecated, so nothing breaks and every call site produces a deprecation warning that can be counted. Run the type check across consumer repositories to get the exact worklist, migrate the call sites — mechanically where the edit is an attribute rewrite — and remove the deprecated shape in the second release. The compiler writes the migration plan; nobody has to grep for usages.
Where should the accessibility tests live if the design system is in a monorepo with its own applications? Component-level tests belong in the package that owns the component, so they run when that package changes and gate its release. Application-level scans stay in the application, because they test composition — landmark structure, heading order, page titles — which no component can guarantee. The mistake to avoid is a single suite at the repository root that scans both: it turns every component change into a full application run, and the feedback loop gets slow enough that people stop reading it.
What about components whose accessibility depends on how they are composed?
Some contracts cannot be closed at the component level. Tabs can guarantee roving tabindex and arrow-key navigation, but it cannot guarantee that a consumer put a meaningful label on each tab; a Table can wire scope attributes but cannot know whether a column of numbers has a header that means anything. For these, encode what is provable in the component, and encode the composition rule as a custom scanner rule that runs against the application instead — the split is described in the guides on component-specific rule authoring.
How is this different from just turning on more lint rules?
Lint operates on source text, so it sees what is written and not what renders. It cannot resolve a spread prop, cannot know that a Button compiles to a <div> three files away, and has no access to computed colour or focus behaviour. Lint is a cheap, fast layer that belongs in the stack, but the guarantees in this guide come from the type system (what can be constructed), the browser (what actually happens) and the token build (what colours exist) — three things a linter cannot observe.
Related
- Automated Remediation & Accessibility Fixing Patterns — the parent section covering detection-to-fix routing, verification and debt triage.
- Enforcing Accessible Component Defaults in a Design System — the discriminated-union prop types and the local ESLint rules that reject a bad call site before it runs.
- Auditing a Component Library with Storybook and axe — the story-by-story sweep that checks every component and state in isolation.
- Regression Prevention After Fixes — turning each individual fix into a permanent assertion once the component work is done.
- Codemod-Driven Accessibility Fixes — migrating the call sites a stricter component type just made into compile errors.