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
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
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
| 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.
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
- 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