APG Patterns
日本語
日本語

Button

An element that enables users to trigger an action or event using role="button".

Demo

Click me Disabled Button

Open demo only →

Native HTML

Use Native HTML First

Before using this custom component, consider using native <button> elements. They provide built-in accessibility, keyboard support, form integration, and work without JavaScript.

<button type="button" onclick="handleClick()">Click me</button>

<!-- For form submission -->
<button type="submit">Submit</button>

<!-- Disabled state -->
<button type="button" disabled>Disabled</button>

Use custom role="button" implementations only for educational purposes or when you must make a non-button element (e.g., <div>, <span>) behave like a button due to legacy constraints.

Feature Native Custom role="button"
Keyboard activation (Space/Enter) Built-in Requires JavaScript
Focus management Automatic Requires tabindex
disabled attribute Built-in Requires aria-disabled + JS
Form submission Built-in Not supported
type attribute submit/button/reset Not supported
Works without JavaScript Yes No
Screen reader announcement Automatic Requires ARIA
Space key scroll prevention Automatic Requires preventDefault()

This custom implementation is provided for educational purposes to demonstrate APG patterns. In production, always prefer native <button> elements.

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
button <button> or element with role="button" Identifies the element as a button widget. Native <button> has this role implicitly.

This implementation uses <code>&lt;span role="button"&gt;</code> for educational purposes. For production use, prefer native <code>&lt;button&gt;</code> elements.

WAI-ARIA Properties

tabindex (Makes the custom button element focusable via keyboard navigation)

Makes the custom button element focusable via keyboard navigation. Native <button> is focusable by default. Set to -1 when disabled.

Values "0" | "-1"
Required Yes (for custom implementations)

aria-disabled (Indicates the button is not interactive and cannot be activated)

Indicates the button is not interactive and cannot be activated. Native <button disabled> automatically handles this.

Values "true" | "false"
Required No (only when disabled)

aria-label (Provides an accessible name for icon-only buttons or when visible text is insufficient)

Provides an accessible name for icon-only buttons or when visible text is insufficient.

Values Text string describing the action
Required No (only for icon-only buttons)

Keyboard Support

Key Action
Space Activate the button
Enter Activate the button
Tab Move focus to the next focusable element
Shift + Tab Move focus to the previous focusable element

Important: Both Space and Enter keys activate buttons. This differs from links, which only respond to Enter. Custom implementations must call event.preventDefault() on Space to prevent page scrolling.

Accessible Naming

Buttons must have an accessible name. This can be provided through:

  • Text content (recommended) - The visible text inside the button
  • aria-label - Provides an invisible label for icon-only buttons
  • aria-labelledby - References an external element as the label

Focus Styles

This implementation provides clear focus indicators:

  • Focus ring - Visible outline when focused via keyboard
  • Cursor style - Pointer cursor to indicate interactivity
  • Disabled appearance - Reduced opacity and not-allowed cursor when disabled

Button vs Toggle Button

This pattern is for simple action buttons. For buttons that toggle between pressed and unpressed states, see the Toggle Button pattern which uses aria-pressed.

References

Source Code

Button.astro
---
/**
 * APG Button Pattern - Astro Implementation
 *
 * A custom button using role="button" on a non-button element.
 * Uses Web Components for enhanced interactivity.
 *
 * Note: This is a custom implementation for educational purposes.
 * For production use, prefer native <button> elements.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/button/
 */

export interface Props {
  /** Whether the button is disabled */
  disabled?: boolean;
  /** Additional CSS class */
  class?: string;
}

const { disabled = false, class: className = '' } = Astro.props;
---

<apg-button>
  <span
    role="button"
    tabindex={disabled ? -1 : 0}
    aria-disabled={disabled ? 'true' : undefined}
    class={`apg-button ${className}`.trim()}
  >
    <slot />
  </span>
</apg-button>

<script>
  class ApgButton extends HTMLElement {
    private spanElement: HTMLSpanElement | null = null;
    private rafId: number | null = null;
    // Track if Space was pressed on this element (for keyup activation)
    private spacePressed = false;

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.spanElement = this.querySelector('span[role="button"]');

      if (!this.spanElement) {
        console.warn('apg-button: span element not found');
        return;
      }

      this.spanElement.addEventListener('click', this.handleClick);
      this.spanElement.addEventListener('keydown', this.handleKeyDown);
      this.spanElement.addEventListener('keyup', this.handleKeyUp);
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      this.spanElement?.removeEventListener('click', this.handleClick);
      this.spanElement?.removeEventListener('keydown', this.handleKeyDown);
      this.spanElement?.removeEventListener('keyup', this.handleKeyUp);
      this.spanElement = null;
    }

    private isDisabled(): boolean {
      return this.spanElement?.getAttribute('aria-disabled') === 'true';
    }

    private handleClick = (event: MouseEvent) => {
      if (this.isDisabled()) {
        event.preventDefault();
        event.stopPropagation();
        return;
      }

      this.dispatchEvent(
        new CustomEvent('button-activate', {
          bubbles: true,
        })
      );
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      // Ignore if composing (IME input) or already handled
      if (event.isComposing || event.defaultPrevented) {
        return;
      }

      if (this.isDisabled()) {
        return;
      }

      // Space: prevent scroll on keydown, activate on keyup (native button behavior)
      if (event.key === ' ') {
        event.preventDefault();
        this.spacePressed = true;
        return;
      }

      // Enter: activate on keydown (native button behavior)
      if (event.key === 'Enter') {
        event.preventDefault();
        this.spanElement?.click();
      }
    };

    private handleKeyUp = (event: KeyboardEvent) => {
      // Space: activate on keyup if Space was pressed on this element
      if (event.key === ' ' && this.spacePressed) {
        this.spacePressed = false;

        if (this.isDisabled()) {
          return;
        }

        event.preventDefault();
        this.spanElement?.click();
      }
    };
  }

  if (!customElements.get('apg-button')) {
    customElements.define('apg-button', ApgButton);
  }
</script>

Usage

Example
---
import Button from './Button.astro';
---

<!-- Basic button -->
<Button>Click me</Button>

<!-- Disabled button -->
<Button disabled>Disabled</Button>

<!-- With aria-label for icon buttons -->
<Button aria-label="Settings">
  <SettingsIcon />
</Button>

<!-- With custom event listener (JavaScript) -->
<Button id="my-button">Interactive Button</Button>

<script>
  document.getElementById('my-button')
    ?.addEventListener('button-activate', (e) => {
      console.log('Button activated');
    });
</script>

API

Prop Type Default Description
disabled boolean false Whether the button is disabled
class string '' Additional CSS classes

Custom Events

Event Detail Description
button-activate - Fired when button is activated (click, Space, or Enter)

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Button component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.

  • ARIA attributes (role="button", tabindex)
  • Keyboard interaction (Space and Enter key activation)
  • Disabled state handling
  • Accessibility via jest-axe

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.

  • ARIA structure in live browser
  • Keyboard activation (Space and Enter key)
  • Click interaction behavior
  • Disabled state interactions
  • axe-core accessibility scanning
  • Cross-framework consistency checks

Important: Both Space and Enter keys activate buttons. This differs from links, which only respond to Enter. Custom implementations must call event.preventDefault() on Space to prevent page scrolling.

Test Categories

High Priority: APG Keyboard Interaction

TestDescription
Space keyActivates the button
Enter keyActivates the button
Space preventDefaultPrevents page scrolling when Space is pressed
IME composingIgnores Space/Enter during IME input
Tab navigationTab moves focus between buttons
Disabled Tab skipDisabled buttons are skipped in Tab order

High Priority: ARIA Attributes

TestDescription
role="button"Element has button role
tabindex="0"Element is focusable via keyboard
aria-disabledSet to "true" when disabled
tabindex="-1"Set when disabled to remove from Tab order
Accessible nameName from text content, aria-label, or aria-labelledby

High Priority: Click Behavior

TestDescription
Click activationClick activates the button
Disabled clickDisabled buttons ignore click events
Disabled SpaceDisabled buttons ignore Space key
Disabled EnterDisabled buttons ignore Enter key

Medium Priority: Accessibility

TestDescription
axe violationsNo WCAG 2.1 AA violations (via jest-axe)
disabled axeNo violations in disabled state
aria-label axeNo violations with aria-label

Low Priority: Props & Attributes

TestDescription
classNameCustom classes are applied
data-* attributesCustom data attributes are passed through
childrenChild content is rendered

Low Priority: Cross-framework Consistency

TestDescription
All frameworks have buttonsReact, Vue, Svelte, Astro all render custom button elements
Same button countAll frameworks render the same number of buttons
Consistent ARIAAll frameworks have consistent ARIA structure

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

Button.test.astro.ts
/**
 * Button Web Component Tests
 *
 * Note: These are unit tests for the Web Component class.
 * Full keyboard navigation and focus management tests require E2E testing
 * with Playwright due to jsdom limitations with focus events.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('Button (Web Component)', () => {
  let container: HTMLElement;

  // Web Component class extracted for testing
  class TestApgButton extends HTMLElement {
    private spanElement: HTMLSpanElement | null = null;
    private rafId: number | null = null;

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.spanElement = this.querySelector('span[role="button"]');

      if (!this.spanElement) {
        return;
      }

      this.spanElement.addEventListener('click', this.handleClick);
      this.spanElement.addEventListener('keydown', this.handleKeyDown);
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      this.spanElement?.removeEventListener('click', this.handleClick);
      this.spanElement?.removeEventListener('keydown', this.handleKeyDown);
    }

    private handleClick = (event: MouseEvent) => {
      if (this.isDisabled()) {
        event.preventDefault();
        return;
      }

      this.activate(event);
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      if (event.isComposing || event.defaultPrevented) {
        return;
      }

      if (this.isDisabled()) {
        return;
      }

      // Button activates on both Space and Enter (unlike links)
      if (event.key === ' ' || event.key === 'Enter') {
        event.preventDefault(); // Prevent Space from scrolling
        this.activate(event);
      }
    };

    private activate(_event: Event) {
      this.dispatchEvent(
        new CustomEvent('button-activate', {
          bubbles: true,
        })
      );
    }

    private isDisabled(): boolean {
      return this.spanElement?.getAttribute('aria-disabled') === 'true';
    }

    // Expose for testing
    get _spanElement() {
      return this.spanElement;
    }
  }

  function createButtonHTML(
    options: {
      disabled?: boolean;
      ariaLabel?: string;
      text?: string;
    } = {}
  ) {
    const { disabled = false, ariaLabel, text = 'Click me' } = options;

    const tabindex = disabled ? '-1' : '0';
    const ariaDisabled = disabled ? 'aria-disabled="true"' : '';
    const ariaLabelAttr = ariaLabel ? `aria-label="${ariaLabel}"` : '';

    return `
      <apg-button class="apg-button">
        <span
          role="button"
          tabindex="${tabindex}"
          ${ariaDisabled}
          ${ariaLabelAttr}
        >
          ${text}
        </span>
      </apg-button>
    `;
  }

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);

    // Register custom element if not already registered
    if (!customElements.get('apg-button')) {
      customElements.define('apg-button', TestApgButton);
    }
  });

  afterEach(() => {
    container.remove();
    vi.restoreAllMocks();
  });

  describe('Initial Rendering', () => {
    it('renders with role="button"', async () => {
      container.innerHTML = createButtonHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="button"]');
      expect(span).toBeTruthy();
    });

    it('renders with tabindex="0" by default', async () => {
      container.innerHTML = createButtonHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="button"]');
      expect(span?.getAttribute('tabindex')).toBe('0');
    });

    it('renders with tabindex="-1" when disabled', async () => {
      container.innerHTML = createButtonHTML({ disabled: true });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="button"]');
      expect(span?.getAttribute('tabindex')).toBe('-1');
    });

    it('renders with aria-disabled="true" when disabled', async () => {
      container.innerHTML = createButtonHTML({ disabled: true });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="button"]');
      expect(span?.getAttribute('aria-disabled')).toBe('true');
    });

    it('does not have aria-pressed (not a toggle button)', async () => {
      container.innerHTML = createButtonHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="button"]');
      expect(span?.hasAttribute('aria-pressed')).toBe(false);
    });

    it('renders with aria-label for accessible name', async () => {
      container.innerHTML = createButtonHTML({ ariaLabel: 'Close dialog' });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="button"]');
      expect(span?.getAttribute('aria-label')).toBe('Close dialog');
    });

    it('has text content as accessible name', async () => {
      container.innerHTML = createButtonHTML({ text: 'Submit Form' });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="button"]');
      expect(span?.textContent?.trim()).toBe('Submit Form');
    });
  });

  describe('Click Interaction', () => {
    it('dispatches button-activate event on click', async () => {
      container.innerHTML = createButtonHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-button') as HTMLElement;
      const span = container.querySelector('span[role="button"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('button-activate', activateHandler);

      span.click();

      expect(activateHandler).toHaveBeenCalledTimes(1);
    });

    it('does not dispatch event when disabled', async () => {
      container.innerHTML = createButtonHTML({ disabled: true });

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-button') as HTMLElement;
      const span = container.querySelector('span[role="button"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('button-activate', activateHandler);

      span.click();

      expect(activateHandler).not.toHaveBeenCalled();
    });
  });

  describe('Keyboard Interaction', () => {
    it('dispatches button-activate event on Space key', async () => {
      container.innerHTML = createButtonHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-button') as HTMLElement;
      const span = container.querySelector('span[role="button"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('button-activate', activateHandler);

      const spaceEvent = new KeyboardEvent('keydown', {
        key: ' ',
        bubbles: true,
        cancelable: true,
      });
      span.dispatchEvent(spaceEvent);

      expect(activateHandler).toHaveBeenCalledTimes(1);
    });

    it('dispatches button-activate event on Enter key', async () => {
      container.innerHTML = createButtonHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-button') as HTMLElement;
      const span = container.querySelector('span[role="button"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('button-activate', activateHandler);

      const enterEvent = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
        cancelable: true,
      });
      span.dispatchEvent(enterEvent);

      expect(activateHandler).toHaveBeenCalledTimes(1);
    });

    it('prevents default on Space key to avoid page scrolling', async () => {
      container.innerHTML = createButtonHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="button"]') as HTMLElement;

      const spaceEvent = new KeyboardEvent('keydown', {
        key: ' ',
        bubbles: true,
        cancelable: true,
      });
      const preventDefaultSpy = vi.spyOn(spaceEvent, 'preventDefault');

      span.dispatchEvent(spaceEvent);

      expect(preventDefaultSpy).toHaveBeenCalled();
    });

    it('does not dispatch event when disabled (Space key)', async () => {
      container.innerHTML = createButtonHTML({ disabled: true });

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-button') as HTMLElement;
      const span = container.querySelector('span[role="button"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('button-activate', activateHandler);

      const spaceEvent = new KeyboardEvent('keydown', {
        key: ' ',
        bubbles: true,
        cancelable: true,
      });
      span.dispatchEvent(spaceEvent);

      expect(activateHandler).not.toHaveBeenCalled();
    });

    it('does not dispatch event when disabled (Enter key)', async () => {
      container.innerHTML = createButtonHTML({ disabled: true });

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-button') as HTMLElement;
      const span = container.querySelector('span[role="button"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('button-activate', activateHandler);

      const enterEvent = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
        cancelable: true,
      });
      span.dispatchEvent(enterEvent);

      expect(activateHandler).not.toHaveBeenCalled();
    });

    it('does not dispatch event when isComposing is true', async () => {
      container.innerHTML = createButtonHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-button') as HTMLElement;
      const span = container.querySelector('span[role="button"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('button-activate', activateHandler);

      const enterEvent = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
        cancelable: true,
      });
      Object.defineProperty(enterEvent, 'isComposing', { value: true });
      span.dispatchEvent(enterEvent);

      expect(activateHandler).not.toHaveBeenCalled();
    });

    it('does not dispatch event when defaultPrevented is true', async () => {
      container.innerHTML = createButtonHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-button') as HTMLElement;
      const span = container.querySelector('span[role="button"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('button-activate', activateHandler);

      const enterEvent = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
        cancelable: true,
      });
      enterEvent.preventDefault();
      span.dispatchEvent(enterEvent);

      expect(activateHandler).not.toHaveBeenCalled();
    });
  });
});

Resources