APG Patterns
日本語
日本語

Checkbox

A control that allows users to select one or more options from a set.

Demo

Open demo only →

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 CaseNative HTMLCustom Implementation
Basic form inputRecommendedNot needed
JavaScript disabled supportWorks nativelyRequires fallback
Indeterminate (mixed) stateJS property only*Full control
Custom stylingLimited (browser-dependent)Full control
Form submissionBuilt-inRequires 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.

WAI-ARIA Properties

aria-label

Provides accessible name

Values
string
Required
When no visible label

aria-labelledby

References external text as label

Values
ID reference
Required
When no visible label

aria-describedby

Additional description

Values
ID reference
Required
No

WAI-ARIA States

aria-checked / checked

Target Element
Checkbox element
Values
true | false | mixed
Required
Yes
Change Trigger
Click, Space key

indeterminate

Target Element
Native checkbox (<input>)
Values
true | false
Required
No
Change Trigger
Parent-child sync, automatically cleared on user interaction

disabled

Target Element
Checkbox element
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
  • 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

Focus Management

Event Behavior
Native checkbox Focusable by default
Custom implementation Requires tabindex="0"
Disabled checkbox Skipped in Tab order

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

Checkbox.svelte
<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));
  // eslint-disable-next-line svelte/prefer-writable-derived -- isIndeterminate is modified both by props sync and user interaction in handleChange
  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

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

PropTypeDefaultDescription
initialCheckedbooleanfalseInitial checked state
indeterminatebooleanfalseWhether the checkbox is in an indeterminate (mixed) state
onCheckedChange(checked: boolean) => void-Callback when state changes
disabledbooleanfalseWhether the checkbox is disabled
namestring-Form field name
valuestring-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)

TestDescription
input typeRenders input with type="checkbox"
checked attributeChecked attribute reflects initialChecked prop
disabled attributeDisabled attribute is set when disabled prop is true
data-indeterminateData attribute set for indeterminate state
control aria-hiddenVisual control element has aria-hidden="true"

High Priority: Keyboard Interaction (E2E)

TestDescription
Space keyToggles the checkbox state
Tab navigationTab moves focus between checkboxes
Disabled Tab skipDisabled checkboxes are skipped in Tab order
Disabled key ignoreDisabled checkboxes ignore key presses

Note: Unlike the Switch pattern, the Enter key does not toggle the checkbox.

High Priority: Click Interaction (E2E)

TestDescription
checked toggleClick toggles checked state
disabled clickDisabled checkboxes prevent click interaction
indeterminate clearUser interaction clears indeterminate state
checkedchange eventCustom event dispatched with correct detail

Medium Priority: Form Integration (Unit)

TestDescription
name attributeForm name attribute is rendered
value attributeForm value attribute is rendered
id attributeID attribute is correctly set for label association

Medium Priority: Label Association (E2E)

TestDescription
Label clickClicking external label toggles checkbox
Wrapping labelClicking wrapping label toggles checkbox

Low Priority: CSS Classes (Unit)

TestDescription
default classapg-checkbox class is applied to wrapper
custom classCustom classes are merged with component classes

Testing Tools

See the Testing Strategy guide for details.

Checkbox.test.svelte.ts
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