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

PropTypeDefaultDescription
initialPressedbooleanfalseInitial pressed state
disabledbooleanfalseWhether the button is disabled
classstring""Additional CSS class

Slots

SlotDefaultDescription
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

EventDetailDescription
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)

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.

Resources