Switch
A control that allows users to toggle between two states: on and off.
Demo
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
switch | Switch element | An 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
| Key | Action |
|---|---|
| Space | Toggle the switch state (on/off) |
| Enter | Toggle 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
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
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
- WAI-ARIA APG: Switch Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist