Design-System Accessibility Defaults

The cheapest accessibility violation is the one a component cannot produce. This guide is part of Automated Remediation & Accessibility Fixing Patterns, and it shifts the work all the way left: instead of scanning for defects after they ship, you make the defects unrepresentable in the design system itself. A button primitive that requires a label, a lint rule that forbids unlabeled icons, and a Storybook a11y gate that blocks a merge together stop whole categories of violations before a single page is scanned.

Key implementation targets:

  • Make accessibility-critical props required at the type level, not optional.
  • Add lint rules that forbid unlabeled primitives in application code.
  • Gate the component library with a Storybook a11y check in CI.
  • Map enforced defaults to WCAG 2.2 SC 4.1.2 so coverage is auditable.
Violations prevented at the component layer funnel A wide stream of potential violations narrows as it passes the type-level layer, the lint layer, and the Storybook a11y gate, leaving almost none to reach production scanning. Potential violations Type layer: required label props Lint layer: forbid unlabeled primitives Storybook a11y gate blocks merge Near-zero reach production scans
Each layer narrows the stream of possible violations; by the time code reaches production scanning, almost nothing is left to catch.

Problem Statement

Most accessibility programs are reactive: a scanner finds an unlabeled button, someone files a ticket, and the same class of defect reappears in the next feature. The root cause is that the component primitives permit the defect — an IconButton whose aria-label is optional will ship unlabeled the moment a developer is in a hurry. Design-system accessibility defaults invert this by making the accessible path the only path: required props, lint enforcement, and a component-library gate. The result is fewer defects reaching application code, which means fewer scanner findings, smaller baselines, and far less remediation.

Key Implementation Targets

Three reinforcing layers do the work. The type layer makes a missing label a compile error, catching the defect on the developer’s keyboard. The lint layer catches what types cannot — a raw <svg> button assembled outside the design system, or a label passed as an empty string. The Storybook gate runs the same axe rules the production pipeline uses against every component story, so a regression in a primitive fails the library’s own CI before any consumer ever imports it.

Prerequisites

1. Make Label Props Required at the Type Level

The first defense is the type system. An IconButton that has no visible text must demand an accessible name, and TypeScript can refuse to compile any usage that omits it. The complete type-plus-lint implementation is detailed in Enforcing Accessible Component Defaults in a Design System.

// IconButton.tsx — aria-label is non-optional, satisfying WCAG 2.2 SC 4.1.2
type IconButtonProps = {
  icon: React.ReactNode;
  "aria-label": string; // required: no default name exists for an icon
  onClick: () => void;
};

export function IconButton({ icon, onClick, ...rest }: IconButtonProps) {
  return (
    <button type="button" onClick={onClick} {...rest}>
      {icon}
    </button>
  );
}
// <IconButton icon={<Trash />} onClick={del} />  -> compile error: missing aria-label

2. Forbid Unlabeled Primitives with a Lint Rule

Types only protect the components that go through the design system. A lint rule extends coverage to raw markup anyone might write. Configure eslint-plugin-jsx-a11y to error on the patterns your scanner would later flag, so the failure happens in the editor.

// eslint.config.js
import jsxA11y from "eslint-plugin-jsx-a11y";

export default [
  {
    files: ["**/*.tsx"],
    plugins: { "jsx-a11y": jsxA11y },
    rules: {
      "jsx-a11y/control-has-associated-label": "error", // unlabeled controls
      "jsx-a11y/alt-text": "error",                       // images need alt
      "jsx-a11y/anchor-has-content": "error",             // empty links
    },
  },
];

3. Gate the Component Library with Storybook a11y

The final layer scans every story with axe, so a primitive that regresses fails the library’s own pipeline. Configure the addon to treat violations as test failures rather than warnings.

// .storybook/preview.js
export const parameters = {
  a11y: {
    config: { rules: [{ id: "color-contrast", enabled: true }] },
    // 'error' makes the test runner fail the build on violations
    test: "error",
  },
};
# CI step: build Storybook, then run axe against every story
npm run build-storybook                  # static build
npx http-server storybook-static -p 6006 &
npx wait-on http://localhost:6006        # block until served
npx test-storybook --url http://localhost:6006  # exits 1 on any a11y violation

Pipeline Integration

Run the type check, lint, and Storybook a11y runner as three required jobs on the design-system package. The TypeScript and ESLint steps fail fast and cheap; test-storybook exits non-zero on any story violation and uploads its JSON report as an artifact. Make all three required status checks so a primitive cannot regress into the published package. Downstream applications then inherit accessible defaults, shrinking the violation baselines maintained per Progressive Threshold Management.

Troubleshooting & Flaky-Test Mitigation

Storybook a11y flakiness usually comes from stories that animate or load async content — disable animations in the test build and await a stable state in the story’s play function. If color-contrast reports inconsistently, pin the rendered theme so light/dark variants do not alternate between runs. When a lint rule produces false positives on a legitimate pattern, scope an inline disable with a justification comment rather than weakening the rule globally.

Common Pitfalls

  • Optional label props. An optional aria-label is an unlabeled button waiting to happen; make it required or derive it from required visible text.
  • Lint as warning, not error. Warnings are ignored at scale; accessibility lint rules must be error to actually gate.
  • Storybook addon in report-only mode. The addon highlights issues in the UI but only blocks CI when configured as a failing test.
  • Skipping raw-markup coverage. Types miss hand-rolled elements; the lint layer is what catches primitives assembled outside the system.

FAQ

Why enforce at the type level when a scanner would catch it later? Cost and certainty. A type error costs seconds on the developer’s machine and is impossible to merge; a scanner finding costs a ticket, a context switch, and a fix days later. Prevention at the source also shrinks the baseline you maintain downstream.

Does requiring an aria-label prop force redundant labels on buttons with visible text? No — model that distinction in the type. A text button derives its name from its children and does not require aria-label; only icon-only primitives demand it. The linked child page shows the discriminated-union type that expresses this.

How does this map to WCAG 2.2? The required-label enforcement directly serves SC 4.1.2 (Name, Role, Value): every interactive primitive ships with a programmatic name. The lint and Storybook layers extend that guarantee to markup the type system cannot see.

In This Section