Toggle Button
A two-state button that can be either "pressed" or "not pressed".
🤖 AI Implementation GuideDemo
Accessibility Features
WAI-ARIA Roles
-
button- Indicates a widget that triggers an action when activated
WAI-ARIA button role (opens in new tab)
WAI-ARIA States
aria-pressed
Indicates the current pressed state of the toggle button.
| Values | true | false (tri-state buttons may also use "mixed") |
| Required | Yes (for toggle buttons) |
| Default | initialPressed prop (default: false) |
| Change Trigger | Click, Enter, Space |
| Reference | aria-pressed (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Space | Toggle the button state |
| Enter | Toggle the button state |
Source Code
import { cn } from '@/lib/utils';
import { useCallback, useState } from 'react';
export interface ToggleButtonProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'onClick' | 'type' | 'aria-pressed' | 'onToggle'
> {
/** Initial pressed state */
initialPressed?: boolean;
/** Button label text */
children: React.ReactNode;
/** Callback fired when toggle state changes */
onPressedChange?: (pressed: boolean) => void;
/** Custom indicator for pressed state (default: "●") */
pressedIndicator?: React.ReactNode;
/** Custom indicator for unpressed state (default: "○") */
unpressedIndicator?: React.ReactNode;
}
export const ToggleButton: React.FC<ToggleButtonProps> = ({
initialPressed = false,
children,
onPressedChange,
pressedIndicator = '●',
unpressedIndicator = '○',
className = '',
...buttonProps
}) => {
const [pressed, setPressed] = useState(initialPressed);
const handleClick = useCallback(() => {
setPressed(!pressed);
onPressedChange?.(!pressed);
}, [pressed, onPressedChange]);
return (
<button
type="button"
{...buttonProps}
className={cn('apg-toggle-button', className)}
aria-pressed={pressed}
onClick={handleClick}
>
<span className="apg-toggle-button-content">{children}</span>
<span className="apg-toggle-indicator" aria-hidden="true">
{pressed ? pressedIndicator : unpressedIndicator}
</span>
</button>
);
};
export default ToggleButton; Usage
import { ToggleButton } from './ToggleButton';
import { Volume2, VolumeOff } from 'lucide-react';
function App() {
return (
<ToggleButton
initialPressed={false}
onPressedChange={(pressed) => console.log('Muted:', pressed)}
pressedIndicator={<VolumeOff size={20} />}
unpressedIndicator={<Volume2 size={20} />}
>
Mute
</ToggleButton>
);
} API
| Prop | Type | Default | Description |
|---|---|---|---|
initialPressed | boolean | false | Initial pressed state |
onPressedChange | (pressed: boolean) => void | - | Callback when state changes |
pressedIndicator | ReactNode | "●" | Custom indicator for pressed state |
unpressedIndicator | ReactNode | "○" | Custom indicator for unpressed state |
children | ReactNode | - | Button 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 Toggle Button component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library)
Verify component rendering and interactions using framework-specific Testing Library utilities. These tests ensure correct component behavior in isolation.
- HTML structure and element hierarchy
- Initial attribute values (aria-pressed, type)
- Click event handling and state toggling
- CSS class application
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all four frameworks. These tests cover interactions that require full browser context.
- Keyboard interactions (Space, Enter)
- aria-pressed state toggling
- Disabled state behavior
- Focus management and Tab navigation
- Cross-framework consistency
Test Categories
High Priority: APG Keyboard Interaction (E2E)
| Test | Description |
|---|---|
Space key toggles | Pressing Space toggles the button state |
Enter key toggles | Pressing Enter toggles the button state |
Tab navigation | Tab key moves focus between buttons |
Disabled Tab skip | Disabled buttons are skipped in Tab order |
High Priority: APG ARIA Attributes (E2E)
| Test | Description |
|---|---|
role="button" | Has implicit button role (via <button>) |
aria-pressed initial | Initial state is aria-pressed="false" |
aria-pressed toggle | Click changes aria-pressed to true |
type="button" | Explicit button type prevents form submission |
disabled state | Disabled buttons don't change state on click |
Medium Priority: Accessibility (E2E)
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
accessible name | Button has an accessible name from content |
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 |
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.
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 { ToggleButton } from './ToggleButton';
describe('ToggleButton', () => {
// 🔴 High Priority: APG Core Compliance
describe('APG: Keyboard Interaction', () => {
it('toggles with Space key', async () => {
const user = userEvent.setup();
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
button.focus();
await user.keyboard(' ');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('toggles with Enter key', async () => {
const user = userEvent.setup();
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
button.focus();
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('can move focus with Tab key', async () => {
const user = userEvent.setup();
render(
<>
<ToggleButton>Button 1</ToggleButton>
<ToggleButton>Button 2</ToggleButton>
</>
);
await user.tab();
expect(screen.getByRole('button', { name: 'Button 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'Button 2' })).toHaveFocus();
});
it('skips with Tab key when disabled', async () => {
const user = userEvent.setup();
render(
<>
<ToggleButton>Button 1</ToggleButton>
<ToggleButton disabled>Button 2</ToggleButton>
<ToggleButton>Button 3</ToggleButton>
</>
);
await user.tab();
expect(screen.getByRole('button', { name: 'Button 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'Button 3' })).toHaveFocus();
});
});
describe('APG: ARIA Attributes', () => {
it('has implicit role="button"', () => {
render(<ToggleButton>Mute</ToggleButton>);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('has aria-pressed="false" in initial state', () => {
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
});
it('changes to aria-pressed="true" after click', async () => {
const user = userEvent.setup();
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.click(button);
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('has type="button"', () => {
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('type', 'button');
});
it('cannot change aria-pressed when disabled', async () => {
const user = userEvent.setup();
render(<ToggleButton disabled>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.click(button);
expect(button).toHaveAttribute('aria-pressed', 'false');
});
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no WCAG 2.1 AA violations', async () => {
const { container } = render(<ToggleButton>Mute</ToggleButton>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has accessible name', () => {
render(<ToggleButton>Mute Audio</ToggleButton>);
expect(screen.getByRole('button', { name: /Mute Audio/i })).toBeInTheDocument();
});
});
describe('Props', () => {
it('renders in pressed state with initialPressed=true', () => {
render(<ToggleButton initialPressed>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('calls onPressedChange when state changes', async () => {
const handlePressedChange = vi.fn();
const user = userEvent.setup();
render(<ToggleButton onPressedChange={handlePressedChange}>Mute</ToggleButton>);
await user.click(screen.getByRole('button'));
expect(handlePressedChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole('button'));
expect(handlePressedChange).toHaveBeenCalledWith(false);
});
});
describe('Custom Indicators', () => {
it('displays default ●/○ indicator', () => {
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
const indicator = button.querySelector('.apg-toggle-indicator');
expect(indicator).toHaveTextContent('○');
});
it('can set custom indicator with pressedIndicator', () => {
render(
<ToggleButton initialPressed pressedIndicator="🔇">
Mute
</ToggleButton>
);
const button = screen.getByRole('button');
const indicator = button.querySelector('.apg-toggle-indicator');
expect(indicator).toHaveTextContent('🔇');
});
it('can set custom indicator with unpressedIndicator', () => {
render(<ToggleButton unpressedIndicator="🔊">Mute</ToggleButton>);
const button = screen.getByRole('button');
const indicator = button.querySelector('.apg-toggle-indicator');
expect(indicator).toHaveTextContent('🔊');
});
it('switches custom indicator on toggle', async () => {
const user = userEvent.setup();
render(
<ToggleButton pressedIndicator="🔇" unpressedIndicator="🔊">
Mute
</ToggleButton>
);
const button = screen.getByRole('button');
const indicator = button.querySelector('.apg-toggle-indicator');
expect(indicator).toHaveTextContent('🔊');
await user.click(button);
expect(indicator).toHaveTextContent('🔇');
await user.click(button);
expect(indicator).toHaveTextContent('🔊');
});
it('can pass ReactNode as custom indicator', () => {
render(
<ToggleButton initialPressed pressedIndicator={<span data-testid="custom-icon">X</span>}>
Mute
</ToggleButton>
);
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});
it('maintains aria-hidden with custom indicator', () => {
render(
<ToggleButton pressedIndicator="🔇" unpressedIndicator="🔊">
Mute
</ToggleButton>
);
const button = screen.getByRole('button');
const indicator = button.querySelector('.apg-toggle-indicator');
expect(indicator).toHaveAttribute('aria-hidden', 'true');
});
it('has no axe violations with custom indicator', async () => {
const { container } = render(
<ToggleButton pressedIndicator="🔇" unpressedIndicator="🔊">
Mute
</ToggleButton>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Extensibility
describe('HTML Attribute Inheritance', () => {
it('merges className correctly', () => {
render(<ToggleButton className="custom-class">Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveClass('custom-class');
expect(button).toHaveClass('apg-toggle-button');
});
it('inherits data-* attributes', () => {
render(<ToggleButton data-testid="custom-toggle">Mute</ToggleButton>);
expect(screen.getByTestId('custom-toggle')).toBeInTheDocument();
});
it('works correctly with React node children', () => {
render(
<ToggleButton>
<span>Icon</span> Text
</ToggleButton>
);
const button = screen.getByRole('button');
expect(button).toHaveTextContent('Icon');
expect(button).toHaveTextContent('Text');
});
});
}); Resources
- WAI-ARIA APG: Button Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist