Button
An element that enables users to trigger an action or event using role="button".
Demo
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><span role="button"></code> for educational purposes. For production use, prefer native <code><button></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
---
/**
* 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
---
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
| Test | Description |
|---|---|
Space key | Activates the button |
Enter key | Activates the button |
Space preventDefault | Prevents page scrolling when Space is pressed |
IME composing | Ignores Space/Enter during IME input |
Tab navigation | Tab moves focus between buttons |
Disabled Tab skip | Disabled buttons are skipped in Tab order |
High Priority: ARIA Attributes
| Test | Description |
|---|---|
role="button" | Element has button role |
tabindex="0" | Element is focusable via keyboard |
aria-disabled | Set to "true" when disabled |
tabindex="-1" | Set when disabled to remove from Tab order |
Accessible name | Name from text content, aria-label, or aria-labelledby |
High Priority: Click Behavior
| Test | Description |
|---|---|
Click activation | Click activates the button |
Disabled click | Disabled buttons ignore click events |
Disabled Space | Disabled buttons ignore Space key |
Disabled Enter | Disabled buttons ignore Enter key |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
disabled axe | No violations in disabled state |
aria-label axe | No violations with aria-label |
Low Priority: Props & Attributes
| Test | Description |
|---|---|
className | Custom classes are applied |
data-* attributes | Custom data attributes are passed through |
children | Child content is rendered |
Low Priority: Cross-framework Consistency
| Test | Description |
|---|---|
All frameworks have buttons | React, Vue, Svelte, Astro all render custom button elements |
Same button count | All frameworks render the same number of buttons |
Consistent ARIA | All frameworks have consistent ARIA structure |
Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
/**
* 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
- WAI-ARIA APG: Button Pattern (opens in new tab)
- MDN: <button> element (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist