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.astro
---
/**
 * 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

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

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

Checkbox.test.astro.ts
/**
 * 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