DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building Accessible Frontend Components: Patterns, Patterns, Patterns

Building Accessible Frontend Components: Patterns, Patterns, Patterns

Building Accessible Frontend Components: Patterns, Patterns, Patterns

Creating accessible, robust, and delightful frontend components is one of the most valuable investments you can make in a UI library or app. This guide walk-throughs a practical set of patterns you can apply today, with real code you can copy-paste or adapt. We’ll cover accessible structure, keyboard interactions, focus management, and testing to ensure your components work for everyone.

1) Accessible component anatomy

A well-structured component has a clear API, semantic markup, and predictable behavior. Start with:

  • Meaningful HTML: use native elements when possible (button, input, dialog) to leverage built-in accessibility features.
  • ARIA as a last resort: only add ARIA attributes when there’s a gap between semantics and behavior.
  • Clear focus indicators: visible outlines or custom styles that meet contrast guidelines.
  • Keyboard operability: all interactive features should be reachable and usable with a keyboard.

Example: an accessible modal

  • Use a hidden backdrop element that traps focus when the modal is open.
  • Return focus to the trigger when closed.
  • Provide aria-modal, role="dialog", and aria-label.

Code pattern (React):

import React, { useEffect, useRef } from 'react';

type ModalProps = {
  open: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
};

export function Modal({ open, onClose, title, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement | null>(null);
  const previouslyFocused = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (open) {
      previouslyFocused.current = document.activeElement as HTMLElement;
      // Escape to close
      const onKey = (e: KeyboardEvent) => {
        if (e.key === 'Escape') onClose();
      };
      document.addEventListener('keydown', onKey);
      // Focus trap: focus first focusable element
      const focusable = dialogRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      focusable?.focus();
      return () => {
        document.removeEventListener('keydown', onKey);
      };
    } else {
      previouslyFocused.current?.focus();
    }
  }, [open, onClose]);

  // Simple focus trap: keep tabbing inside
  useEffect(() => {
    if (!open) return;
    const trap = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;
      const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      const first = focusables?.;
      const last = focusables?.[focusables.length - 1];
      if (!document.activeElement || !focusables) return;
      if ((e.shiftKey && document.activeElement === first) || (!e.shiftKey && document.activeElement === last)) {
        e.preventDefault();
        (e.shiftKey ? last : first)?.focus();
      }
    };
    document.addEventListener('keydown', trap);
    return () => document.removeEventListener('keydown', trap);
  }, [open]);

  if (!open) return null;

  return (
    <div role="presentation" aria-label="Backdrop" style={backdropStyle}>
      <div
        role="dialog"
        aria-modal="true"
        aria-label={title}
        ref={dialogRef}
        style={dialogStyle}
      >
        <header style={headerStyle}>
          <h2 id="modal-title">{title}</h2>
          <button onClick={onClose} aria-label="Close modal" style={closeBtnStyle}>
            ×
          </button>
        </header>
        <section aria-labelledby="modal-title" style={bodyStyle}>
          {children}
        </section>
      </div>
    </div>
  );
}

// Simple inline styles for clarity; in real apps, use CSS modules or styled components.
const backdropStyle: React.CSSProperties = {
  position: 'fixed',
  inset: 0,
  background: 'rgba(0,0,0,0.5)',
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  zIndex: 1000,
};

const dialogStyle: React.CSSProperties = {
  background: '#fff',
  borderRadius: 8,
  maxWidth: 600,
  width: '90%',
  boxShadow: '0 10px 25px rgba(0,0,0,.25)',
  padding: 0,
};

const headerStyle: React.CSSProperties = {
  display: 'flex',
  justifyContent: 'space-between',
  alignItems: 'center',
  padding: '16px 20px',
  borderBottom: '1px solid #eee',
};

const closeBtnStyle: React.CSSProperties = {
  border: 'none',
  background: 'transparent',
  fontSize: 20,
  lineHeight: 1,
  cursor: 'pointer',
};

const bodyStyle: React.CSSProperties = {
  padding: 20,
};
Enter fullscreen mode Exit fullscreen mode

Takeaways:

  • Use role="dialog" and aria-modal="true".
  • Trap focus within the modal and restore it when closed.
  • Provide a clear, accessible close control. ### 2) Keyboard-first interactions

A keyboard-friendly component respects the user’s need to navigate without a mouse.

patterns:

  • Logical focus order: the DOM order should reflect the visual order.
  • Visible focus ring: ensure focus ring meets WCAG contrast guidelines.
  • Alternative triggers: actions should be available via keyboard (Enter/Space, arrow keys for navigation).

Example: accessible dropdown

type DropdownItem = { label: string; value: string };

export function Dropdown({ items }: { items: DropdownItem[] }) {
  const [open, setOpen] = React.useState(false);
  const [idx, setIdx] = React.useState(0);
  const btnRef = React.useRef<HTMLButtonElement | null>(null);
  const listRef = React.useRef<HTMLUListElement | null>(null);

  useEffect(() => {
    if (open) {
      const first = listRef.current?.querySelector<HTMLElement>('li button');
      first?.focus();
    }
  }, [open]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (!open) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setIdx((i) => Math.min(i + 1, items.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setIdx((i) => Math.max(i - 1, 0));
    } else if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      // select item
      alert(`Selected: ${items[idx].label}`);
      setOpen(false);
      btnRef.current?.focus();
    } else if (e.key === 'Escape') {
      setOpen(false);
      btnRef.current?.focus();
    }
  };

  return (
    <div>
      <button ref={btnRef} onClick={() => setOpen((s) => !s)} aria-expanded={open}>
        Choose item
      </button>
      {open && (
        <ul role="menu" ref={listRef} onKeyDown={onKeyDown} style={menuStyle}>
          {items.map((it, i) => (
            <li key={it.value} role="none">
              <button
                role="menuitem"
                aria-selected={idx === i}
                onClick={() => {
                  alert(`Selected: ${it.label}`);
                  setOpen(false);
                  btnRef.current?.focus();
                }}
                style={i === idx ? activeItemStyle : itemStyle}
              >
                {it.label}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

const menuStyle: React.CSSProperties = {
  marginTop: 8,
  padding: 0,
  listStyle: 'none',
  border: '1px solid #ddd',
  borderRadius: 6,
  width: 200,
  background: '#fff',
};

const itemStyle: React.CSSProperties = {
  width: '100%',
  padding: '8px 12px',
  textAlign: 'left',
  border: 'none',
  background: 'transparent',
  cursor: 'pointer',
};

const activeItemStyle: React.CSSProperties = {
  ...itemStyle,
  background: '#f0f0f0',
};
Enter fullscreen mode Exit fullscreen mode

Key ideas:

  • Use ARIA roles for menus, and manage focus with keyboard events.
  • Keep a consistent focus order and provide visual cues for the active item. ### 3) Focus management in single-page apps

Single-page apps (SPAs) can trap the user in sections. A simple pattern: restore focus to a sensible element when navigation or modals close.

  • When opening a panel, store the currently focused element.
  • Return focus to that element after closing.
  • For in-page navigation, move focus to the first meaningful heading or interactive element.

Example: accessible tab panel

type Tab = { id: string; label: string; content: React.ReactNode };

export function Tabs({ tabs }: { tabs: Tab[] }) {
  const [active, setActive] = React.useState(0);
  const lastFocused = React.useRef<HTMLElement | null>(null);
  const panelRef = React.useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    // Save focus before changing tabs
    const root = document.activeElement as HTMLElement | null;
    lastFocused.current = root;
  }, [active]);

  useEffect(() => {
    // Move focus to panel after activation
    panelRef.current?.focus();
  }, []);

  const onKey = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowRight') setActive((i) => (i + 1) % tabs.length);
    if (e.key === 'ArrowLeft') setActive((i) => (i - 1 + tabs.length) % tabs.length);
  };

  return (
    <div>
      <div role="tablist" aria-label="Sample tabs" onKeyDown={onKey}>
        {tabs.map((t, i) => (
          <button
            key={t.id}
            role="tab"
            aria-selected={i === active}
            aria-controls={`panel-${t.id}`}
            id={`tab-${t.id}`}
            onClick={() => setActive(i)}
          >
            {t.label}
          </button>
        ))}
      </div>
      {tabs.map((t, i) => (
        <section
          role="tabpanel"
          aria-labelledby={`tab-${t.id}`}
          id={`panel-${t.id}`}
          key={t.id}
          hidden={i !== active}
          tabIndex={-1}
          ref={i === active ? panelRef : null}
        >
          {t.content}
        </section>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Tips:

  • Use tab roles and aria-selected to indicate focus state.
  • Manage focus transitions to maintain a predictable experience. ### 4) Testing for accessibility

Automated checks catch many issues, but human review matters. Combine:

  • Automated tests: axe-core, lighthouse audits
  • Unit tests: render components with accessibility props and simulate keyboard interactions
  • Manual checks: screen reader feedback, high-contrast modes

Example: a simple ARIA-focused unit test (React Testing Library)

import { render, screen, fireEvent } from '@testing-library/react';
import { Dropdown } from './Dropdown';

test('dropdown opens and navigates with keyboard', () => {
  const items = [
    { label: 'Apple', value: 'apple' },
    { label: 'Banana', value: 'banana' },
    { label: 'Cherry', value: 'cherry' },
  ];
  render(<Dropdown items={items} />);

  const trigger = screen.getByRole('button', { name: /Choose item/i });
  trigger.click();

  const options = screen.getAllByRole('menuitem');
  expect(options.length).toBe(3);

  // Keyboard navigation
  options.focus();
  expect(document.activeElement).toBe(options);

  // Activate a selection
  fireEvent.keyDown(document.activeElement as Element, { key: 'Enter' });
  // In real code, you would mock the selection handler
});
Enter fullscreen mode Exit fullscreen mode

Recommendations:

  • Integrate a11y checks into CI.
  • Include accessibility test cases in your component test suites. ### 5) State management patterns for components

Avoid prop-drilling and too much internal state. Use a clean pattern:

  • Uncontrolled vs. controlled: offer both modes where appropriate.
  • Use a small, explicit state machine for complex components (open/closed, focused, loading, error).
  • Expose callbacks for external control (onOpen, onClose, onChange).

Example: a controlled dropdown

export function ControlledDropdown({ value, onChange, items }: { value: string; onChange: (v: string) => void; items: DropdownItem[] }) {
  const [open, setOpen] = React.useState(false);
  return (
    <div>
      <button onClick={() => setOpen((s) => !s)} aria-expanded={open}>
        {items.find((i) => i.value === value)?.label ?? 'Select'}
      </button>
      {open && (
        <ul role="menu" aria-label="dropdown" style={menuStyle}>
          {items.map((it) => (
            <li key={it.value} role="none">
              <button
                role="menuitem"
                onClick={() => {
                  onChange(it.value);
                  setOpen(false);
                }}
              >
                {it.label}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Clear API for consumers.
  • Easier to test and reason about state transitions. ### 6) Performance considerations

Accessibility and performance go hand in hand:

  • Avoid heavy rendering for hidden components; utilize conditional rendering rather than CSS display toggles.
  • Debounce expensive updates in components that react to user input (e.g., live filtering).
  • Use memoization for pure subcomponents to prevent unnecessary re-renders.

Example: memoized list render

type Item = { id: string; name: string };

export const ItemList = React.memo(function ItemList({ items }: { items: Item[] }) {
  console.log('render list');
  return (
    <ul>
      {items.map((it) => (
        <li key={it.id}>{it.name}</li>
      ))}
    </ul>
  );
});
Enter fullscreen mode Exit fullscreen mode

This prevents re-renders when parent state changes unrelated to the list.

7) Accessibility checklist

  • Semantic HTML used where possible: buttons, lists, headings, landmarks.
  • ARIA only where necessary; avoid over-annotation.
  • Keyboard operability: all actions reachable via keyboard; provide visible focus.
  • Focus management: restore focus on close/exit; trap focus in dialogs/panels.
  • Visual contrast: ensure text and interactive elements meet WCAG contrast guidelines.
  • Screen reader friendly: meaningful labels, roles, and relationships (aria-label, aria-labelledby, aria-describedby). ### 8) Practical example: an accessible collapsible panel

A collapsible panel demonstrates patterns we’ve covered: semantic structure, keyboard accessibility, and focus handling.

type PanelProps = {
  id: string;
  title: string;
  children: React.ReactNode;
};

export function AccessiblePanel({ id, title, children }: PanelProps) {
  const [open, setOpen] = React.useState(false);
  const btnRef = React.useRef<HTMLButtonElement | null>(null);
  const contentRef = React.useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (open) {
      contentRef.current?.focus();
    } else {
      btnRef.current?.focus();
    }
  }, [open]);

  return (
    <section aria-labelledby={`${id}-header`}>
      <h3 id={`${id}-header`}>
        <button
          ref={btnRef}
          onClick={() => setOpen((s) => !s)}
          aria-expanded={open}
          aria-controls={`${id}-content`}
          style={buttonStyle}
        >
          {title}
        </button>
      </h3>
      {open && (
        <div
          id={`${id}-content`}
          tabIndex={-1}
          ref={contentRef}
          role="region"
          aria-label={title}
          style={panelStyle}
        >
          {children}
        </div>
      )}
    </section>
  );
}

const buttonStyle: React.CSSProperties = {
  background: 'transparent',
  border: '1px solid #ccc',
  padding: '6px 10px',
  borderRadius: 6,
  cursor: 'pointer',
};

const panelStyle: React.CSSProperties = {
  padding: 12,
  border: '1px solid #eee',
  borderRadius: 6,
  marginTop: 8,
};
Enter fullscreen mode Exit fullscreen mode

This pattern shows how to structure for accessibility while keeping the UI intuitive.
If you’d like, I can tailor this into a longer blog post with a real project scaffold (e.g., a small component library demonstrating multiple accessible patterns), or adapt it to plain JavaScript, Vue, Svelte, or another framework you’re using. Which direction would you prefer?

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)