Checkbox
A control that allows users to select one or more options from a set.
Demo
Native HTML
Use Native HTML First
Before using this custom component, consider using native <input type="checkbox"> elements. They provide built-in accessibility, work without JavaScript, and require no ARIA attributes.
<label>
<input type="checkbox" name="agree" />
I agree to the terms
</label> Use custom implementations only when you need custom styling that native elements cannot provide, or complex indeterminate state management for checkbox groups.
| Use Case | Native HTML | Custom Implementation |
|---|---|---|
| Basic form input | Recommended | Not needed |
| JavaScript disabled support | Works natively | Requires fallback |
| Indeterminate (mixed) state | JS property only* | Full control |
| Custom styling | Limited (browser-dependent) | Full control |
| Form submission | Built-in | Requires hidden input |
*Native indeterminate is a JavaScript property, not an HTML attribute. It cannot be set declaratively.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
checkbox | <input type="checkbox"> or element with role="checkbox" | Identifies the element as a checkbox. Native <input type="checkbox"> has this role implicitly. |
This implementation uses native <input type="checkbox"> which provides
the checkbox role implicitly. For custom implementations using <div> or <button>, explicit role="checkbox" is required.
WAI-ARIA States
aria-checked / checked
| Values | true | false | mixed |
| Required | Yes |
| Change Trigger | Click, Space key |
| Reference | aria-checked / checked (opens in new tab) |
indeterminate
| Values | true | false |
| Required | No |
| Change Trigger | Parent-child sync, automatically cleared on user interaction |
disabled
| Values | present | absent |
| Required | No |
| Change Trigger | Programmatic change |
Keyboard Support
| Key | Action |
|---|---|
| Space | Toggle the checkbox state (checked/unchecked) |
| Tab | Move focus to the next focusable element |
| Shift + Tab | Move focus to the previous focusable element |
Note: Unlike the Switch pattern, the Enter key does not toggle the checkbox.
Accessible Naming
Checkboxes must have an accessible name. This can be provided through:
- Label element (recommended) - Using <label> with for attribute or wrapping the input
- aria-label - Provides an invisible label for the checkbox
- aria-labelledby - References an external element as the label
Visual Design
This implementation follows WCAG 1.4.1 (Use of Color) by not relying solely on color to indicate state:
- Checked - Checkmark icon
- Indeterminate - Dash/minus icon
- Unchecked - Empty box
- Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode
References
Source Code
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useRef, useState } from 'react';
export interface CheckboxProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'type' | 'onChange'
> {
/** Initial checked state */
initialChecked?: boolean;
/** Indeterminate (mixed) state */
indeterminate?: boolean;
/** Callback when checked state changes */
onCheckedChange?: (checked: boolean) => void;
/** Test ID for wrapper element */
'data-testid'?: string;
}
export const Checkbox: React.FC<CheckboxProps> = ({
initialChecked = false,
indeterminate = false,
onCheckedChange,
className,
disabled,
'data-testid': dataTestId,
...inputProps
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [checked, setChecked] = useState(initialChecked);
const [isIndeterminate, setIsIndeterminate] = useState(indeterminate);
// Update indeterminate property on the input element
useEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = isIndeterminate;
}
}, [isIndeterminate]);
// Sync with prop changes
useEffect(() => {
setIsIndeterminate(indeterminate);
}, [indeterminate]);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newChecked = event.target.checked;
setChecked(newChecked);
setIsIndeterminate(false);
onCheckedChange?.(newChecked);
},
[onCheckedChange]
);
return (
<span className={cn('apg-checkbox', className)} data-testid={dataTestId}>
<input
ref={inputRef}
type="checkbox"
className="apg-checkbox-input"
checked={checked}
disabled={disabled}
onChange={handleChange}
{...inputProps}
/>
<span className="apg-checkbox-control" aria-hidden="true">
<span className="apg-checkbox-icon apg-checkbox-icon--check">
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M10 3L4.5 8.5L2 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span className="apg-checkbox-icon apg-checkbox-icon--indeterminate">
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 6H9.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</span>
</span>
</span>
);
};
export default Checkbox; Usage
import { Checkbox } from './Checkbox';
function App() {
return (
<form>
{/* With wrapping label */}
<label className="inline-flex items-center gap-2">
<Checkbox
name="terms"
onCheckedChange={(checked) => console.log('Checked:', checked)}
/>
I agree to the terms and conditions
</label>
{/* With separate label */}
<label htmlFor="newsletter">Subscribe to newsletter</label>
<Checkbox id="newsletter" name="newsletter" initialChecked={true} />
{/* Indeterminate state for "select all" */}
<label className="inline-flex items-center gap-2">
<Checkbox indeterminate aria-label="Select all items" />
Select all items
</label>
</form>
);
} API
| Prop | Type | Default | Description |
|---|---|---|---|
initialChecked | boolean | false | Initial checked state |
indeterminate | boolean | false | Whether the checkbox is in an indeterminate (mixed) state |
onCheckedChange | (checked: boolean) => void | - | Callback when state changes |
disabled | boolean | false | Whether the checkbox is disabled |
name | string | - | Form field name |
value | string | - | Form field value |
id | string | - | ID for external label association |
All other props are passed to the underlying <input> element.
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Checkbox component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Container API)
Verify the component's HTML output using Astro Container API. These tests ensure correct template rendering without requiring a browser.
- HTML structure and element hierarchy
- Initial attribute values (checked, disabled, indeterminate)
- Form integration attributes (name, value, id)
- CSS class application
E2E Tests (Playwright)
Verify Web Component behavior in a real browser environment. These tests cover interactions that require JavaScript execution.
- Click and keyboard interactions
- Custom event dispatching (checkedchange)
- Indeterminate state clearing on user action
- Label association and click behavior
- Focus management and tab navigation
Test Categories
High Priority : HTML Structure (Unit)
| Test | Description |
|---|---|
input type | Renders input with type="checkbox" |
checked attribute | Checked attribute reflects initialChecked prop |
disabled attribute | Disabled attribute is set when disabled prop is true |
data-indeterminate | Data attribute set for indeterminate state |
control aria-hidden | Visual control element has aria-hidden="true" |
High Priority : Keyboard Interaction (E2E)
| Test | Description |
|---|---|
Space key | Toggles the checkbox state |
Tab navigation | Tab moves focus between checkboxes |
Disabled Tab skip | Disabled checkboxes are skipped in Tab order |
Disabled key ignore | Disabled checkboxes ignore key presses |
Note: Unlike the Switch pattern, the Enter key does not toggle the checkbox.
High Priority : Click Interaction (E2E)
| Test | Description |
|---|---|
checked toggle | Click toggles checked state |
disabled click | Disabled checkboxes prevent click interaction |
indeterminate clear | User interaction clears indeterminate state |
checkedchange event | Custom event dispatched with correct detail |
Medium Priority : Form Integration (Unit)
| Test | Description |
|---|---|
name attribute | Form name attribute is rendered |
value attribute | Form value attribute is rendered |
id attribute | ID attribute is correctly set for label association |
Medium Priority : Label Association (E2E)
| Test | Description |
|---|---|
Label click | Clicking external label toggles checkbox |
Wrapping label | Clicking wrapping label toggles checkbox |
Low Priority : CSS Classes (Unit)
| Test | Description |
|---|---|
default class | apg-checkbox class is applied to wrapper |
custom class | Custom classes are merged with component classes |
Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Astro Container API (opens in new tab) - Server-side component rendering for unit tests
- Playwright (opens in new tab) - Browser automation for E2E tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
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 { Checkbox } from './Checkbox';
describe('Checkbox', () => {
// 🔴 High Priority: DOM State
describe('DOM State', () => {
it('has role="checkbox"', () => {
render(<Checkbox aria-label="Accept terms" />);
expect(screen.getByRole('checkbox')).toBeInTheDocument();
});
it('is unchecked by default', () => {
render(<Checkbox aria-label="Accept terms" />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
});
it('is checked when initialChecked=true', () => {
render(<Checkbox aria-label="Accept terms" initialChecked />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked();
});
it('toggles checked state on click', async () => {
const user = userEvent.setup();
render(<Checkbox aria-label="Accept terms" />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
it('supports indeterminate property', () => {
render(<Checkbox aria-label="Select all" indeterminate />);
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
expect(checkbox.indeterminate).toBe(true);
});
it('clears indeterminate on user interaction', async () => {
const user = userEvent.setup();
render(<Checkbox aria-label="Select all" indeterminate />);
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
expect(checkbox.indeterminate).toBe(true);
await user.click(checkbox);
expect(checkbox.indeterminate).toBe(false);
});
it('is disabled when disabled prop is set', () => {
render(<Checkbox aria-label="Accept terms" disabled />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeDisabled();
});
it('does not change state when clicked while disabled', async () => {
const user = userEvent.setup();
render(<Checkbox aria-label="Accept terms" disabled />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
});
// 🔴 High Priority: Label & Form
describe('Label & Form', () => {
it('sets accessible name via aria-label', () => {
render(<Checkbox aria-label="Accept terms and conditions" />);
expect(
screen.getByRole('checkbox', { name: 'Accept terms and conditions' })
).toBeInTheDocument();
});
it('sets accessible name via external <label>', () => {
render(
<>
<label htmlFor="terms-checkbox">Accept terms and conditions</label>
<Checkbox id="terms-checkbox" />
</>
);
expect(
screen.getByRole('checkbox', { name: 'Accept terms and conditions' })
).toBeInTheDocument();
});
it('toggles checkbox when clicking external label', async () => {
const user = userEvent.setup();
render(
<>
<label htmlFor="terms-checkbox">Accept terms</label>
<Checkbox id="terms-checkbox" />
</>
);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await user.click(screen.getByText('Accept terms'));
expect(checkbox).toBeChecked();
});
it('supports name attribute for form submission', () => {
render(<Checkbox aria-label="Accept terms" name="terms" />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveAttribute('name', 'terms');
});
it('sets value attribute correctly', () => {
render(<Checkbox aria-label="Red" name="color" value="red" />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveAttribute('value', 'red');
});
it('supports aria-describedby for description', () => {
render(
<>
<Checkbox aria-label="Accept terms" aria-describedby="terms-desc" />
<p id="terms-desc">Please read our terms carefully</p>
</>
);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveAttribute('aria-describedby', 'terms-desc');
});
it('supports aria-labelledby for external label reference', () => {
render(
<>
<span id="label-text">Accept terms</span>
<Checkbox aria-labelledby="label-text" />
</>
);
expect(screen.getByRole('checkbox', { name: 'Accept terms' })).toBeInTheDocument();
});
});
// 🔴 High Priority: Keyboard
describe('Keyboard', () => {
it('toggles on Space key', async () => {
const user = userEvent.setup();
render(<Checkbox aria-label="Accept terms" />);
const checkbox = screen.getByRole('checkbox');
checkbox.focus();
expect(checkbox).not.toBeChecked();
await user.keyboard(' ');
expect(checkbox).toBeChecked();
});
it('moves focus with Tab key', async () => {
const user = userEvent.setup();
render(
<>
<Checkbox aria-label="Checkbox 1" />
<Checkbox aria-label="Checkbox 2" />
</>
);
await user.tab();
expect(screen.getByRole('checkbox', { name: 'Checkbox 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('checkbox', { name: 'Checkbox 2' })).toHaveFocus();
});
it('skips disabled checkbox with Tab', async () => {
const user = userEvent.setup();
render(
<>
<Checkbox aria-label="Checkbox 1" />
<Checkbox aria-label="Checkbox 2 (disabled)" disabled />
<Checkbox aria-label="Checkbox 3" />
</>
);
await user.tab();
expect(screen.getByRole('checkbox', { name: 'Checkbox 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('checkbox', { name: 'Checkbox 3' })).toHaveFocus();
});
it('ignores Space key when disabled', async () => {
const user = userEvent.setup();
render(<Checkbox aria-label="Accept terms" disabled />);
const checkbox = screen.getByRole('checkbox');
checkbox.focus();
await user.keyboard(' ');
expect(checkbox).not.toBeChecked();
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(<Checkbox aria-label="Accept terms" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when checked', async () => {
const { container } = render(<Checkbox aria-label="Accept terms" initialChecked />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when indeterminate', async () => {
const { container } = render(<Checkbox aria-label="Select all" indeterminate />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(<Checkbox aria-label="Accept terms" disabled />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with external label', async () => {
const { container } = render(
<>
<label htmlFor="terms">Accept terms</label>
<Checkbox id="terms" />
</>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Callbacks
describe('Callbacks', () => {
it('calls onCheckedChange when state changes', async () => {
const handleCheckedChange = vi.fn();
const user = userEvent.setup();
render(<Checkbox aria-label="Accept terms" onCheckedChange={handleCheckedChange} />);
await user.click(screen.getByRole('checkbox'));
expect(handleCheckedChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole('checkbox'));
expect(handleCheckedChange).toHaveBeenCalledWith(false);
});
it('calls onCheckedChange when indeterminate is cleared', async () => {
const handleCheckedChange = vi.fn();
const user = userEvent.setup();
render(
<Checkbox aria-label="Select all" indeterminate onCheckedChange={handleCheckedChange} />
);
await user.click(screen.getByRole('checkbox'));
expect(handleCheckedChange).toHaveBeenCalledWith(true);
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('merges className correctly', () => {
render(<Checkbox aria-label="Accept terms" className="custom-class" data-testid="wrapper" />);
const wrapper = screen.getByTestId('wrapper');
expect(wrapper).toHaveClass('custom-class');
expect(wrapper).toHaveClass('apg-checkbox');
});
it('passes through data-* attributes', () => {
render(<Checkbox aria-label="Accept terms" data-testid="custom-checkbox" />);
expect(screen.getByTestId('custom-checkbox')).toBeInTheDocument();
});
it('sets id attribute', () => {
render(<Checkbox aria-label="Accept terms" id="my-checkbox" />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveAttribute('id', 'my-checkbox');
});
it('sets required attribute', () => {
render(<Checkbox aria-label="Accept terms" required />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeRequired();
});
});
}); Resources
- WAI-ARIA APG: Checkbox Pattern (opens in new tab)
- MDN: <input type="checkbox"> (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist