APG Patterns

Toggle Button

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

Demo

Accessibility Features

WAI-ARIA Roles

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

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;

const stateClass = initialPressed
  ? "apg-toggle-button--pressed"
  : "apg-toggle-button--not-pressed";

const indicatorClass = initialPressed
  ? "apg-toggle-indicator--pressed"
  : "apg-toggle-indicator--not-pressed";

// 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 ${stateClass}`}
    aria-pressed={initialPressed}
    disabled={disabled}
  >
    <span class="apg-toggle-button-content">
      <slot />
    </span>
    <span class={`apg-toggle-indicator ${indicatorClass}`} 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
      this.button.setAttribute("aria-pressed", String(newPressed));

      // Update CSS classes
      this.button.classList.toggle("apg-toggle-button--pressed", newPressed);
      this.button.classList.toggle(
        "apg-toggle-button--not-pressed",
        !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 ? "●" : "β—‹";
        }

        indicator.classList.toggle("apg-toggle-indicator--pressed", newPressed);
        indicator.classList.toggle(
          "apg-toggle-indicator--not-pressed",
          !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

Props

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

Custom Events

Event Detail Description
toggle { pressed: boolean } Fired when the toggle state changes

This component uses a Web Component (<apg-toggle-button>) for client-side interactivity.

Resources