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.
Three-layer enforcement of accessible component defaults An IconButton usage passes through a TypeScript type check, an ESLint rule, and a Storybook a11y gate. All three map to WCAG 2.2 SC 4.1.2 and any layer can block the change. IconButton usage 1. TS type required prop 2. ESLint raw markup 3. Storybook a11y gate Ship named All three map to WCAG 2.2 SC 4.1.2 (Name, Role, Value)
Three independent layers enforce one criterion: the type check, the lint rule, and the Storybook gate all serve 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 lint labelAttributes list 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 play function before the a11y assertion runs.
  • Polymorphic as props: if Button can 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 custom Button, 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-label over 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.