Toggle Button
A two-state button that can be either "pressed" or "not pressed".
Demo
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
button | Button element | Indicates a widget that triggers an action when activated |
WAI-ARIA States
aria-pressed
- Target Element
- button
- Values
- true | false
- Required
- Yes
- Change Trigger
- Click, Enter, Space
Keyboard Support
| Key | Action |
|---|---|
| Space | Toggle the button state |
| Enter | Toggle the button state |
- Toggle buttons must have an accessible name via visible label text, aria-label, or aria-labelledby.
- Use type=โbuttonโ to prevent accidental form submission.
- Tri-state buttons may use aria-pressed=โmixedโ for partially selected state (e.g., โSelect Allโ when some items selected).
Implementation Notes
Structure:
<button type="button" aria-pressed="false">
Mute
</button>
State Changes:
- Initial: aria-pressed="false" (not pressed)
- After click: aria-pressed="true" (pressed)
Use type="button":
- Prevents accidental form submission
- Native <button> defaults to type="submit"
Tri-state (rare):
- aria-pressed="mixed" for partially selected state
- Example: "Select All" when some items selected
Toggle Button structure and state changes
References
Source Code
ToggleButton.astro
---
/**
* APG Toggle Button Pattern - Astro Implementation
*
* A two-state button that can be either "pressed" or "not pressed".
* Uses Web Components for client-side interactivity.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/button/
*/
export interface Props {
/** Initial pressed state */
initialPressed?: boolean;
/** Whether the button is disabled */
disabled?: boolean;
/** Additional CSS class */
class?: string;
}
const { initialPressed = false, disabled = false, class: className = '' } = Astro.props;
// Check if custom indicator slots are provided
const hasPressedIndicator = Astro.slots.has('pressed-indicator');
const hasUnpressedIndicator = Astro.slots.has('unpressed-indicator');
const hasCustomIndicators = hasPressedIndicator || hasUnpressedIndicator;
---
<apg-toggle-button class={className}>
<button type="button" class="apg-toggle-button" aria-pressed={initialPressed} disabled={disabled}>
<span class="apg-toggle-button-content">
<slot />
</span>
<span
class="apg-toggle-indicator"
aria-hidden="true"
data-custom-indicators={hasCustomIndicators ? 'true' : undefined}
>
{
hasCustomIndicators ? (
<>
<span class="apg-indicator-pressed" hidden={!initialPressed}>
<slot name="pressed-indicator">โ</slot>
</span>
<span class="apg-indicator-unpressed" hidden={initialPressed}>
<slot name="unpressed-indicator">โ</slot>
</span>
</>
) : initialPressed ? (
'โ'
) : (
'โ'
)
}
</span>
</button>
</apg-toggle-button>
<script>
class ApgToggleButton extends HTMLElement {
private button: HTMLButtonElement | null = null;
private rafId: number | null = null;
connectedCallback() {
// Use requestAnimationFrame to ensure DOM is fully constructed
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.button = this.querySelector('button');
if (!this.button) {
console.warn('apg-toggle-button: button element not found');
return;
}
this.button.addEventListener('click', this.handleClick);
}
disconnectedCallback() {
// Cancel pending initialization
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// Remove event listeners
this.button?.removeEventListener('click', this.handleClick);
this.button = null;
}
private handleClick = () => {
if (!this.button || this.button.disabled) return;
const currentPressed = this.button.getAttribute('aria-pressed') === 'true';
const newPressed = !currentPressed;
// Update aria-pressed (CSS uses [aria-pressed] selectors)
this.button.setAttribute('aria-pressed', String(newPressed));
// Update indicator
const indicator = this.button.querySelector('.apg-toggle-indicator');
if (indicator) {
const hasCustomIndicators = indicator.getAttribute('data-custom-indicators') === 'true';
if (hasCustomIndicators) {
// Toggle visibility of custom indicator slots
const pressedIndicator = indicator.querySelector('.apg-indicator-pressed');
const unpressedIndicator = indicator.querySelector('.apg-indicator-unpressed');
if (pressedIndicator instanceof HTMLElement) {
pressedIndicator.hidden = !newPressed;
}
if (unpressedIndicator instanceof HTMLElement) {
unpressedIndicator.hidden = newPressed;
}
} else {
// Use default text indicators
indicator.textContent = newPressed ? 'โ' : 'โ';
}
}
// Dispatch custom event for external listeners
this.dispatchEvent(
new CustomEvent('toggle', {
detail: { pressed: newPressed },
bubbles: true,
})
);
};
}
// Register the custom element
if (!customElements.get('apg-toggle-button')) {
customElements.define('apg-toggle-button', ApgToggleButton);
}
</script> Usage
Example
---
import ToggleButton from './ToggleButton.astro';
import Icon from './Icon.astro';
---
<ToggleButton>
<Icon name="volume-off" slot="pressed-indicator" />
<Icon name="volume-2" slot="unpressed-indicator" />
Mute
</ToggleButton>
<script>
// Listen for toggle events
document.querySelector('apg-toggle-button')?.addEventListener('toggle', (e) => {
console.log('Muted:', e.detail.pressed);
});
</script> API
| Prop | Type | Default | Description |
|---|---|---|---|
initialPressed | boolean | false | Initial pressed state |
disabled | boolean | false | Whether the button is disabled |
class | string | "" | Additional CSS class |
Slots
| Slot | Default | Description |
|---|---|---|
default | - | Button label content |
pressed-indicator | "โ" | Custom indicator for pressed state |
unpressed-indicator | "โ" | Custom indicator for unpressed state |
This component uses a Web Component (
<apg-toggle-button>) for client-side interactivity. Custom Events
| Event | Detail | Description |
|---|---|---|
toggle | { pressed: boolean } | Fired when the toggle state changes |
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 <code><button></code>) |
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.
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