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 <button> | 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 <span role="button"> for educational purposes. For production use, prefer native <button> elements.
WAI-ARIA Properties
tabindex
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. 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.
- 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 the Testing Strategy guide for details.
/**
* Button Astro Component Tests using Container API
*
* These tests verify the initial server-rendered HTML of Button.astro.
* Web Component behavior (click, Space/Enter activation, disabled handling,
* IME composition and the button-activate event) is covered by E2E tests
* in e2e/button.spec.ts.
*
* @see https://docs.astro.build/en/reference/container-reference/
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Button from './Button.astro';
describe('Button (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
async function renderButton(
props: Record<string, unknown> = {},
label = 'Click me'
): Promise<Document> {
const html = await container.renderToString(Button, {
props,
slots: { default: label },
});
return new JSDOM(html).window.document;
}
// 🔴 High Priority: HTML Structure
describe('HTML Structure', () => {
it('renders apg-button custom element wrapper', async () => {
const doc = await renderButton();
expect(doc.querySelector('apg-button')).not.toBeNull();
});
it('renders a span with role="button"', async () => {
const doc = await renderButton();
const button = doc.querySelector('[role="button"]');
expect(button?.tagName).toBe('SPAN');
});
it('renders the slot content as the accessible name', async () => {
const doc = await renderButton({}, 'Submit Form');
const button = doc.querySelector('[role="button"]');
expect(button?.textContent?.trim()).toBe('Submit Form');
});
});
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('does not have aria-pressed (not a toggle button)', async () => {
const doc = await renderButton();
const button = doc.querySelector('[role="button"]');
expect(button?.hasAttribute('aria-pressed')).toBe(false);
});
it('has aria-disabled="true" when disabled', async () => {
const doc = await renderButton({ disabled: true });
const button = doc.querySelector('[role="button"]');
expect(button?.getAttribute('aria-disabled')).toBe('true');
});
it('does not have aria-disabled when not disabled', async () => {
const doc = await renderButton();
const button = doc.querySelector('[role="button"]');
expect(button?.hasAttribute('aria-disabled')).toBe(false);
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" by default', async () => {
const doc = await renderButton();
const button = doc.querySelector('[role="button"]');
expect(button?.getAttribute('tabindex')).toBe('0');
});
it('has tabindex="-1" when disabled', async () => {
const doc = await renderButton({ disabled: true });
const button = doc.querySelector('[role="button"]');
expect(button?.getAttribute('tabindex')).toBe('-1');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attributes', () => {
it('has the apg-button class', async () => {
const doc = await renderButton();
const button = doc.querySelector('[role="button"]');
expect(button?.getAttribute('class')).toBe('apg-button');
});
it('applies a custom class alongside apg-button', async () => {
const doc = await renderButton({ class: 'custom-class' });
const button = doc.querySelector('[role="button"]');
expect(button?.getAttribute('class')).toBe('apg-button custom-class');
});
});
});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