Checkbox
A control that allows users to select one or more options from a set.
🤖 AI Implementation GuideDemo
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 Role
| Role | 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
Indicates the current checked state of the checkbox. Required for all checkbox implementations.
| Values | true | false | mixed (for indeterminate)
|
| Required | Yes |
| Native HTML | checked property (implicit aria-checked)
|
| Custom ARIA | aria-checked="true|false|mixed" |
| Change Trigger | Click, Space |
indeterminate (Native Property)
Indicates a mixed state, typically used for "select all" checkboxes when some but not all items are selected.
| Values | true | false |
| Required | No (only for mixed state) |
| Note | JavaScript property only, not an HTML attribute |
| Behavior | Automatically cleared on user interaction |
disabled (Native Attribute)
Indicates the checkbox is not interactive and cannot be changed.
| Values | Present | Absent |
| Required | No (only when disabled) |
| Effect | Removed from tab order, ignores input |
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>withforattribute 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:
- Checkmark icon - Visible when checked
- Dash/minus icon - Visible when indeterminate
- Empty box - Visible when unchecked
- 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