APG Patterns
日本語
日本語

Switch

A control that allows users to toggle between two states: on and off.

Demo

Open demo only →

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
switchSwitch elementAn input widget that allows users to choose one of two values: on or off

WAI-ARIA States

aria-checked

Target Element
switch element
Values
true | false
Required
Yes
Change Trigger
Click, Enter, Space

aria-disabled

Target Element
switch element
Values
true | undefined
Required
No
Change Trigger
Only when disabled

Keyboard Support

KeyAction
SpaceToggle the switch state (on/off)
EnterToggle the switch state (on/off)
  • Switches must have an accessible name via visible label (recommended), aria-label, or aria-labelledby.
  • This implementation follows WCAG 1.4.1 (Use of Color) by not relying solely on color to indicate state.
  • Thumb position: Left = off, Right = on. Checkmark icon visible only when on.
  • Forced colors mode uses system colors for Windows High Contrast Mode accessibility.

Implementation Notes

Structure:
<button role="switch" aria-checked="false">
  <span class="switch-track">
    <span class="switch-thumb" />
  </span>
  Enable notifications
</button>

Visual States:
┌─────────┬────────────┐
│ OFF     │ ON         │
├─────────┼────────────┤
│ [○    ] │ [    ✓]   │
│ Left    │ Right+icon │
└─────────┴────────────┘

Switch vs Checkbox:
- Switch: immediate effect, on/off semantics
- Checkbox: may require form submit, checked/unchecked semantics

Use Switch when:
- Action takes effect immediately
- Represents on/off, enable/disable
- Similar to a physical switch

Switch component structure and visual states

References

Source Code

Switch.tsx
import { cn } from '@/lib/utils';
import { useCallback, useState } from 'react';

export interface SwitchProps extends Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  'onClick' | 'type' | 'role' | 'aria-checked'
> {
  /** Initial checked state */
  initialChecked?: boolean;
  /** Switch label text */
  children?: React.ReactNode;
  /** Callback fired when checked state changes */
  onCheckedChange?: (checked: boolean) => void;
}

export const Switch: React.FC<SwitchProps> = ({
  initialChecked = false,
  children,
  onCheckedChange,
  className = '',
  disabled,
  ...buttonProps
}) => {
  const [checked, setChecked] = useState(initialChecked);

  const handleClick = useCallback(() => {
    if (disabled) return;
    const newChecked = !checked;
    setChecked(newChecked);
    onCheckedChange?.(newChecked);
  }, [checked, onCheckedChange, disabled]);

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLButtonElement>) => {
      if (disabled) return;
      if (event.key === ' ' || event.key === 'Enter') {
        event.preventDefault();
        const newChecked = !checked;
        setChecked(newChecked);
        onCheckedChange?.(newChecked);
      }
    },
    [checked, onCheckedChange, disabled]
  );

  return (
    <button
      type="button"
      role="switch"
      {...buttonProps}
      className={cn('apg-switch', className)}
      aria-checked={checked}
      aria-disabled={disabled || undefined}
      disabled={disabled}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
    >
      <span className="apg-switch-track">
        <span className="apg-switch-icon" aria-hidden="true">
          <svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
            <path
              d="M10.28 2.28a.75.75 0 00-1.06-1.06L4.5 5.94 2.78 4.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l5.25-5.25z"
              fill="currentColor"
            />
          </svg>
        </span>
        <span className="apg-switch-thumb" />
      </span>
      {children && <span className="apg-switch-label">{children}</span>}
    </button>
  );
};

export default Switch;

Usage

Example
import { Switch } from './Switch';

function App() {
  return (
    <Switch
      initialChecked={false}
      onCheckedChange={(checked) => console.log('Checked:', checked)}
    >
      Enable notifications
    </Switch>
  );
}

API

Prop Type Default Description
initialChecked boolean false Initial checked state
onCheckedChange (checked: boolean) => void - Callback when state changes
disabled boolean false Whether the switch is disabled
children ReactNode - Switch label
All other props are passed to the underlying <button> element.

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Switch component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.

  • ARIA attributes (role="switch", aria-checked)
  • Keyboard interaction (Space, Enter)
  • Disabled state handling
  • Accessibility via jest-axe

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.

  • Click and keyboard toggle behavior
  • ARIA structure in live browser
  • Disabled state interactions
  • axe-core accessibility scanning
  • Cross-framework consistency checks

Test Categories

High Priority: APG Keyboard Interaction ( Unit + E2E )

Test Description
Space key Toggles the switch state
Enter key Toggles the switch state
Tab navigation Tab moves focus between switches
Disabled Tab skip Disabled switches are skipped in Tab order
Disabled key ignore Disabled switches ignore key presses

High Priority: APG ARIA Attributes ( Unit + E2E )

Test Description
role="switch" Element has switch role
aria-checked initial Initial state is aria-checked="false"
aria-checked toggle Click changes aria-checked value
type="button" Explicit button type prevents form submission
aria-disabled Disabled switches have aria-disabled="true"

Medium Priority: Accessibility ( Unit + E2E )

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe)
Accessible name (children) Switch has name from children content
aria-label Accessible name via aria-label
aria-labelledby Accessible name via external element

Low Priority: HTML Attribute Inheritance ( Unit )

Test Description
className merge Custom classes are merged with component classes
data-* attributes Custom data attributes are passed through

Low Priority: Cross-framework Consistency ( E2E )

Test Description
All frameworks have switch React, Vue, Svelte, Astro all render switch elements
Toggle on click All frameworks toggle correctly on click
Consistent ARIA All frameworks have consistent ARIA structure

Example Test Code

The following is the actual E2E test file (e2e/switch.spec.ts).

e2e/switch.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Switch Pattern
 *
 * A switch is a type of checkbox that represents on/off values.
 * It uses `role="switch"` and `aria-checked` to communicate state
 * to assistive technology.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/switch/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// Helper to get switch elements
const getSwitches = (page: import('@playwright/test').Page) => {
  return page.locator('[role="switch"]');
};

for (const framework of frameworks) {
  test.describe(`Switch (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/switch/${framework}/demo/`);
      await page.waitForLoadState('networkidle');
    });

    // 🔴 High Priority: ARIA Structure
    test.describe('APG: ARIA Structure', () => {
      test('has role="switch"', async ({ page }) => {
        const switches = getSwitches(page);
        const count = await switches.count();
        expect(count).toBeGreaterThan(0);

        for (let i = 0; i < count; i++) {
          await expect(switches.nth(i)).toHaveAttribute('role', 'switch');
        }
      });

      test('has aria-checked attribute', async ({ page }) => {
        const switches = getSwitches(page);
        const count = await switches.count();

        for (let i = 0; i < count; i++) {
          const ariaChecked = await switches.nth(i).getAttribute('aria-checked');
          expect(['true', 'false']).toContain(ariaChecked);
        }
      });

      test('has accessible name', async ({ page }) => {
        const switches = getSwitches(page);
        const count = await switches.count();

        for (let i = 0; i < count; i++) {
          const switchEl = switches.nth(i);
          const text = await switchEl.textContent();
          const ariaLabel = await switchEl.getAttribute('aria-label');
          const ariaLabelledby = await switchEl.getAttribute('aria-labelledby');
          const hasAccessibleName =
            (text && text.trim().length > 0) || ariaLabel !== null || ariaLabelledby !== null;
          expect(hasAccessibleName).toBe(true);
        }
      });
    });

    // 🔴 High Priority: Click Interaction
    test.describe('APG: Click Interaction', () => {
      test('toggles aria-checked on click', async ({ page }) => {
        const switchEl = getSwitches(page).first();
        const initialState = await switchEl.getAttribute('aria-checked');

        await switchEl.click();

        const newState = await switchEl.getAttribute('aria-checked');
        expect(newState).not.toBe(initialState);

        // Click again to toggle back
        await switchEl.click();
        const finalState = await switchEl.getAttribute('aria-checked');
        expect(finalState).toBe(initialState);
      });
    });

    // 🔴 High Priority: Keyboard Interaction
    test.describe('APG: Keyboard Interaction', () => {
      test('toggles on Space key', async ({ page }) => {
        const switchEl = getSwitches(page).first();
        const initialState = await switchEl.getAttribute('aria-checked');

        await switchEl.focus();
        await expect(switchEl).toBeFocused();
        await switchEl.press('Space');

        const newState = await switchEl.getAttribute('aria-checked');
        expect(newState).not.toBe(initialState);
      });

      test('toggles on Enter key', async ({ page }) => {
        const switchEl = getSwitches(page).first();
        const initialState = await switchEl.getAttribute('aria-checked');

        await switchEl.focus();
        await expect(switchEl).toBeFocused();
        await switchEl.press('Enter');

        const newState = await switchEl.getAttribute('aria-checked');
        expect(newState).not.toBe(initialState);
      });

      test('is focusable via Tab', async ({ page }) => {
        const switchEl = getSwitches(page).first();

        // Tab to the switch
        let found = false;
        for (let i = 0; i < 20; i++) {
          await page.keyboard.press('Tab');
          if (await switchEl.evaluate((el) => el === document.activeElement)) {
            found = true;
            break;
          }
        }

        expect(found).toBe(true);
      });
    });

    // 🔴 High Priority: Disabled State
    test.describe('Disabled State', () => {
      test('disabled switch has aria-disabled="true"', async ({ page }) => {
        const disabledSwitch = page.locator('[role="switch"][aria-disabled="true"]');

        if ((await disabledSwitch.count()) > 0) {
          await expect(disabledSwitch.first()).toHaveAttribute('aria-disabled', 'true');
        }
      });

      test('disabled switch does not toggle on click', async ({ page }) => {
        const disabledSwitch = page.locator('[role="switch"][aria-disabled="true"]');

        if ((await disabledSwitch.count()) > 0) {
          const initialState = await disabledSwitch.first().getAttribute('aria-checked');
          await disabledSwitch.first().click({ force: true });
          const newState = await disabledSwitch.first().getAttribute('aria-checked');
          expect(newState).toBe(initialState);
        }
      });

      test('disabled switch does not toggle on keyboard', async ({ page }) => {
        const disabledSwitch = page.locator('[role="switch"][aria-disabled="true"]');

        if ((await disabledSwitch.count()) > 0) {
          const initialState = await disabledSwitch.first().getAttribute('aria-checked');
          await disabledSwitch.first().focus();
          await page.keyboard.press('Space');
          const newState = await disabledSwitch.first().getAttribute('aria-checked');
          expect(newState).toBe(initialState);
        }
      });
    });

    // 🟡 Medium Priority: Accessibility
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        const switches = getSwitches(page);
        await switches.first().waitFor();

        const accessibilityScanResults = await new AxeBuilder({ page })
          .include('[role="switch"]')
          .analyze();

        expect(accessibilityScanResults.violations).toEqual([]);
      });
    });
  });
}

// Cross-framework consistency tests
test.describe('Switch - Cross-framework Consistency', () => {
  test('all frameworks have switch elements', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/switch/${framework}/demo/`);
      await page.waitForLoadState('networkidle');

      const switches = page.locator('[role="switch"]');
      const count = await switches.count();
      expect(count).toBeGreaterThan(0);
    }
  });

  test('all frameworks toggle correctly on click', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/switch/${framework}/demo/`);
      await page.waitForLoadState('networkidle');

      const switchEl = page.locator('[role="switch"]').first();
      const initialState = await switchEl.getAttribute('aria-checked');

      await switchEl.click();

      const newState = await switchEl.getAttribute('aria-checked');
      expect(newState).not.toBe(initialState);
    }
  });

  test('all frameworks have consistent ARIA structure', async ({ page }) => {
    const ariaStructures: Record<string, unknown[]> = {};

    for (const framework of frameworks) {
      await page.goto(`patterns/switch/${framework}/demo/`);
      await page.waitForLoadState('networkidle');

      ariaStructures[framework] = await page.evaluate(() => {
        const switches = document.querySelectorAll('[role="switch"]');
        return Array.from(switches).map((switchEl) => ({
          hasAriaChecked: switchEl.hasAttribute('aria-checked'),
          hasAccessibleName:
            (switchEl.textContent && switchEl.textContent.trim().length > 0) ||
            switchEl.hasAttribute('aria-label') ||
            switchEl.hasAttribute('aria-labelledby'),
        }));
      });
    }

    // All frameworks should have the same structure
    const reactStructure = ariaStructures['react'];
    for (const framework of frameworks) {
      expect(ariaStructures[framework]).toEqual(reactStructure);
    }
  });
});

Running Tests

          
            # Run unit tests for Switch
npm run test -- switch

# Run E2E tests for Switch (all frameworks)
npm run test:e2e:pattern --pattern=switch
          
        

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

Switch.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Switch } from './Switch';

describe('Switch', () => {
  // 🔴 High Priority: APG Core Compliance
  describe('APG: ARIA Attributes', () => {
    it('has role="switch"', () => {
      render(<Switch>Wi-Fi</Switch>);
      expect(screen.getByRole('switch')).toBeInTheDocument();
    });

    it('has aria-checked="false" in initial state', () => {
      render(<Switch>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');
      expect(switchEl).toHaveAttribute('aria-checked', 'false');
    });

    it('changes to aria-checked="true" after click', async () => {
      const user = userEvent.setup();
      render(<Switch>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');

      expect(switchEl).toHaveAttribute('aria-checked', 'false');
      await user.click(switchEl);
      expect(switchEl).toHaveAttribute('aria-checked', 'true');
    });

    it('has type="button"', () => {
      render(<Switch>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');
      expect(switchEl).toHaveAttribute('type', 'button');
    });

    it('has aria-disabled when disabled', () => {
      render(<Switch disabled>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');
      expect(switchEl).toHaveAttribute('aria-disabled', 'true');
    });

    it('cannot change aria-checked when disabled', async () => {
      const user = userEvent.setup();
      render(<Switch disabled>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');

      expect(switchEl).toHaveAttribute('aria-checked', 'false');
      await user.click(switchEl);
      expect(switchEl).toHaveAttribute('aria-checked', 'false');
    });
  });

  describe('APG: Keyboard Interaction', () => {
    it('toggles with Space key', async () => {
      const user = userEvent.setup();
      render(<Switch>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');

      expect(switchEl).toHaveAttribute('aria-checked', 'false');
      switchEl.focus();
      await user.keyboard(' ');
      expect(switchEl).toHaveAttribute('aria-checked', 'true');
    });

    it('toggles with Enter key', async () => {
      const user = userEvent.setup();
      render(<Switch>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');

      expect(switchEl).toHaveAttribute('aria-checked', 'false');
      switchEl.focus();
      await user.keyboard('{Enter}');
      expect(switchEl).toHaveAttribute('aria-checked', 'true');
    });

    it('can move focus with Tab key', async () => {
      const user = userEvent.setup();
      render(
        <>
          <Switch>Switch 1</Switch>
          <Switch>Switch 2</Switch>
        </>
      );

      await user.tab();
      expect(screen.getByRole('switch', { name: 'Switch 1' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('switch', { name: 'Switch 2' })).toHaveFocus();
    });

    it('skips with Tab key when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <Switch>Switch 1</Switch>
          <Switch disabled>Switch 2</Switch>
          <Switch>Switch 3</Switch>
        </>
      );

      await user.tab();
      expect(screen.getByRole('switch', { name: 'Switch 1' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('switch', { name: 'Switch 3' })).toHaveFocus();
    });

    it('keyboard operation disabled when disabled', async () => {
      const user = userEvent.setup();
      render(<Switch disabled>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');

      switchEl.focus();
      await user.keyboard(' ');
      expect(switchEl).toHaveAttribute('aria-checked', 'false');
    });
  });

  // 🟡 Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('has no WCAG 2.1 AA violations', async () => {
      const { container } = render(<Switch>Wi-Fi</Switch>);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has accessible name via label (children)', () => {
      render(<Switch>Wi-Fi</Switch>);
      expect(screen.getByRole('switch', { name: 'Wi-Fi' })).toBeInTheDocument();
    });

    it('can set accessible name via aria-label', () => {
      render(<Switch aria-label="Enable notifications" />);
      expect(screen.getByRole('switch', { name: 'Enable notifications' })).toBeInTheDocument();
    });

    it('can reference external label via aria-labelledby', () => {
      render(
        <>
          <span id="switch-label">Bluetooth</span>
          <Switch aria-labelledby="switch-label" />
        </>
      );
      expect(screen.getByRole('switch', { name: 'Bluetooth' })).toBeInTheDocument();
    });
  });

  describe('Props', () => {
    it('renders in ON state with initialChecked=true', () => {
      render(<Switch initialChecked>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');
      expect(switchEl).toHaveAttribute('aria-checked', 'true');
    });

    it('calls onCheckedChange when state changes', async () => {
      const handleCheckedChange = vi.fn();
      const user = userEvent.setup();
      render(<Switch onCheckedChange={handleCheckedChange}>Wi-Fi</Switch>);

      await user.click(screen.getByRole('switch'));
      expect(handleCheckedChange).toHaveBeenCalledWith(true);

      await user.click(screen.getByRole('switch'));
      expect(handleCheckedChange).toHaveBeenCalledWith(false);
    });
  });

  // 🟢 Low Priority: Extensibility
  describe('HTML Attribute Inheritance', () => {
    it('merges className correctly', () => {
      render(<Switch className="custom-class">Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');
      expect(switchEl).toHaveClass('custom-class');
      expect(switchEl).toHaveClass('apg-switch');
    });

    it('inherits data-* attributes', () => {
      render(<Switch data-testid="custom-switch">Wi-Fi</Switch>);
      expect(screen.getByTestId('custom-switch')).toBeInTheDocument();
    });
  });
});

Resources