DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building Accessible, Inclusive Frontend Components: A Practical Guide with Real Code Patterns

Building Accessible, Inclusive Frontend Components: A Practical Guide with Real Code Patterns

Building Accessible, Inclusive Frontend Components: A Practical Guide with Real Code Patterns

Accessibility and inclusion aren’t add-ons-they’re core design decisions that shape how users interact with your product. This tutorial walks through building a small, reusable frontend component library with accessible patterns, inclusive defaults, and practical code you can reuse in real projects. You’ll see concrete patterns for structure, styling, testing, and documentation, plus example implementations in React with TypeScript.

Goals and scope

  • Create an accessible, keyboard-navigable, screen-reader-friendly button and a composite control (a labeled toggle group) that works across themes and devices.
  • Emphasize inclusive defaults: high contrast, motion-reduction support, and semantic markup.
  • Provide end-to-end patterns: types, hooks, components, tests, and documentation snippets.
  • Use plain, copy-pastable code you can adapt to your own project.

    Core principles

  • Semantic HTML first: let the browser convey meaning, then layer styling and behavior on top.

  • Keyboard first: ensure all interactive elements are reachable and operable via keyboard.

  • Visual focus and state cues: visible focus rings, clear active/disabled states.

  • Inclusive defaults: color contrast, reduced motion, and screen-reader friendly labels.

  • Composability: design small primitives that can be combined into more complex widgets.

    Pattern 1: Accessible Button (Plain, reusable)

A button is the most fundamental interactive element. The goal is to ensure it’s usable by everyone and easy to style consistently.

Code: Button.tsx

  • TypeScript React functional component
  • Props for label, onClick, ariaLabel, disabled, variant, and size
  • Uses semantic button element
  • Focus styles via CSS, and reduced motion support
import React from 'react';

type ButtonVariant = 'primary' | 'secondary' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  label: string;
  variant?: ButtonVariant;
  size?: ButtonSize;
  ariaLabel?: string;
}

const base =
  'px-4 py-2 border rounded inline-flex items-center justify-center font-medium focus:outline-none focus:ring-2 focus:ring-offset-2';
const variants: Record<ButtonVariant, string> = {
  primary: 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700',
  secondary: 'bg-white text-blue-600 border-blue-600 hover:bg-blue-50',
  ghost: 'bg-transparent text-blue-600 border-transparent hover:bg-blue-50',
};
const sizes: Record<ButtonSize, string> = {
  sm: 'text-sm',
  md: 'text-base',
  lg: 'text-lg',
};

export const Button: React.FC<ButtonProps> = ({
  label,
  variant = 'primary',
  size = 'md',
  disabled,
  ariaLabel,
  ...rest
}) => {
  return (
    <button
      type="button"
      aria-label={ariaLabel ?? label}
      className={`${base} ${variants[variant]} ${sizes[size]} ${rest.className ?? ''} ${
        disabled ? 'opacity-60 cursor-not-allowed' : ''
      }`}
      disabled={disabled}
      {...rest}
    >
      {label}
    </button>
  );
};

export default Button;
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Uses semantic button with aria-label fallback to visible label.
  • Focus ring helps keyboard users; consider preferring outline-none with your design system and providing a visible ring.
  • Tailwind-like classes are used for brevity; swap to your CSS-in-JS or CSS modules as needed.

Usage example:

<Button label="Submit" onClick={() => console.log('submitted')} />
<Button label="Cancel" variant="secondary" onClick={handleCancel} />
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Accessible Toggle Group (Radio-like switcher)

A labeled group where only one option is selected, like theme pickers or segmented controls.

Code: ToggleGroup.tsx

  • Accessible via role="group" and role="radio" semantics
  • Keyboard navigation with arrow keys
  • Announced state for screen readers
import React from 'react';

type ToggleOption<T extends string> = { id: T; label: string };

interface ToggleGroupProps<T extends string> {
  options: ToggleOption<T>[];
  value: T;
  onChange: (value: T) => void;
  ariaLabel?: string;
}

export function ToggleGroup<T extends string>({
  options,
  value,
  onChange,
  ariaLabel,
}: ToggleGroupProps<T>) {
  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>, idx: number) => {
    const last = options.length - 1;
    if (e.key === 'ArrowRight') {
      e.preventDefault();
      const next = idx >= last ? 0 : idx + 1;
      onChange(options[next].id);
    } else if (e.key === 'ArrowLeft') {
      e.preventDefault();
      const prev = idx <= 0 ? last : idx - 1;
      onChange(options[prev].id);
    }
  };

  return (
    <div
      role="group"
      aria-label={ariaLabel ?? 'Toggle group'}
      className="inline-flex gap-1 p-1 border rounded bg-white"
    >
      {options.map((opt, idx) => {
        const selected = opt.id === value;
        return (
          <button
            key={opt.id}
            role="radio"
            aria-checked={selected}
            className={`px-3 py-2 rounded ${selected ? 'bg-blue-600 text-white' : 'bg-white text-blue-600'}`}
            onClick={() => onChange(opt.id)}
            onKeyDown={(e) => handleKeyDown(e, idx)}
            aria-label={opt.label}
          >
            {opt.label}
          </button>
        );
      })}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage example:

const themes = [
  { id: 'light', label: 'Light' },
  { id: 'dark', label: 'Dark' },
  { id: 'system', label: 'System' },
] as const;

function App() {
  const [theme, setTheme] = React.useState<string>('system');

  return (
    <div>
      <ToggleGroup
        options={themes}
        value={theme}
        onChange={setTheme}
        ariaLabel="Theme"
      />
      <p>Selected: {theme}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • The component uses role="radio" semantics inside a group to convey mutual exclusivity to assistive tech.
  • Keyboard navigation mirrors standard segmented controls.
  • You can wire this to a theming system or persistence layer easily. ### Pattern 3: Focus management and skip links

Accessibility is not only about controls but also page navigation. Provide a skip link for keyboard users and screen readers.

Code: SkipLink.tsx

import React from 'react';

export const SkipLink: React.FC<{ targetId: string; label?: string }> = ({
  targetId,
  label = 'Skip to main content',
}) => {
  const href = `#${targetId}`;
  return (
    <a
      href={href}
      className="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0"
      style={{
        position: 'absolute',
        top: -40,
        left: 0,
        padding: '8px 12px',
        background: '#111',
        color: '#fff',
        borderRadius: 4,
        zIndex: 9999,
      }}
    >
      {label}
    </a>
  );
};
Enter fullscreen mode Exit fullscreen mode

Usage:

<SkipLink targetId="main" />
<main id="main" tabIndex={-1}>
  {/* main content */}
</main>
Enter fullscreen mode Exit fullscreen mode

Notes:

  • The skip link is visually hidden but becomes visible when focused, aiding keyboard users.
  • Ensure focus can land on main content after skipping. ### Pattern 4: Reduced motion support

Respect user preferences for reduced motion. Provide a CSS utility or class that disables animations when users prefer-reduced-motion.

CSS (plain):

@media (prefers-reduced-motion: reduce) {
  .no-motion * {
    animation: none !important;
    transition: none !important;
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<div className="no-motion">
  <Button label="Animate" onClick={handleClick} />
  {/* any animated child elements will respect reduced motion */}
</div>
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Place critical animations behind a no-motion wrapper or a CSS class in your design system. ### Pattern 5: Keyboard focus visuals

Always ensure focus is visible. If your project uses custom pseudo-elements or outlines, provide an explicit focus ring.

CSS example:

:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}
Enter fullscreen mode Exit fullscreen mode

JS/TS approach:

  • Use focusVisible polyfill if you support older browsers that don’t implement :focus-visible.
  • Prefer a consistent color system aligned with your design tokens. ### Pattern 6: Testing accessibility (a11y)

Tests ensure your patterns stay accessible as the codebase evolves.

Test: Button accessibility with React Testing Library and axe-core integration (pseudo-code)

import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
import { toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

test('button is accessible and clickable', async () => {
  const { container } = render(<Button label="Submit" />);
  const btn = screen.getByRole('button', { name: /submit/i });
  expect(btn).toBeInTheDocument();
  expect(btn).not.toBeDisabled();

  // keyboard activation
  btn.focus();
  fireEvent.click(btn);
  // add assertions for onClick if needed
  // a11y check
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
Enter fullscreen mode Exit fullscreen mode

Tips:

  • Include aria-labels or aria-labelledby where necessary.
  • Validate tab order logic with keyboard navigation tests. ### Pattern 7: Documentation snippet (MDX-friendly)

Document components in a way that designers and engineers can reuse.

Example: docs/Button.mdx

import { Button } from './Button';

### Button

A small, accessible button component.

### Best practices

- Use semantic button elements.
- Provide aria-label when visible text isn’t descriptive enough.
- Respect reduced motion preferences.

### Props

- label: string (required)
- variant: 'primary' | 'secondary' | 'ghost'
- size: 'sm' | 'md' | 'lg'
- onClick: function
- disabled: boolean

### Usage

Enter fullscreen mode Exit fullscreen mode


tsx


Enter fullscreen mode Exit fullscreen mode


typescript

Notes:

  • MDX enables live examples and props tables in your doc site.
  • Keep docs in sync with types and re-exported components. ### Pattern 8: Theming and color tokens

Define a small design token set to ensure accessible color contrast across themes.

Example: tokens.ts

export const tokens = {
  color: {
    primary: '#2563eb', // blue-600
    onPrimary: '#ffffff',
    surface: '#ffffff',
    surfaceInverted: '#111827',
    border: '#e5e7eb',
    text: '#111827',
    muted: '#6b7280',
  },
  radii: {
    small: 6,
    medium: 10,
  },
  motion: {
    duration: '180ms',
  },
};
Enter fullscreen mode Exit fullscreen mode

Usage in Button (replace class names with tokens as needed):

<style jsx>{`
  .btn {
    background: ${tokens.color.primary};
    color: ${tokens.color.onPrimary};
  }
`}</style>
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Centralize colors to preserve contrast ratios across themes.
  • Ensure dark mode has tested contrast against accessibility guidelines (WCAG AA/AAA).

    Pattern 9: Accessibility-first testing strategy

  • Unit tests for semantic roles, ARIA attributes, and keyboard interactions.

  • Integration tests for real-world use cases (e.g., focusing a composite control).

  • Visual tests that verify contrast and focus states, not only pixel parity.

Checklist:

  • [ ] All interactive elements have a keyboard-focusable path.
  • [ ] ARIA attributes reflect the actual state.
  • [ ] No color alone conveys critical information (also provide text or icons).
  • [ ] Reduced motion respected everywhere animations exist.
  • [ ] Components render correctly in high-contrast mode.

    Example project structure

  • src/

    • components/
    • Button.tsx
    • ToggleGroup.tsx
    • SkipLink.tsx
    • hooks/
    • usePrefersReducedMotion.ts
    • styles/
    • globals.css
    • tests/
    • Button.test.tsx
    • ToggleGroup.test.tsx
    • doc/
    • Button.mdx
  • package.json

  • tsconfig.json

  • README.md

Notes:

  • Organize components into small, reusable primitives.
  • Keep tests close to the components to catch regressions early.

    Practical migration tips

  • Start with a single accessible button before expanding to complex components.

  • Audit a real page with a11y tools (Lighthouse, axe-core) and fix blockers iteratively.

  • Add skip links and keyboard navigability early in the project’s lifecycle.

  • Use semantic HTML first; only add ARIA when necessary to improve semantics.

    A small refactor plan you can follow

1) Audit current components for semantic HTML usage and keyboard support.
2) Implement a basic Accessible Button and a basic ToggleGroup in a shared library.
3) Add tests focusing on keyboard interaction and ARIA attributes.
4) Introduce a design token system to unify color and motion tokens.
5) Document components with MDX docs and example usage.
6) Validate accessibility with automated tests and manual checks (screen readers, color contrast).
If you’d like, I can tailor this into a complete starter repository for your setup (React with TypeScript, Next.js, or a vanilla setup), including a minimal design system and a11y-focused test suite. Which framework and tooling would you prefer to start with?

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)