Enforcing Accessible Component Defaults in a Design System
The cheapest accessibility feedback a developer can get is a red squiggle under the call site they are currently typing, and the only two tools that deliver it are the compiler and the linter. This guide is part of Design-System Accessibility Defaults, and it covers exactly that layer: prop types shaped so an unnamed icon-only control cannot be constructed, two local lint rules that reject the markup patterns a type cannot reach, and a deliberate escape hatch that requires a written justification and can be counted with one command. Nothing here runs a browser; everything here fails before the code has ever rendered.
Root Cause
A violation report is a statement about a rendered page, which means it arrives after the build, after the test suite, and usually after the merge. The developer who wrote the offending line has moved on to another ticket, the finding is attributed to a route rather than to a component, and the fix is scheduled instead of typed. That delay is not a process problem to be optimised — it is inherent in checking output rather than input. The compiler checks input, and it checks it in the editor.
The reason a design system does not get this for free is that most prop types are written for convenience rather than for correctness. 'aria-label'?: string is the natural thing to write: it is optional because most buttons do not need it, and it is a string because that is what the attribute takes. The type is also a promise that omitting it is fine, and the compiler will keep that promise on every one of the hundred call sites where it is not fine. The same holds in the other direction — a type that requires aria-label on every button invites redundant labels over visible text, and when the label and the text diverge the result is a WCAG 2.2 SC 2.5.3 (Label in Name) failure that no scanner will flag either, because both strings exist and both are non-empty.
Lint fills a different gap and cannot fill this one. eslint-plugin-jsx-a11y reads source text through an AST with no type information, so it cannot tell that the Button imported three files away compiles down to a <div>, cannot resolve what is inside a {...props} spread, and cannot know that tone="danger" maps to a colour pairing at 3.9:1. What lint is uniquely good at is banning a syntactic shape everywhere it appears: a <div> carrying an onClick, an outline: none inside the package that owns focus indicators. Used for what each is good at, the two layers close the two holes that matter, and the residue — a label that exists but says the wrong thing — is handed to a runtime check rather than pretended away.
Configuration
The prop type is a discriminated union on a boolean, which keeps the call site short — iconOnly reads as a flag rather than as a mode string — and gives the compiler a single field to narrow on. Two never fields do the real work: children?: never on the icon branch, because a text child would contradict the discriminant, and 'aria-label'?: never on the labelled branch, because a second name over visible text is the SC 2.5.3 hazard. Both are only airtight with exactOptionalPropertyTypes: true; without it, aria-label={undefined} satisfies ?: never and the branch is no longer closed.
// packages/ui/src/Button.tsx
import * as React from 'react';
import type { PairToken } from './tokens.generated';
type Shared = Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
// These four are the component's decisions, not the caller's.
'aria-label' | 'aria-labelledby' | 'children' | 'type'
> & {
onPress: () => void;
tone?: Extract<PairToken, 'action.primary' | 'action.danger'>;
/** Documented, greppable opt-out. A reason string cannot be typed by accident. */
unsafelyRemoveFocusRing?: { reason: string };
};
// Icon-only: nothing in the subtree renders text, so a name must be supplied.
type IconOnly = Shared & {
iconOnly: true;
icon: React.ReactElement;
'aria-label': string; // required — WCAG 2.2 SC 4.1.2 (Name, Role, Value)
children?: never; // a text child would contradict iconOnly
};
// Labelled: the accessible name comes from the children, and only from them.
type Labelled = Shared & {
iconOnly?: false;
icon?: React.ReactElement; // decorative; hidden from the tree below
children: React.ReactNode;
'aria-label'?: never; // forbidden — a divergent name fails SC 2.5.3
};
export type ButtonProps = IconOnly | Labelled;
export function Button(props: ButtonProps) {
const { onPress, tone = 'action.primary', unsafelyRemoveFocusRing, ...rest } = props;
const className = [
'acme-button',
unsafelyRemoveFocusRing ? undefined : 'acme-focusable',
].filter(Boolean).join(' ');
if (props.iconOnly) {
const { icon, iconOnly, ...attrs } = rest as Omit<IconOnly, keyof Shared>;
return (
<button type="button" className={className} data-tone={tone}
onClick={onPress} aria-label={props['aria-label']}>
{/* The glyph is decoration: the name is on the button, not the svg. */}
<span aria-hidden="true">{icon}</span>
</button>
);
}
return (
<button type="button" className={className} data-tone={tone} onClick={onPress}>
{props.icon ? <span aria-hidden="true">{props.icon}</span> : null}
{props.children}
</button>
);
}
The lint layer is a local plugin rather than a configuration of an existing one, because both rules are about this repository’s conventions. The first forbids an interaction handler on a non-interactive element, which is the pattern that bypasses the component entirely; the second forbids removing an outline anywhere inside the design-system package, which is the pattern that guts the focus indicator the package is responsible for. Both share one escape mechanism: a comment on the line above matching a11y-escape: followed by a real sentence, checked for length so a11y-escape: ok does not count.
// tools/eslint-plugin-a11y-local/index.js
const NON_INTERACTIVE = new Set(['div', 'span', 'li', 'td', 'section', 'article', 'p']);
const HANDLERS = new Set(['onClick', 'onKeyDown', 'onKeyUp', 'onMouseDown']);
const OUTLINE_OFF = /outline\s*:\s*(none|0)(\s|;|$)/i;
// A justification must be a sentence: the marker plus at least 20 more characters.
const ESCAPE = /a11y-escape:\s*\S.{19,}/;
function justified(context, node) {
const source = context.sourceCode ?? context.getSourceCode();
return source.getCommentsBefore(node).some((c) => ESCAPE.test(c.value));
}
const noNonInteractiveHandler = {
meta: {
type: 'problem',
docs: { description: 'Interaction handlers belong on interactive elements' },
messages: {
useButton:
'A <{{tag}}> with {{handler}} is not focusable or keyboard-operable. ' +
'Use <Button> from @acme/ui, or justify with an a11y-escape comment.',
},
schema: [],
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.type !== 'JSXIdentifier') return;
const tag = node.name.name;
if (!NON_INTERACTIVE.has(tag)) return;
const attr = node.attributes.find(
(a) => a.type === 'JSXAttribute' && HANDLERS.has(a.name.name),
);
if (!attr) return;
// A role + tabIndex pair is a legitimate hand-rolled widget; leave it
// to jsx-a11y, which already checks that combination properly.
const hasRole = node.attributes.some(
(a) => a.type === 'JSXAttribute' && a.name.name === 'role',
);
if (hasRole) return;
if (justified(context, node.parent)) return;
context.report({
node: attr,
messageId: 'useButton',
data: { tag, handler: attr.name.name },
});
},
};
},
};
const noOutlineNone = {
meta: {
type: 'problem',
docs: { description: 'The design system owns the focus indicator' },
messages: {
keepOutline:
'Removing the outline breaks WCAG 2.2 SC 2.4.7 for every consumer. ' +
'Restyle :focus-visible in the acme.focus layer instead.',
},
schema: [],
},
create(context) {
return {
// styled-components / css`` template literals
TemplateElement(node) {
if (!OUTLINE_OFF.test(node.value.raw)) return;
if (justified(context, node.parent)) return;
context.report({ node, messageId: 'keepOutline' });
},
// style={{ outline: 'none' }} and CSS-in-JS objects
Property(node) {
const key = node.key.name ?? node.key.value;
if (key !== 'outline' && key !== 'outlineStyle') return;
const value = node.value.value;
if (value !== 'none' && value !== 0 && value !== '0') return;
if (justified(context, node)) return;
context.report({ node, messageId: 'keepOutline' });
},
};
},
};
export default {
meta: { name: 'eslint-plugin-a11y-local', version: '1.0.0' },
rules: {
'no-non-interactive-handler': noNonInteractiveHandler,
'no-outline-none': noOutlineNone,
},
};
Wire the two rules with different scopes, because they answer to different owners. The handler rule belongs everywhere, including application code, since that is where hand-rolled controls appear. The outline rule belongs only inside the package that ships the indicator — applications may legitimately restyle focus for their own non-design-system elements, and firing there produces failures the design-system team cannot fix.
// eslint.config.js
import a11yLocal from './tools/eslint-plugin-a11y-local/index.js';
export default [
{
files: ['**/*.{jsx,tsx}'],
plugins: { 'a11y-local': a11yLocal },
rules: { 'a11y-local/no-non-interactive-handler': 'error' },
},
{
// Scoped: only the package that owns the focus ring may not remove it.
files: ['packages/ui/src/**/*.{ts,tsx}'],
plugins: { 'a11y-local': a11yLocal },
rules: { 'a11y-local/no-outline-none': 'error' },
},
];
Validation
Both layers need a test that fails when the guard stops working, and for the compiler that test is inverted: @ts-expect-error turns a missing error into a build failure, so the day someone loosens the union to unblock a feature, CI reports an unused directive instead of quietly accepting the new hole. Keep the file out of the published build with a tsconfig exclusion, and keep it next to the component so it is obvious what it guards.
// packages/ui/src/Button.type-test.tsx — compiled by tsc, never bundled
import * as React from 'react';
import { Button } from './Button';
const noop = () => {};
const Trash = () => <TrashGlyph aria-hidden="true" />;
// Each directive is an assertion. If the error stops occurring, tsc fails with
// "Unused '@ts-expect-error' directive" — the guard is guarded.
// @ts-expect-error iconOnly requires an accessible name
export const a = <Button iconOnly icon={<Trash />} onPress={noop} />;
// @ts-expect-error a labelled button may not also carry aria-label
export const b = <Button aria-label="Save" onPress={noop}>Save</Button>;
// @ts-expect-error an icon-only button may not carry text children
export const c = <Button iconOnly icon={<Trash />} aria-label="Delete" onPress={noop}>x</Button>;
// @ts-expect-error the opt-out needs a reason, not a boolean
export const d = <Button unsafelyRemoveFocusRing onPress={noop}>Save</Button>;
// These must compile: the guard should reject bad usage, not all usage.
export const ok1 = <Button onPress={noop}>Save</Button>;
export const ok2 = <Button iconOnly icon={<Trash />} aria-label="Delete draft" onPress={noop} />;
# 1. Types: four expected errors, zero unexpected, zero unused directives.
npx tsc --noEmit -p packages/ui/tsconfig.json
# (no output) -> every @ts-expect-error above still fires
# 2. Lint: both local rules on a deliberately bad fixture.
npx eslint packages/ui/src/__fixtures__/bad.tsx
# bad.tsx:4:8 error A <div> with onClick is not focusable or keyboard-operable
# a11y-local/no-non-interactive-handler
# bad.tsx:11:5 error Removing the outline breaks WCAG 2.2 SC 2.4.7
# a11y-local/no-outline-none
# 2 problems (2 errors, 0 warnings)
# 3. The escape hatch: the same fixture with a justification comment passes.
npx eslint packages/ui/src/__fixtures__/escaped.tsx # exit 0
# 4. Census: every live opt-out, with its file, line and stated reason.
grep -rn --include=*.tsx -e 'a11y-escape:' -e 'unsafelyRemoveFocusRing' \
packages src | grep -v __fixtures__
# packages/ui/src/Sparkline.tsx:31: // a11y-escape: the canvas is aria-hidden
# src/legacy/Toolbar.tsx:88: unsafelyRemoveFocusRing={{ reason: 'ring clipped
# by overflow; tracked in A11Y-412, fix lands with the toolbar rewrite' }}
# 2 opt-outs
Step four is the one to run before every release. Two opt-outs with tracked reasons is a healthy number; twenty means the default is wrong and the component needs a design change rather than more discipline from its callers. Recording the count against a committed baseline, so that only an increase fails the job, is covered as part of the library release gate in the parent guide.
Be exact about what the compiler cannot see, because the temptation is to over-claim. A required 'aria-label': string guarantees a non-empty type, not a meaningful string: aria-label="button", aria-label="icon", aria-label="Click here" and a German build still labelled "Delete" all satisfy the type and all fail a real user. A template-literal type can filter a few of the worst — aria-label rejecting a value that ends in " button" is a two-line trick — but no type can compare a label against the visible text next to it, and none can tell whether a label describes the action. That class of failure needs the rendered result, which is what the story sweep in auditing a component library with Storybook and axe is for: it computes the accessible name from the DOM with axe-core and can compare it against the visible text for a WCAG 2.2 SC 2.5.3 check the compiler will never make.
Edge Cases and Conditional Guards
- Spread props.
<Button {...rest} />is checked only as strictly asrestis typed; if it came fromRecord<string, unknown>or aany-typed API response, the union is satisfied vacuously. Type wrapper components’ own props asButtonPropsand re-spread, so narrowing propagates through the wrapper instead of stopping at it. - Polymorphic
asprops. AButtonthat can render as an anchor changes which name rules apply — a link’s accessible name should describe the destination, andtype="button"becomes invalid. Modelasas a third branch of the union with its own required fields rather than as an extra prop on the existing branches, or the compiler will accept a link with a button’s contract. - Consumers that do not type-check. A plain-JavaScript application, a Storybook MDX page, or a template compiled outside
tscgets none of this. Ship a development-only runtime assertion inside the component — throw inNODE_ENV === 'development'wheniconOnlyis set with noaria-label— so the guarantee degrades to a loud console failure rather than to nothing.
Pipeline Impact
These two layers are the cheapest jobs in the pipeline and belong first. tsc --noEmit over a component package finishes in tens of seconds, eslint in about the same, and both produce a file and line rather than a CSS selector, so the failure is actionable without opening a browser. Run them as separate required status checks rather than folded into a single “static” job: when the type-test file fails on an unused @ts-expect-error, that is a semantically different event from a lint error, and separate checks make the distinction visible in the pull-request UI.
Neither job should be permitted to run in warning mode indefinitely. The one legitimate warning period is a migration: introduce the union, set the handler rule to warn, count the occurrences, fix them — mechanically where the edit is attribute surgery, which is the same approach used in bulk-fixing form label associations with jscodeshift — and promote to error in the same pull request that clears the last one. A rule left at warn after the codebase is clean will silently accumulate new violations, because nothing in CI reads warnings.
Downstream, expect the effect to show up as an absence. The application scans stop reporting button-name on design-system buttons, so the interesting number becomes the count of findings on markup that did not come through the package — which is exactly the list worth turning into component requests. Where a component’s contract depends on how it is composed rather than on how it is constructed, encode the remainder as a scanner rule instead, following the approach in component-specific rule writing.
Common Pitfalls
- A single optional
aria-label. It collapses the two branches into one permissive shape and makes the nameless icon button compile; the union exists precisely to keep them apart. - Omitting
exactOptionalPropertyTypes. Without it,aria-label={undefined}satisfies?: never, and the labelled branch is open again for anyone passing a computed value. - Honouring
eslint-disable-next-lineas the escape hatch. A bare disable comment carries no reason, no owner and no way to count; require the project’s own marker and make the rules ignore the generic one. - Putting the outline rule in the root config. Scoped to the whole repository it fires on application code the design-system team does not own, and the first response is to switch it off everywhere.
- Believing a required string is a correct string. The type proves a name exists; only a rendered check proves it says something true, and conflating the two produces confident, unfixed reports.
FAQ
Why a boolean discriminant instead of a variant string?
A boolean keeps the common call site shorter — <Button iconOnly icon={...} aria-label="..." /> versus variant="icon" — and it composes better with the optional-false branch, since iconOnly?: false lets every labelled call site omit the discriminant entirely. A string union is the better choice when there are three or more shapes with genuinely different required fields, because at that point the boolean stops narrowing usefully and each branch needs its own name.
Does forbidding aria-label on the labelled branch ever get in the way?
Occasionally, and the case is real: a button whose visible text is “Edit” inside a card about a specific invoice may need “Edit invoice 4021” as its name. The right answer is not to reopen aria-label but to add a purpose-built prop — accessibleNameSuffix, appended to the children rather than replacing them — so the visible text remains a prefix of the accessible name and WCAG 2.2 SC 2.5.3 still holds by construction.
Should the lint rules live in the design-system package or in a separate tooling package? Separate. A design system published to consumers should not force them to install a lint plugin to use a button, and a lint plugin should be versionable independently of component code — rule changes are breaking events for pipelines even when no component changed. Keep it as its own workspace package, publish it alongside, and let consumers adopt it on their own schedule.
Related
- Design-System Accessibility Defaults — the parent guide covering the contract, component tests, contrast-safe tokens and the library release gate.
- Auditing a Component Library with Storybook and axe — the rendered check that catches the failures a type cannot express.
- Custom Rule Development & Context-Aware Testing — encoding a component contract as a scanner rule when composition, not construction, is the risk.