APG Patterns
日本語 GitHub
日本語 GitHub

Checkbox

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

🤖 AI Implementation Guide

Demo

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

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

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

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

See testing-strategy.md (opens in new tab) for full documentation.

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