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.vue
<template>
<button
type="button"
class="apg-toggle-button"
:aria-pressed="pressed"
:disabled="props.disabled"
v-bind="$attrs"
@click="handleClick"
>
<span class="apg-toggle-button-content">
<slot />
</span>
<span class="apg-toggle-indicator" aria-hidden="true">
<template v-if="pressed">
<slot name="pressed-indicator">{{ props.pressedIndicator ?? 'โ' }}</slot>
</template>
<template v-else>
<slot name="unpressed-indicator">{{ props.unpressedIndicator ?? 'โ' }}</slot>
</template>
</span>
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// Inherit all HTML button attributes
defineOptions({
inheritAttrs: false,
});
export interface ToggleButtonProps {
/** Initial pressed state */
initialPressed?: boolean;
/** Whether the button is disabled */
disabled?: boolean;
/** Callback fired when toggle state changes */
onToggle?: (pressed: boolean) => void;
/** Custom indicator for pressed state (default: "โ") */
pressedIndicator?: string;
/** Custom indicator for unpressed state (default: "โ") */
unpressedIndicator?: string;
}
const props = withDefaults(defineProps<ToggleButtonProps>(), {
initialPressed: false,
disabled: false,
onToggle: undefined,
pressedIndicator: undefined,
unpressedIndicator: undefined,
});
const emit = defineEmits<{
toggle: [pressed: boolean];
}>();
defineSlots<{
default(): unknown;
'pressed-indicator'(): unknown;
'unpressed-indicator'(): unknown;
}>();
const pressed = ref(props.initialPressed);
const handleClick = () => {
const newPressed = !pressed.value;
pressed.value = newPressed;
// Call onToggle prop if provided (for React compatibility)
props.onToggle?.(newPressed);
// Emit Vue event
emit('toggle', newPressed);
};
</script> Usage
Example
<script setup>
import ToggleButton from './ToggleButton.vue';
import { Volume2, VolumeOff } from 'lucide-vue-next';
const handleToggle = (pressed) => {
console.log('Muted:', pressed);
};
</script>
<template>
<ToggleButton
:initial-pressed="false"
@toggle="handleToggle"
>
<template #pressed-indicator>
<VolumeOff :size="20" />
</template>
<template #unpressed-indicator>
<Volume2 :size="20" />
</template>
Mute
</ToggleButton>
</template> API
| Prop | Type | Default | Description |
|---|---|---|---|
initialPressed | boolean | false | Initial pressed state |
pressedIndicator | string | "โ" | Custom indicator for pressed state |
unpressedIndicator | string | "โ" | Custom indicator for unpressed state |
Slots
| Slot | Default | Description |
|---|---|---|
default | - | Button label content |
pressed-indicator | "โ" | Custom indicator for pressed state |
unpressed-indicator | "โ" | Custom indicator for unpressed state |
All other attributes are passed to the underlying
<button> element via v-bind="$attrs". Custom Events
| Event | Detail | Description |
|---|---|---|
toggle | boolean | Emitted when 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.
ToggleButton.test.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import ToggleButton from './ToggleButton.vue';
describe('ToggleButton (Vue)', () => {
// ๐ด High Priority: APG ๆบๆ ใฎๆ ธๅฟ
describe('APG: ใญใผใใผใๆไฝ', () => {
it('Space ใญใผใงใใฐใซใใ', async () => {
const user = userEvent.setup();
render(ToggleButton, {
slots: { default: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
button.focus();
await user.keyboard(' ');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('Enter ใญใผใงใใฐใซใใ', async () => {
const user = userEvent.setup();
render(ToggleButton, {
slots: { default: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
button.focus();
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('Tab ใญใผใงใใฉใผใซใน็งปๅๅฏ่ฝ', async () => {
const user = userEvent.setup();
render({
components: { ToggleButton },
template: `
<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('disabled ๆใฏ Tab ใญใผในใญใใ', async () => {
const user = userEvent.setup();
render({
components: { ToggleButton },
template: `
<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 ๅฑๆง', () => {
it('role="button" ใๆใค๏ผๆ้ป็๏ผ', () => {
render(ToggleButton, {
slots: { default: 'Mute' },
});
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('ๅๆ็ถๆ
ใง aria-pressed="false"', () => {
render(ToggleButton, {
slots: { default: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
});
it('ใฏใชใใฏๅพใซ aria-pressed="true" ใซๅคใใ', async () => {
const user = userEvent.setup();
render(ToggleButton, {
slots: { default: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.click(button);
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('type="button" ใ่จญๅฎใใใฆใใ', () => {
render(ToggleButton, {
slots: { default: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('type', 'button');
});
it('disabled ็ถๆ
ใง aria-pressed ๅคๆดไธๅฏ', async () => {
const user = userEvent.setup();
render(ToggleButton, {
props: { disabled: true },
slots: { default: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.click(button);
expect(button).toHaveAttribute('aria-pressed', 'false');
});
});
// ๐ก Medium Priority: ใขใฏใปใทใใชใใฃๆค่จผ
describe('ใขใฏใปใทใใชใใฃ', () => {
it('axe ใซใใ WCAG 2.1 AA ้ๅใใชใ', async () => {
const { container } = render(ToggleButton, {
slots: { default: 'Mute' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('ใขใฏใปใทใใซใใผใ ใ่จญๅฎใใใฆใใ', () => {
render(ToggleButton, {
slots: { default: 'Mute Audio' },
});
expect(screen.getByRole('button', { name: /Mute Audio/i })).toBeInTheDocument();
});
});
describe('Props', () => {
it('initialPressed=true ใงๆผไธ็ถๆ
ใงใฌใณใใชใณใฐใใใ', () => {
render(ToggleButton, {
props: { initialPressed: true },
slots: { default: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('onToggle ใ็ถๆ
ๅคๅๆใซๅผใณๅบใใใ', async () => {
const handleToggle = vi.fn();
const user = userEvent.setup();
render(ToggleButton, {
props: { onToggle: handleToggle },
slots: { default: 'Mute' },
});
await user.click(screen.getByRole('button'));
expect(handleToggle).toHaveBeenCalledWith(true);
await user.click(screen.getByRole('button'));
expect(handleToggle).toHaveBeenCalledWith(false);
});
it('@toggle ใคใใณใใ็ถๆ
ๅคๅๆใซ็บ็ซใใ', async () => {
const handleToggle = vi.fn();
const user = userEvent.setup();
render(ToggleButton, {
props: { onToggle: handleToggle },
slots: { default: 'Mute' },
});
await user.click(screen.getByRole('button'));
expect(handleToggle).toHaveBeenCalledWith(true);
});
});
// ๐ข Low Priority: ๆกๅผตๆง
describe('HTML ๅฑๆง็ถๆฟ', () => {
it('class ใๆญฃใใใใผใธใใใ', () => {
render(ToggleButton, {
attrs: { class: 'custom-class' },
slots: { default: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveClass('custom-class');
expect(button).toHaveClass('apg-toggle-button');
});
it('data-* ๅฑๆงใ็ถๆฟใใใ', () => {
render(ToggleButton, {
attrs: { 'data-testid': 'custom-toggle' },
slots: { default: 'Mute' },
});
expect(screen.getByTestId('custom-toggle')).toBeInTheDocument();
});
});
}); 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