APG Patterns
日本語
日本語

Toggle Button

A two-state button that can be either "pressed" or "not pressed".

Demo

Open demo only →

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.tsx
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

Example
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

PropTypeDefaultDescription
initialPressedbooleanfalseInitial pressed state
onPressedChange(pressed: boolean) => void-Callback when state changes
pressedIndicatorReactNode"●"Custom indicator for pressed state
unpressedIndicatorReactNode"○"Custom indicator for unpressed state
childrenReactNode-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)

testdescription
Space key togglesPressing Space toggles the button state
Enter key togglesPressing Enter toggles the button state
Tab navigationTab key moves focus between buttons
Disabled Tab skipDisabled buttons are skipped in Tab order

High Priority: APG ARIA Attributes (E2E)

testdescription
role="button"Has implicit button role (via <code>&lt;button&gt;</code>)
aria-pressed initialInitial state is aria-pressed="false"
aria-pressed toggleClick changes aria-pressed to true
type="button"Explicit button type prevents form submission
disabled stateDisabled buttons don't change state on click

Medium Priority: Accessibility (E2E)

testdescription
axe violationsNo WCAG 2.1 AA violations (via jest-axe)
accessible nameButton has an accessible name from content

Low Priority: HTML Attribute Inheritance (Unit)

testdescription
className mergeCustom classes are merged with component classes
data-* attributesCustom data attributes are passed through

Testing Tools

See the Testing Strategy guide for details.

ToggleButton.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 { 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