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.tsx
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

Example
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

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
idstring-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)

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.tsx
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