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

WAI-ARIA States

aria-checked

Values true | false
Required Yes (for switch role)
Default initialChecked prop (default: false)
Change Trigger Click, Enter, Space
Reference aria-checked (opens in new tab)

aria-disabled

Values true | undefined
Required No (only when disabled)
Change Trigger Only when disabled
Reference aria-disabled (opens in new tab)

Keyboard Support

Key Action
Space Toggle the switch state (on/off)
Enter Toggle the switch state (on/off)

Accessible Naming

Switches must have an accessible name. This can be provided through:

  • Visible label (recommended) - The switch's child content provides the accessible name
  • aria-label - Provides an invisible label for the switch
  • aria-labelledby - References an external element as the label

Visual Design

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 the switch is on
  • Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode

Source Code

Switch.astro
---
/**
 * APG Switch Pattern - Astro Implementation
 *
 * A control that allows users to toggle between two states: on and off.
 * Uses Web Components for client-side interactivity.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/switch/
 */

export interface Props {
  /** Initial checked state */
  initialChecked?: boolean;
  /** Whether the switch is disabled */
  disabled?: boolean;
  /** Additional CSS class */
  class?: string;
}

const { initialChecked = false, disabled = false, class: className = '' } = Astro.props;
---

<apg-switch class={className}>
  <button
    type="button"
    role="switch"
    class="apg-switch"
    aria-checked={initialChecked}
    aria-disabled={disabled || undefined}
    disabled={disabled}
  >
    <span class="apg-switch-track">
      <span class="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"></path>
        </svg>
      </span>
      <span class="apg-switch-thumb"></span>
    </span>
    {
      Astro.slots.has('default') && (
        <span class="apg-switch-label">
          <slot />
        </span>
      )
    }
  </button>
</apg-switch>

<script>
  class ApgSwitch extends HTMLElement {
    private button: HTMLButtonElement | null = null;
    private rafId: number | null = null;

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.button = this.querySelector('button[role="switch"]');
      if (!this.button) {
        console.warn('apg-switch: button element not found');
        return;
      }

      this.button.addEventListener('click', this.handleClick);
      this.button.addEventListener('keydown', this.handleKeyDown);
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      this.button?.removeEventListener('click', this.handleClick);
      this.button?.removeEventListener('keydown', this.handleKeyDown);
      this.button = null;
    }

    private toggle() {
      if (!this.button || this.button.disabled) return;

      const currentChecked = this.button.getAttribute('aria-checked') === 'true';
      const newChecked = !currentChecked;

      this.button.setAttribute('aria-checked', String(newChecked));

      this.dispatchEvent(
        new CustomEvent('change', {
          detail: { checked: newChecked },
          bubbles: true,
        })
      );
    }

    private handleClick = () => {
      this.toggle();
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === ' ' || event.key === 'Enter') {
        event.preventDefault();
        this.toggle();
      }
    };
  }

  if (!customElements.get('apg-switch')) {
    customElements.define('apg-switch', ApgSwitch);
  }
</script>

Usage

Example
---
import Switch from './Switch.astro';
---

<Switch initialChecked={false}>
  Enable notifications
</Switch>

<script>
  // Listen for change events
  document.querySelector('apg-switch')?.addEventListener('change', (e) => {
    console.log('Checked:', e.detail.checked);
  });
</script>

API

Prop Type Default Description
initialChecked boolean false Initial checked state
disabled boolean false Whether the switch is disabled
class string "" Additional CSS classes

Custom Events

Event Detail Description
change { checked: boolean } Fired when the switch state changes

Slots

Slot Description
default Switch label content

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.

Resources