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
<script lang="ts">
import { untrack } from 'svelte';
interface CheckboxProps {
initialChecked?: boolean;
indeterminate?: boolean;
disabled?: boolean;
name?: string;
value?: string;
onCheckedChange?: (checked: boolean) => void;
[key: string]: unknown;
}
let {
initialChecked = false,
indeterminate: indeterminateProp = false,
disabled = false,
name,
value,
onCheckedChange = (_) => {},
...restProps
}: CheckboxProps = $props();
let checked = $state(untrack(() => initialChecked));
let isIndeterminate = $state(untrack(() => indeterminateProp));
let inputRef: HTMLInputElement | undefined = $state();
// Update indeterminate property when ref or state changes
$effect(() => {
if (inputRef) {
inputRef.indeterminate = isIndeterminate;
}
});
// Sync with prop changes
$effect(() => {
isIndeterminate = indeterminateProp;
});
function handleChange(event: Event) {
const target = event.target as HTMLInputElement;
checked = target.checked;
isIndeterminate = false;
onCheckedChange(checked);
}
</script>
<span class="apg-checkbox {restProps.class || ''}" data-testid={restProps['data-testid']}>
<input
bind:this={inputRef}
type="checkbox"
class="apg-checkbox-input"
{checked}
{disabled}
{name}
{value}
onchange={handleChange}
{...restProps}
data-testid={undefined}
/>
<span class="apg-checkbox-control" aria-hidden="true">
<span class="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"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="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" stroke-width="2" stroke-linecap="round" />
</svg>
</span>
</span>
</span> Usage
<script>
import Checkbox from './Checkbox.svelte';
function handleChange(checked) {
console.log('Checked:', checked);
}
</script>
<form>
<!-- With wrapping label -->
<label class="inline-flex items-center gap-2">
<Checkbox name="terms" onCheckedChange={handleChange} />
I agree to the terms and conditions
</label>
<!-- With separate label -->
<label for="newsletter">Subscribe to newsletter</label>
<Checkbox id="newsletter" name="newsletter" initialChecked={true} />
<!-- Indeterminate state for "select all" -->
<label class="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 |
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/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Checkbox from './Checkbox.svelte';
describe('Checkbox (Svelte)', () => {
// 🔴 High Priority: DOM State
describe('DOM State', () => {
it('has role="checkbox"', () => {
render(Checkbox, {
props: { 'aria-label': 'Accept terms' },
});
expect(screen.getByRole('checkbox')).toBeInTheDocument();
});
it('is unchecked by default', () => {
render(Checkbox, {
props: { 'aria-label': 'Accept terms' },
});
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
});
it('is checked when initialChecked=true', () => {
render(Checkbox, {
props: { 'aria-label': 'Accept terms', initialChecked: true },
});
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked();
});
it('toggles checked state on click', async () => {
const user = userEvent.setup();
render(Checkbox, {
props: { '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, {
props: { 'aria-label': 'Select all', indeterminate: true },
});
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, {
props: { 'aria-label': 'Select all', indeterminate: true },
});
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, {
props: { 'aria-label': 'Accept terms', disabled: true },
});
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeDisabled();
});
it('does not change state when clicked while disabled', async () => {
const user = userEvent.setup();
render(Checkbox, {
props: { 'aria-label': 'Accept terms', disabled: true },
});
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, {
props: { 'aria-label': 'Accept terms and conditions' },
});
expect(
screen.getByRole('checkbox', { name: 'Accept terms and conditions' })
).toBeInTheDocument();
});
it('sets accessible name via external <label>', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const label = document.createElement('label');
label.htmlFor = 'terms-checkbox';
label.textContent = 'Accept terms and conditions';
container.appendChild(label);
render(Checkbox, {
target: container,
props: { id: 'terms-checkbox' },
});
expect(
screen.getByRole('checkbox', { name: 'Accept terms and conditions' })
).toBeInTheDocument();
document.body.removeChild(container);
});
it('supports name attribute for form submission', () => {
render(Checkbox, {
props: { 'aria-label': 'Accept terms', name: 'terms' },
});
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveAttribute('name', 'terms');
});
it('sets value attribute correctly', () => {
render(Checkbox, {
props: { 'aria-label': 'Red', name: 'color', value: 'red' },
});
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveAttribute('value', 'red');
});
});
// 🔴 High Priority: Keyboard
describe('Keyboard', () => {
it('toggles on Space key', async () => {
const user = userEvent.setup();
render(Checkbox, {
props: { 'aria-label': 'Accept terms' },
});
const checkbox = screen.getByRole('checkbox');
checkbox.focus();
expect(checkbox).not.toBeChecked();
await user.keyboard(' ');
expect(checkbox).toBeChecked();
});
it('skips disabled checkbox with Tab', async () => {
const user = userEvent.setup();
const container = document.createElement('div');
document.body.appendChild(container);
const { unmount: unmount1 } = render(Checkbox, {
target: container,
props: { 'aria-label': 'Checkbox 1' },
});
const { unmount: unmount2 } = render(Checkbox, {
target: container,
props: { 'aria-label': 'Checkbox 2', disabled: true },
});
const { unmount: unmount3 } = render(Checkbox, {
target: container,
props: { '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();
unmount1();
unmount2();
unmount3();
document.body.removeChild(container);
});
it('ignores Space key when disabled', () => {
render(Checkbox, {
props: { 'aria-label': 'Accept terms', disabled: true },
});
const checkbox = screen.getByRole('checkbox');
// disabled checkbox cannot be focused, so keyboard events won't affect it
expect(checkbox).toBeDisabled();
expect(checkbox).not.toBeChecked();
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(Checkbox, {
props: { 'aria-label': 'Accept terms' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when checked', async () => {
const { container } = render(Checkbox, {
props: { 'aria-label': 'Accept terms', initialChecked: true },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when indeterminate', async () => {
const { container } = render(Checkbox, {
props: { 'aria-label': 'Select all', indeterminate: true },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(Checkbox, {
props: { 'aria-label': 'Accept terms', disabled: true },
});
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, {
props: { '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, {
props: {
'aria-label': 'Select all',
indeterminate: true,
onCheckedChange: handleCheckedChange,
},
});
await user.click(screen.getByRole('checkbox'));
expect(handleCheckedChange).toHaveBeenCalledWith(true);
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('has apg-checkbox class by default', () => {
render(Checkbox, {
props: { 'aria-label': 'Accept terms', 'data-testid': 'wrapper' },
});
const wrapper = screen.getByTestId('wrapper');
expect(wrapper).toHaveClass('apg-checkbox');
});
it('passes through data-* attributes', () => {
render(Checkbox, {
props: { 'aria-label': 'Accept terms', 'data-testid': 'custom-checkbox' },
});
expect(screen.getByTestId('custom-checkbox')).toBeInTheDocument();
});
});
}); 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