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.tsx
import { cn } from '@/lib/utils';
import { useCallback, useRef } from 'react';

export interface ButtonProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'onClick'> {
  /** Click handler */
  onClick?: (event: React.MouseEvent | React.KeyboardEvent) => void;
  /** Disabled state */
  disabled?: boolean;
  /** Button content */
  children: React.ReactNode;
}

/**
 * Custom Button using role="button"
 *
 * This component demonstrates how to implement a custom button using ARIA.
 * For production use, prefer the native <button> element which provides
 * all accessibility features automatically.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/button/
 */
export const Button: React.FC<ButtonProps> = ({
  onClick,
  disabled = false,
  className,
  children,
  ...spanProps
}) => {
  // Track if Space was pressed on this element (for keyup activation)
  const spacePressed = useRef(false);

  const handleClick = useCallback(
    (event: React.MouseEvent<HTMLSpanElement>) => {
      if (disabled) {
        event.preventDefault();
        event.stopPropagation();
        return;
      }

      onClick?.(event);
    },
    [disabled, onClick]
  );

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLSpanElement>) => {
      // Ignore if composing (IME input) or already handled
      if (event.nativeEvent.isComposing || event.defaultPrevented) {
        return;
      }

      if (disabled) {
        return;
      }

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

      // Enter: activate on keydown (native button behavior)
      if (event.key === 'Enter') {
        event.preventDefault();
        event.currentTarget.click();
      }
    },
    [disabled]
  );

  const handleKeyUp = useCallback(
    (event: React.KeyboardEvent<HTMLSpanElement>) => {
      // Space: activate on keyup if Space was pressed on this element
      if (event.key === ' ' && spacePressed.current) {
        spacePressed.current = false;

        if (disabled) {
          return;
        }

        event.preventDefault();
        event.currentTarget.click();
      }
    },
    [disabled]
  );

  return (
    <span
      {...spanProps}
      role="button"
      tabIndex={disabled ? -1 : 0}
      aria-disabled={disabled ? 'true' : undefined}
      className={cn('apg-button', className)}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      onKeyUp={handleKeyUp}
    >
      {children}
    </span>
  );
};

export default Button;

Usage

Example
import { Button } from './Button';

function App() {
  return (
    <div>
      {/* Basic button */}
      <Button onClick={() => console.log('Clicked!')}>
        Click me
      </Button>

      {/* Disabled button */}
      <Button disabled onClick={() => alert('Should not fire')}>
        Disabled
      </Button>

      {/* With aria-label for icon buttons */}
      <Button onClick={handleSettings} aria-label="Settings">
        <SettingsIcon />
      </Button>
    </div>
  );
}

API

Prop Type Default Description
onClick (event) => void - Click/Space/Enter event handler
disabled boolean false Whether the button is disabled
children ReactNode - Button content

All other props are passed to the underlying <span> element.

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.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 { Button } from './Button';

describe('Button', () => {
  // 🔴 High Priority: APG ARIA Attributes
  describe('APG ARIA Attributes', () => {
    it('has role="button" on element', () => {
      render(<Button>Click me</Button>);
      expect(screen.getByRole('button')).toBeInTheDocument();
    });

    it('has tabindex="0" on element', () => {
      render(<Button>Click me</Button>);
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('tabindex', '0');
    });

    it('has accessible name from text content', () => {
      render(<Button>Submit Form</Button>);
      expect(screen.getByRole('button', { name: 'Submit Form' })).toBeInTheDocument();
    });

    it('has accessible name from aria-label', () => {
      render(
        <Button aria-label="Close dialog">
          <span aria-hidden="true">×</span>
        </Button>
      );
      expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument();
    });

    it('has accessible name from aria-labelledby', () => {
      render(
        <>
          <span id="btn-label">Save changes</span>
          <Button aria-labelledby="btn-label">Save</Button>
        </>
      );
      expect(screen.getByRole('button', { name: 'Save changes' })).toBeInTheDocument();
    });

    it('sets aria-disabled="true" when disabled', () => {
      render(<Button disabled>Disabled button</Button>);
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('aria-disabled', 'true');
    });

    it('sets tabindex="-1" when disabled', () => {
      render(<Button disabled>Disabled button</Button>);
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('tabindex', '-1');
    });

    it('does not have aria-disabled when not disabled', () => {
      render(<Button>Active button</Button>);
      const button = screen.getByRole('button');
      expect(button).not.toHaveAttribute('aria-disabled');
    });

    it('does not have aria-pressed (not a toggle button)', () => {
      render(<Button>Not a toggle</Button>);
      const button = screen.getByRole('button');
      expect(button).not.toHaveAttribute('aria-pressed');
    });
  });

  // 🔴 High Priority: APG Keyboard Interaction
  describe('APG Keyboard Interaction', () => {
    it('calls onClick on Space key', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(<Button onClick={handleClick}>Click me</Button>);

      const button = screen.getByRole('button');
      button.focus();
      await user.keyboard(' ');

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

    it('calls onClick on Enter key', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(<Button onClick={handleClick}>Click me</Button>);

      const button = screen.getByRole('button');
      button.focus();
      await user.keyboard('{Enter}');

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

    it('does not scroll page on Space key', async () => {
      const handleClick = vi.fn();
      render(<Button onClick={handleClick}>Click me</Button>);

      const button = screen.getByRole('button');
      button.focus();

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

      button.dispatchEvent(spaceEvent);
      expect(preventDefaultSpy).toHaveBeenCalled();
    });

    it('does not call onClick when event.isComposing is true', () => {
      const handleClick = vi.fn();
      render(<Button onClick={handleClick}>Click me</Button>);

      const button = screen.getByRole('button');
      const event = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
      });
      Object.defineProperty(event, 'isComposing', { value: true });

      button.dispatchEvent(event);
      expect(handleClick).not.toHaveBeenCalled();
    });

    it('does not call onClick when event.defaultPrevented is true', () => {
      const handleClick = vi.fn();
      render(<Button onClick={handleClick}>Click me</Button>);

      const button = screen.getByRole('button');
      const event = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
        cancelable: true,
      });
      event.preventDefault();

      button.dispatchEvent(event);
      expect(handleClick).not.toHaveBeenCalled();
    });

    it('calls onClick on click', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(<Button onClick={handleClick}>Click me</Button>);

      await user.click(screen.getByRole('button'));
      expect(handleClick).toHaveBeenCalledTimes(1);
    });

    it('does not call onClick when disabled (click)', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(
        <Button onClick={handleClick} disabled>
          Disabled
        </Button>
      );

      await user.click(screen.getByRole('button'));
      expect(handleClick).not.toHaveBeenCalled();
    });

    it('does not call onClick when disabled (Space key)', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(
        <Button onClick={handleClick} disabled>
          Disabled
        </Button>
      );

      const button = screen.getByRole('button');
      button.focus();
      await user.keyboard(' ');

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

    it('does not call onClick when disabled (Enter key)', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(
        <Button onClick={handleClick} disabled>
          Disabled
        </Button>
      );

      const button = screen.getByRole('button');
      button.focus();
      await user.keyboard('{Enter}');

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

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('is focusable via Tab', async () => {
      const user = userEvent.setup();
      render(<Button>Click me</Button>);

      await user.tab();
      expect(screen.getByRole('button')).toHaveFocus();
    });

    it('is not focusable when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <Button disabled>Disabled button</Button>
          <button>After</button>
        </>
      );

      await user.tab();
      expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
    });

    it('moves focus between multiple buttons with Tab', async () => {
      const user = userEvent.setup();
      render(
        <>
          <Button>Button 1</Button>
          <Button>Button 2</Button>
          <Button>Button 3</Button>
        </>
      );

      await user.tab();
      expect(screen.getByRole('button', { name: 'Button 1' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('button', { name: 'Button 2' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('button', { name: 'Button 3' })).toHaveFocus();
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(<Button>Click me</Button>);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(<Button disabled>Disabled button</Button>);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-label', async () => {
      const { container } = render(<Button aria-label="Close">×</Button>);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies className to element', () => {
      render(<Button className="custom-button">Styled</Button>);
      const button = screen.getByRole('button');
      expect(button).toHaveClass('custom-button');
    });

    it('passes through data-* attributes', () => {
      render(
        <Button data-testid="my-button" data-custom="value">
          Button
        </Button>
      );
      const button = screen.getByTestId('my-button');
      expect(button).toHaveAttribute('data-custom', 'value');
    });

    it('sets id attribute', () => {
      render(<Button id="main-button">Main</Button>);
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('id', 'main-button');
    });
  });
});

Resources