Enforcing Accessible Component Defaults in a Design System
When an icon-only button can be created without an accessible name, an unlabeled control is inevitable at scale. This page — part of Design-System Accessibility Defaults — shows the concrete enforcement stack: a TypeScript type that makes the label non-optional, an ESLint rule that catches what types cannot, and a Storybook a11y CI gate that blocks the merge. Together they map a single WCAG criterion — 2.2 SC 4.1.2 (Name, Role, Value) — onto three layers of automated prevention so a nameless interactive primitive simply cannot ship.
Baseline controls this page enforces:
- A discriminated-union type so icon-only buttons require a name but text buttons do not.
- An ESLint configuration that errors on unlabeled controls in raw markup.
- A Storybook a11y test runner that fails CI on any story violation.
- Explicit mapping of each layer to WCAG 2.2 SC 4.1.2.
Root Cause / Context
A scanner finds an unlabeled control after it renders, in application code, where the cost of fixing it is highest. The reason the same defect recurs is structural: the component primitive allows it. If IconButton’s aria-label is an optional string, then every consumer is one omission away from a violation, and no amount of downstream scanning changes that the door is open. Enforcement at the design-system layer closes the door, so the violation cannot be authored in the first place.
The subtlety is that not every button needs an aria-label — a button with visible text gets its name from its children, and forcing a redundant label would itself risk a WCAG 2.2 SC 2.5.3 (Label in Name) mismatch. So a blunt “always require aria-label” type is wrong. The correct model is a discriminated union: a text button requires children and forbids a redundant label, while an icon-only button forbids text children and requires the label. The type system can express exactly this, turning the accessibility rule into a compile-time invariant. The lint and Storybook layers then cover the markup the type system never sees, scanning with the same axe-core engine the production pipeline uses.
Configuration
The type uses a discriminated union on a variant field so the compiler demands a name exactly when one is needed.
// IconButton.tsx
import * as React from "react";
// Text variant: name comes from children; no aria-label allowed.
type TextButton = {
variant: "text";
children: React.ReactNode;
onClick: () => void;
"aria-label"?: never; // forbid redundant label (guards SC 2.5.3)
};
// Icon variant: no text children; aria-label is REQUIRED (SC 4.1.2).
type IconButton = {
variant: "icon";
icon: React.ReactNode;
onClick: () => void;
"aria-label": string; // required: an icon has no intrinsic name
children?: never;
};
type ButtonProps = TextButton | IconButton;
export function Button(props: ButtonProps) {
if (props.variant === "icon") {
return (
<button type="button" onClick={props.onClick} aria-label={props["aria-label"]}>
{props.icon}
</button>
);
}
return (
<button type="button" onClick={props.onClick}>
{props.children}
</button>
);
}
// <Button variant="icon" icon={<Trash />} onClick={del} />
// -> compile error: Property 'aria-label' is missing
The ESLint layer catches controls written outside this primitive — a raw <button> wrapping only an <svg>, for example.
// eslint.config.js
import jsxA11y from "eslint-plugin-jsx-a11y";
export default [
{
files: ["**/*.tsx"],
plugins: { "jsx-a11y": jsxA11y },
rules: {
// errors on a control with no accessible name (text, label, or labelledby)
"jsx-a11y/control-has-associated-label": [
"error",
{
labelAttributes: ["aria-label", "aria-labelledby"],
controlComponents: ["Button"], // also check our custom primitive
depth: 3, // search nested children for a text node
},
],
},
},
];
The Storybook gate runs axe over every story and fails CI on a violation.
// .storybook/preview.js
export const parameters = {
a11y: {
config: { rules: [{ id: "button-name", enabled: true }] }, // SC 4.1.2 rule
test: "error", // turn violations into test failures, not warnings
},
};
Validation
Prove all three layers reject. The type check fails on a missing label; the lint rule fails on raw unlabeled markup; the Storybook runner fails on a violating story.
# 1. Type layer: missing aria-label on an icon button
npx tsc --noEmit
# IconButton usage: error TS2741: Property 'aria-label' is missing
# 2. Lint layer: a raw <button><svg/></button> with no name
npx eslint src/
# error A control must be associated with a text label jsx-a11y/control-has-associated-label
# 3. Storybook gate: an icon story authored without a name
npm run build-storybook && npx http-server storybook-static -p 6006 &
npx wait-on http://localhost:6006
npx test-storybook --url http://localhost:6006
# button-name: Buttons must have discernible text -> 1 failed, exit 1
All three exiting non-zero confirms the WCAG 2.2 SC 4.1.2 invariant is enforced from the keyboard to the merge gate.
Edge Cases & Conditional Guards
- Tooltip-labeled icons: if the name comes from an associated tooltip via
aria-labelledby, accept that in the lintlabelAttributeslist so a valid pattern is not falsely flagged. - Async-loaded icons: a story whose icon renders after a fetch can scan empty — await a stable state in the story
playfunction before the a11y assertion runs. - Polymorphic
asprops: ifButtoncan render as an anchor, branch the required-name logic so a link’s name rules (not a button’s) apply.
Pipeline Impact
The type check and lint run as fast pre-test jobs that fail in seconds; test-storybook exits non-zero on any story violation and uploads its axe JSON as an artifact for review. Make all three required status checks on the design-system package so a regression in a primitive cannot publish. Because consumers inherit the enforced defaults, downstream application scans surface fewer findings, directly shrinking the baselines tracked in Progressive Threshold Management and reducing PR-gating noise described in Blocking Pull Requests on Critical Accessibility Violations.
Common Pitfalls
- A single optional
aria-label. It collapses the icon/text distinction and lets unlabeled icon buttons compile; use a discriminated union instead. - Forgetting
controlComponents. Without listing your customButton, the lint rule only checks native elements and misses your primitive. - Storybook addon left as a warning. Set
test: "error"or the gate highlights but never blocks. - Requiring labels on text buttons. A redundant
aria-labelover visible text risks an SC 2.5.3 mismatch; forbid it on the text variant.
FAQ
Why use a discriminated union instead of just requiring aria-label everywhere?
Because text buttons derive their name from visible content, and adding a separate label there can violate WCAG 2.2 SC 2.5.3 when the two strings diverge. The union requires a label precisely where one is needed and forbids it where it would cause harm.
Do I still need the production scanner if the design system enforces this? Yes. The design system covers components authored through it, but applications still contain bespoke markup, third-party embeds, and content. The layers here shrink the scanner’s workload; they do not replace it.
How do I roll this out without breaking every existing call site?
Introduce the union type behind a temporary compatibility shim, fix call sites incrementally, then remove the shim. The lint rule can run as a warning during migration and be promoted to error once the codebase is clean.
Related
- Design-System Accessibility Defaults — the parent strategy combining types, lint, and gating.
- Automated Remediation & Accessibility Fixing Patterns — the broader section on preventing and fixing defects.
- Writing Custom axe-core Rules for Complex Data Tables — extending component-level enforcement with custom rules.