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.vue
<template>
  <span class="apg-checkbox" v-bind="wrapperAttrs">
    <input
      ref="inputRef"
      type="checkbox"
      class="apg-checkbox-input"
      :id="props.id"
      :checked="checked"
      :disabled="props.disabled"
      :name="props.name"
      :value="props.value"
      v-bind="inputAttrs"
      @change="handleChange"
    />
    <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>
</template>

<script setup lang="ts">
import { computed, onMounted, ref, useAttrs, watch } from 'vue';

defineOptions({
  inheritAttrs: false,
});

export interface CheckboxProps {
  /** 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;
  /** ID for external label association */
  id?: string;
  /** Callback fired when checked state changes */
  onCheckedChange?: (checked: boolean) => void;
}

const props = withDefaults(defineProps<CheckboxProps>(), {
  initialChecked: false,
  indeterminate: false,
  disabled: false,
  name: undefined,
  value: undefined,
  id: undefined,
  onCheckedChange: undefined,
});

const attrs = useAttrs() as {
  class?: string;
  'data-testid'?: string;
  'aria-describedby'?: string;
  'aria-label'?: string;
  'aria-labelledby'?: string;
  id?: string;
  [key: string]: unknown;
};

const emit = defineEmits<{
  change: [checked: boolean];
}>();

const inputRef = ref<HTMLInputElement | null>(null);
const checked = ref(props.initialChecked);
const isIndeterminate = ref(props.indeterminate);

// Separate attrs for wrapper and input
const wrapperAttrs = computed(() => {
  return {
    class: attrs.class,
    'data-testid': attrs['data-testid'],
  };
});

const inputAttrs = computed(() => {
  const { class: _className, 'data-testid': _testId, ...rest } = attrs;
  return rest;
});

// Update indeterminate property on the input element
const updateIndeterminate = () => {
  if (inputRef.value) {
    inputRef.value.indeterminate = isIndeterminate.value;
  }
};

onMounted(() => {
  updateIndeterminate();
});

watch(
  () => props.indeterminate,
  (newValue) => {
    isIndeterminate.value = newValue;
  }
);

watch(isIndeterminate, () => {
  updateIndeterminate();
});

const handleChange = (event: Event) => {
  const target = event.target as HTMLInputElement;
  const newChecked = target.checked;
  checked.value = newChecked;
  isIndeterminate.value = false;
  props.onCheckedChange?.(newChecked);
  emit('change', newChecked);
};
</script>

Usage

Example
<script setup>
import Checkbox from './Checkbox.vue';

function handleChange(checked) {
  console.log('Checked:', checked);
}
</script>

<template>
  <form>
    <!-- With wrapping label -->
    <label class="inline-flex items-center gap-2">
      <Checkbox name="terms" @change="handleChange" />
      I agree to the terms and conditions
    </label>

    <!-- With separate label -->
    <label for="newsletter">Subscribe to newsletter</label>
    <Checkbox id="newsletter" name="newsletter" :initial-checked="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>
</template>

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

Events

Event Payload Description
@change boolean Emitted when state changes

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.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Checkbox from './Checkbox.vue';

describe('Checkbox (Vue)', () => {
  // 🔴 High Priority: DOM State
  describe('DOM State', () => {
    it('has role="checkbox"', () => {
      render(Checkbox, {
        attrs: { 'aria-label': 'Accept terms' },
      });
      expect(screen.getByRole('checkbox')).toBeInTheDocument();
    });

    it('is unchecked by default', () => {
      render(Checkbox, {
        attrs: { 'aria-label': 'Accept terms' },
      });
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).not.toBeChecked();
    });

    it('is checked when initialChecked=true', () => {
      render(Checkbox, {
        props: { initialChecked: true },
        attrs: { 'aria-label': 'Accept terms' },
      });
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toBeChecked();
    });

    it('toggles checked state on click', async () => {
      const user = userEvent.setup();
      render(Checkbox, {
        attrs: { '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: { indeterminate: true },
        attrs: { 'aria-label': 'Select all' },
      });
      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: { indeterminate: true },
        attrs: { 'aria-label': 'Select all' },
      });
      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: { disabled: true },
        attrs: { 'aria-label': 'Accept terms' },
      });
      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: { disabled: true },
        attrs: { 'aria-label': 'Accept terms' },
      });
      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, {
        attrs: { 'aria-label': 'Accept terms and conditions' },
      });
      expect(
        screen.getByRole('checkbox', { name: 'Accept terms and conditions' })
      ).toBeInTheDocument();
    });

    it('sets accessible name via external <label>', () => {
      render({
        components: { Checkbox },
        template: `
          <div>
            <label for="terms-checkbox">Accept terms and conditions</label>
            <Checkbox id="terms-checkbox" />
          </div>
        `,
      });
      expect(
        screen.getByRole('checkbox', { name: 'Accept terms and conditions' })
      ).toBeInTheDocument();
    });

    it('toggles checkbox when clicking external label', async () => {
      const user = userEvent.setup();
      render({
        components: { Checkbox },
        template: `
          <div>
            <label for="terms-checkbox">Accept terms</label>
            <Checkbox id="terms-checkbox" />
          </div>
        `,
      });
      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, {
        props: { name: 'terms' },
        attrs: { 'aria-label': 'Accept terms' },
      });
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toHaveAttribute('name', 'terms');
    });

    it('sets value attribute correctly', () => {
      render(Checkbox, {
        props: { name: 'color', value: 'red' },
        attrs: { 'aria-label': '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, {
        attrs: { '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({
        components: { Checkbox },
        template: `
          <div>
            <Checkbox aria-label="Checkbox 1" />
            <Checkbox aria-label="Checkbox 2" />
          </div>
        `,
      });

      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({
        components: { Checkbox },
        template: `
          <div>
            <Checkbox aria-label="Checkbox 1" />
            <Checkbox aria-label="Checkbox 2 (disabled)" disabled />
            <Checkbox aria-label="Checkbox 3" />
          </div>
        `,
      });

      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, {
        props: { disabled: true },
        attrs: { 'aria-label': 'Accept terms' },
      });
      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, {
        attrs: { '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: { initialChecked: true },
        attrs: { 'aria-label': 'Accept terms' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when indeterminate', async () => {
      const { container } = render(Checkbox, {
        props: { indeterminate: true },
        attrs: { 'aria-label': 'Select all' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(Checkbox, {
        props: { disabled: true },
        attrs: { 'aria-label': 'Accept terms' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with external label', async () => {
      const { container } = render({
        components: { Checkbox },
        template: `
          <div>
            <label for="terms">Accept terms</label>
            <Checkbox id="terms" />
          </div>
        `,
      });
      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: { onCheckedChange: handleCheckedChange },
        attrs: { 'aria-label': 'Accept terms' },
      });

      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: { indeterminate: true, onCheckedChange: handleCheckedChange },
        attrs: { 'aria-label': 'Select all' },
      });

      await user.click(screen.getByRole('checkbox'));
      expect(handleCheckedChange).toHaveBeenCalledWith(true);
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('merges class correctly', () => {
      render(Checkbox, {
        attrs: { class: 'custom-class', 'data-testid': 'wrapper', 'aria-label': 'Accept terms' },
      });
      const wrapper = screen.getByTestId('wrapper');
      expect(wrapper).toHaveClass('custom-class');
      expect(wrapper).toHaveClass('apg-checkbox');
    });

    it('passes through data-* attributes', () => {
      render(Checkbox, {
        attrs: { 'data-testid': 'custom-checkbox', 'aria-label': 'Accept terms' },
      });
      expect(screen.getByTestId('custom-checkbox')).toBeInTheDocument();
    });
  });
});

Resources