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
---
/**
* APG Checkbox Pattern - Astro Implementation
*
* A control that allows users to select one or more options.
* Uses native input[type="checkbox"] with Web Components for enhanced interactivity.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/
*/
export interface Props {
/** Initial checked state */
initialChecked?: boolean;
/** Indeterminate (mixed) state */
indeterminate?: boolean;
/** Whether the checkbox is disabled */
disabled?: boolean;
/** Form field name */
name?: string;
/** Form field value */
value?: string;
/** Checkbox id for label association */
id?: string;
/** Additional CSS class */
class?: string;
}
const {
initialChecked = false,
indeterminate = false,
disabled = false,
name,
value,
id,
class: className = '',
} = Astro.props;
---
<apg-checkbox
class={`apg-checkbox ${className}`.trim()}
data-indeterminate={indeterminate ? 'true' : undefined}
>
<input
type="checkbox"
id={id}
class="apg-checkbox-input"
checked={initialChecked}
disabled={disabled}
name={name}
value={value}
/>
<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"></path>
</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"></path>
</svg>
</span>
</span>
</apg-checkbox>
<script>
class ApgCheckbox extends HTMLElement {
private input: HTMLInputElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.input = this.querySelector('input[type="checkbox"]');
if (!this.input) {
console.warn('apg-checkbox: input element not found');
return;
}
// Set initial indeterminate state if specified
if (this.dataset.indeterminate === 'true') {
this.input.indeterminate = true;
}
this.input.addEventListener('change', this.handleChange);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.input?.removeEventListener('change', this.handleChange);
this.input = null;
}
private handleChange = (event: Event) => {
const target = event.target as HTMLInputElement;
// Clear indeterminate on user interaction
if (target.indeterminate) {
target.indeterminate = false;
}
this.dispatchEvent(
new CustomEvent('checkedchange', {
detail: { checked: target.checked },
bubbles: true,
})
);
};
}
if (!customElements.get('apg-checkbox')) {
customElements.define('apg-checkbox', ApgCheckbox);
}
</script> Usage
---
import Checkbox from './Checkbox.astro';
---
<form>
<!-- With wrapping label -->
<label class="inline-flex items-center gap-2">
<Checkbox name="terms" />
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 />
Select all items
</label>
</form>
<script>
// Listen for change events
document.querySelectorAll('apg-checkbox').forEach((checkbox) => {
checkbox.addEventListener('checkedchange', (e) => {
console.log('Checked:', e.detail.checked);
});
});
</script> API
| Prop | Type | Default | Description |
|---|---|---|---|
initialChecked | boolean | false | Initial checked state |
indeterminate | boolean | false | Whether the checkbox is in an indeterminate (mixed) state |
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 |
class | string | "" | Additional CSS classes |
Custom Events
| Event | Detail | Description |
|---|---|---|
checkedchange | { checked: boolean } | Fired when the checkbox state changes |
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.
/**
* Checkbox Astro Component Tests using Container API
*
* These tests verify the actual Checkbox.astro component output using Astro's Container API.
* This ensures the component renders correct HTML structure and attributes.
*
* Note: Web Component behavior tests (click interaction, event dispatching) require
* E2E testing with Playwright as they need a real browser environment.
*
* @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 Checkbox from './Checkbox.astro';
describe('Checkbox (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
// Helper to render and parse HTML
async function renderCheckbox(
props: {
initialChecked?: boolean;
indeterminate?: boolean;
disabled?: boolean;
name?: string;
value?: string;
id?: string;
class?: string;
} = {}
): Promise<Document> {
const html = await container.renderToString(Checkbox, { props });
const dom = new JSDOM(html);
return dom.window.document;
}
// 🔴 High Priority: HTML Structure
describe('HTML Structure', () => {
it('renders apg-checkbox custom element wrapper', async () => {
const doc = await renderCheckbox();
const wrapper = doc.querySelector('apg-checkbox');
expect(wrapper).not.toBeNull();
});
it('renders input with type="checkbox"', async () => {
const doc = await renderCheckbox();
const input = doc.querySelector('input[type="checkbox"]');
expect(input).not.toBeNull();
});
it('renders checkbox control span with aria-hidden', async () => {
const doc = await renderCheckbox();
const control = doc.querySelector('.apg-checkbox-control');
expect(control).not.toBeNull();
expect(control?.getAttribute('aria-hidden')).toBe('true');
});
it('renders check icon inside control', async () => {
const doc = await renderCheckbox();
const checkIcon = doc.querySelector('.apg-checkbox-icon--check');
expect(checkIcon).not.toBeNull();
expect(checkIcon?.querySelector('svg')).not.toBeNull();
});
it('renders indeterminate icon inside control', async () => {
const doc = await renderCheckbox();
const indeterminateIcon = doc.querySelector('.apg-checkbox-icon--indeterminate');
expect(indeterminateIcon).not.toBeNull();
expect(indeterminateIcon?.querySelector('svg')).not.toBeNull();
});
});
// 🔴 High Priority: Checked State
describe('Checked State', () => {
it('renders unchecked by default', async () => {
const doc = await renderCheckbox();
const input = doc.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
expect(input?.hasAttribute('checked')).toBe(false);
});
it('renders checked when initialChecked is true', async () => {
const doc = await renderCheckbox({ initialChecked: true });
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.hasAttribute('checked')).toBe(true);
});
});
// 🔴 High Priority: Disabled State
describe('Disabled State', () => {
it('renders without disabled attribute by default', async () => {
const doc = await renderCheckbox();
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.hasAttribute('disabled')).toBe(false);
});
it('renders with disabled attribute when disabled is true', async () => {
const doc = await renderCheckbox({ disabled: true });
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.hasAttribute('disabled')).toBe(true);
});
});
// 🟡 Medium Priority: Indeterminate State
describe('Indeterminate State', () => {
it('does not have data-indeterminate by default', async () => {
const doc = await renderCheckbox();
const wrapper = doc.querySelector('apg-checkbox');
expect(wrapper?.hasAttribute('data-indeterminate')).toBe(false);
});
it('has data-indeterminate="true" when indeterminate is true', async () => {
const doc = await renderCheckbox({ indeterminate: true });
const wrapper = doc.querySelector('apg-checkbox');
expect(wrapper?.getAttribute('data-indeterminate')).toBe('true');
});
});
// 🟡 Medium Priority: Form Integration
describe('Form Integration', () => {
it('renders without name attribute by default', async () => {
const doc = await renderCheckbox();
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.hasAttribute('name')).toBe(false);
});
it('renders with name attribute when provided', async () => {
const doc = await renderCheckbox({ name: 'terms' });
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.getAttribute('name')).toBe('terms');
});
it('renders without value attribute by default', async () => {
const doc = await renderCheckbox();
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.hasAttribute('value')).toBe(false);
});
it('renders with value attribute when provided', async () => {
const doc = await renderCheckbox({ value: 'accepted' });
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.getAttribute('value')).toBe('accepted');
});
});
// 🟡 Medium Priority: Label Association
describe('Label Association', () => {
it('renders without id attribute by default', async () => {
const doc = await renderCheckbox();
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.hasAttribute('id')).toBe(false);
});
it('renders with id attribute when provided for external label association', async () => {
const doc = await renderCheckbox({ id: 'my-checkbox' });
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.getAttribute('id')).toBe('my-checkbox');
});
});
// 🟢 Low Priority: CSS Classes
describe('CSS Classes', () => {
it('has default apg-checkbox class on wrapper', async () => {
const doc = await renderCheckbox();
const wrapper = doc.querySelector('apg-checkbox');
expect(wrapper?.classList.contains('apg-checkbox')).toBe(true);
});
it('has apg-checkbox-input class on input', async () => {
const doc = await renderCheckbox();
const input = doc.querySelector('input');
expect(input?.classList.contains('apg-checkbox-input')).toBe(true);
});
it('appends custom class to wrapper', async () => {
const doc = await renderCheckbox({ class: 'custom-class' });
const wrapper = doc.querySelector('apg-checkbox');
expect(wrapper?.classList.contains('apg-checkbox')).toBe(true);
expect(wrapper?.classList.contains('custom-class')).toBe(true);
});
});
// 🟢 Low Priority: Combined States
describe('Combined States', () => {
it('renders checked and disabled together', async () => {
const doc = await renderCheckbox({ initialChecked: true, disabled: true });
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.hasAttribute('checked')).toBe(true);
expect(input?.hasAttribute('disabled')).toBe(true);
});
it('renders with all form attributes', async () => {
const doc = await renderCheckbox({
id: 'terms-checkbox',
name: 'terms',
value: 'accepted',
initialChecked: true,
});
const input = doc.querySelector('input[type="checkbox"]');
expect(input?.getAttribute('id')).toBe('terms-checkbox');
expect(input?.getAttribute('name')).toBe('terms');
expect(input?.getAttribute('value')).toBe('accepted');
expect(input?.hasAttribute('checked')).toBe(true);
});
});
}); 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