Skip to content

Checkbox

A customizable boolean form control with indeterminate state support, plus a group wrapper for managing multi-selection lists.

  • bit-checkbox — standalone checkbox for a single boolean value.
  • bit-checkbox-group<fieldset> wrapper that manages a set of checkboxes, propagates color, size, and disabled to all children, and tracks checked values as a comma-separated string.

Features

Checkbox

  • Accessiblearia-checked including "mixed" for indeterminate; keyboard toggle
  • 🌈 6 Semantic Colors — primary, secondary, info, success, warning, error
  • 🎛️ Indeterminate State — partial selection indicator for "select all" patterns
  • 🎭 States — checked, unchecked, indeterminate, disabled
  • 📏 3 Sizes — sm, md, lg
  • 🔧 Customizable — CSS custom properties for size, radius, and colors

Checkbox Group

  • ↕️ 2 Orientations — vertical & horizontal
  • 💬 Validation Feedbackhelper and error text with ARIA wiring
  • 📝 Form Integration — comma-separated value submits with any <form> or bit-form
  • 📡 Context Propagationcolor, size, and disabled propagate to all child checkboxes
  • 🗂️ Semantic Markup — renders as <fieldset> + <legend> for proper screen reader grouping

Source Code

View Checkbox Source
ts
import { defineComponent, html, inject, signal, watch } from '@vielzeug/craftit';
import { useA11yControl, createCheckableControl } from '@vielzeug/craftit/labs';

import type { CheckableProps, DisablableProps, SizableProps, ThemableProps } from '../../types';

import { coarsePointerMixin, formControlMixins, sizeVariantMixin } from '../../styles';
import { CHECKBOX_GROUP_CTX } from '../checkbox-group/checkbox-group';
import { useToggleField } from '../shared/composables';
import { CONTROL_SIZE_PRESET } from '../shared/design-presets';
import { mountFormContextSync } from '../shared/dom-sync';
import componentStyles from './checkbox.css?inline';

export type BitCheckboxEvents = {
  change: { checked: boolean; fieldValue: string; originalEvent?: Event; value: boolean };
};

export type BitCheckboxProps = CheckableProps &
  ThemableProps &
  SizableProps &
  DisablableProps & {
    /** Error message (marks field as invalid) */
    error?: string;
    /** Helper text displayed below the checkbox */
    helper?: string;
    /** Indeterminate state (partially checked) */
    indeterminate?: boolean;
  };

/**
 * A customizable checkbox component with theme colors, sizes, and indeterminate state support.
 *
 * @element bit-checkbox
 *
 * @attr {boolean} checked - Checked state
 * @attr {boolean} disabled - Disable checkbox interaction
 * @attr {boolean} indeterminate - Indeterminate (partially checked) state
 * @attr {string} value - Field value submitted with forms
 * @attr {string} name - Form field name
 * @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
 * @attr {string} size - Checkbox size: 'sm' | 'md' | 'lg'
 * @attr {string} error - Error message (marks field as invalid)
 * @attr {string} helper - Helper text displayed below the checkbox
 *
 * @fires change - Emitted when checkbox is toggled. detail: { value: boolean, checked: boolean, fieldValue: string, originalEvent?: Event }
 *
 * @slot - Checkbox label text
 *
 * @part checkbox - The checkbox wrapper element
 * @part box - The visual checkbox box
 * @part label - The label element
 * @part helper-text - The helper/error text element
 */
export const CHECKBOX_TAG = defineComponent<BitCheckboxProps, BitCheckboxEvents>({
  formAssociated: true,
  props: {
    checked: { default: false },
    color: { default: undefined },
    disabled: { default: false },
    error: { default: '' },
    helper: { default: '' },
    indeterminate: { default: false },
    name: { default: '' },
    size: { default: undefined },
    value: { default: 'on' },
  },
  setup({ emit, host, props, reflect }) {
    // Form integration — provides checkedSignal and triggerValidation
    const { checkedSignal, formCtx, triggerValidation } = useToggleField(props);

    mountFormContextSync(host, formCtx, props);

    // Separate writable indeterminate signal, synced from the prop
    const indeterminateSignal = signal(Boolean(props.indeterminate.value));

    watch(props.indeterminate, (v) => {
      indeterminateSignal.value = Boolean(v);
    });

    const groupCtx = inject(CHECKBOX_GROUP_CTX, undefined);

    // Pass the writable checkedSignal directly — toggle() mutates it in place
    const controlHandle = createCheckableControl({
      checked: checkedSignal,
      clearIndeterminateFirst: true,
      disabled: props.disabled,
      group: groupCtx,
      indeterminate: indeterminateSignal,
      onToggle: (e) => {
        triggerValidation('change');

        // In a checkbox-group, the group owns change emission/state updates.
        // Emitting here would bubble to the group and toggle a second time.
        if (groupCtx) return;

        emit('change', controlHandle.changePayload(e));
      },
      value: props.value,
    });

    const a11y = useA11yControl(host, {
      checked: () => {
        if (controlHandle.indeterminate.value) return 'mixed';

        return controlHandle.checked.value ? 'true' : 'false';
      },
      helperText: () => props.error.value || props.helper.value,
      helperTone: () => (props.error.value ? 'error' : 'default'),
      invalid: () => !!props.error.value,
      role: 'checkbox',
    });

    reflect({
      checked: () => controlHandle.checked.value,
      classMap: () => ({
        'is-checked': controlHandle.checked.value,
        'is-disabled': !!props.disabled.value,
        'is-indeterminate': controlHandle.indeterminate.value,
      }),
      indeterminate: () => controlHandle.indeterminate.value,
      onClick: (e: Event) => controlHandle.toggle(e),
      onKeydown: (e: Event) => {
        const ke = e as KeyboardEvent;

        if (ke.key === ' ' || ke.key === 'Enter') {
          ke.preventDefault();
          controlHandle.toggle(e);
        }
      },
      tabindex: () => (props.disabled.value ? undefined : 0),
    });

    return html`
      <div class="checkbox-wrapper" part="checkbox">
        <div class="box" part="box">
          <svg
            class="checkmark"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            xmlns="http://www.w3.org/2000/svg">
            <path d="M 20,6 9,17 4,12" />
          </svg>
          <svg
            class="dash"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            xmlns="http://www.w3.org/2000/svg">
            <path d="M 5,12 H 19" />
          </svg>
        </div>
      </div>
      <span class="label" part="label" data-a11y-label id="${a11y.labelId}"><slot></slot></span>
      <div
        class="helper-text"
        part="helper-text"
        data-a11y-helper
        id="${a11y.helperId}"
        aria-live="polite"
        hidden></div>
    `;
  },
  styles: [...formControlMixins, coarsePointerMixin, sizeVariantMixin(CONTROL_SIZE_PRESET), componentStyles],
  tag: 'bit-checkbox',
});
View Checkbox Group Source
ts
import {
  computed,
  createContext,
  createId,
  defineComponent,
  effect,
  handle,
  html,
  inject,
  onMount,
  onSlotChange,
  provide,
  type ReadonlySignal,
  signal,
} from '@vielzeug/craftit';

import type { ComponentSize, ThemeColor } from '../../types';

import { colorThemeMixin, disabledStateMixin, sizeVariantMixin } from '../../styles';
import { mountFormContextSync } from '../shared/dom-sync';
import { FORM_CTX } from '../shared/form-context';
import { createChoiceChangeDetail, parseCsvValues, type ChoiceChangeDetail } from '../shared/utils';
import componentStyles from './checkbox-group.css?inline';

// ─── Context ──────────────────────────────────────────────────────────────────

export type CheckboxGroupContext = {
  color: ReadonlySignal<ThemeColor | undefined>;
  disabled: ReadonlySignal<boolean>;
  size: ReadonlySignal<ComponentSize | undefined>;
  toggle: (value: string, originalEvent?: Event) => void;
  values: ReadonlySignal<string[]>;
};

export const CHECKBOX_GROUP_CTX = createContext<CheckboxGroupContext>('CheckboxGroupContext');

// ─── Types ────────────────────────────────────────────────────────────────────

export type BitCheckboxGroupProps = {
  /** Theme color — propagated to all child bit-checkbox elements */
  color?: ThemeColor;
  /** Disable all checkboxes in the group */
  disabled?: boolean;
  /** Error message shown below the group */
  error?: string;
  /** Helper text shown below the group */
  helper?: string;
  /** Legend / label for the fieldset. Required for accessibility. */
  label?: string;
  /** Layout direction of the checkbox options */
  orientation?: 'vertical' | 'horizontal';
  /** Mark the group as required */
  required?: boolean;
  /** Size — propagated to all child bit-checkbox elements */
  size?: ComponentSize;
  /** Comma-separated list of currently checked values */
  values?: string;
};

export type BitCheckboxGroupEvents = {
  change: ChoiceChangeDetail;
};

/**
 * A fieldset wrapper that groups `bit-checkbox` elements, provides shared
 * `color` and `size` via context, and manages multi-value selection state.
 *
 * @element bit-checkbox-group
 *
 * @attr {string} label - Legend text (required for a11y)
 * @attr {string} values - Comma-separated list of checked values
 * @attr {boolean} disabled - Disable all checkboxes in the group
 * @attr {string} error - Error message
 * @attr {string} helper - Helper text
 * @attr {string} color - Theme color
 * @attr {string} size - Component size: 'sm' | 'md' | 'lg'
 * @attr {string} orientation - Layout: 'vertical' | 'horizontal'
 * @attr {boolean} required - Required field
 *
 * @fires change - Emitted when selection changes. detail: { value: string, values: string[], labels: string[], originalEvent?: Event }
 *
 * @slot - Place `bit-checkbox` elements here
 */
export const CHECKBOX_GROUP_TAG = defineComponent<BitCheckboxGroupProps, BitCheckboxGroupEvents>({
  props: {
    color: { default: undefined },
    disabled: { default: false },
    error: { default: '' },
    helper: { default: '' },
    label: { default: '' },
    orientation: { default: 'vertical' },
    required: { default: false },
    size: { default: undefined },
    values: { default: '' },
  },
  setup({ emit, host, props }) {
    // Parse comma-separated value string into an array of checked values
    const parseValues = (v: string | undefined): string[] => parseCsvValues(v);
    const checkedValues = signal<string[]>(parseValues(props.values.value));

    const getCheckboxes = (): HTMLElement[] => Array.from(host.getElementsByTagName('bit-checkbox')) as HTMLElement[];
    const getLabelForValue = (value: string): string => {
      const checkbox = getCheckboxes().find((item) => (item.getAttribute('value') ?? '') === value);

      return checkbox?.textContent?.replace(/\s+/g, ' ').trim() || value;
    };
    const emitChange = (originalEvent?: Event) => {
      const values = checkedValues.value;

      emit('change', createChoiceChangeDetail(values, values.map(getLabelForValue), originalEvent));
    };

    // Keep checkedValues in sync when prop changes externally
    effect(() => {
      checkedValues.value = parseValues(props.values.value);
    });

    const toggleCheckbox = (val: string, originalEvent?: Event) => {
      const current = checkedValues.value;
      const next = current.includes(val) ? current.filter((v) => v !== val) : [...current, val];

      checkedValues.value = next;
      host.setAttribute('values', next.join(','));
      emitChange(originalEvent);
    };
    const formCtx = inject(FORM_CTX, undefined);

    mountFormContextSync(host, formCtx, props);

    provide(CHECKBOX_GROUP_CTX, {
      color: props.color,
      disabled: computed(() => Boolean(props.disabled.value)),
      size: props.size,
      toggle: toggleCheckbox,
      values: checkedValues,
    });

    // Sync checked state + color/size/disabled onto slotted bit-checkbox children
    const syncChildren = () => {
      const values = checkedValues.value;
      const color = props.color.value;
      const size = props.size.value;
      const disabled = props.disabled.value;
      const checkboxes = Array.from(host.getElementsByTagName('bit-checkbox')) as HTMLElement[];

      for (const checkbox of checkboxes) {
        const val = checkbox.getAttribute('value') ?? '';

        if (values.includes(val)) checkbox.setAttribute('checked', '');
        else checkbox.removeAttribute('checked');

        if (color) checkbox.setAttribute('color', color);
        else checkbox.removeAttribute('color');

        if (size) checkbox.setAttribute('size', size);
        else checkbox.removeAttribute('size');

        if (disabled) checkbox.setAttribute('disabled', '');
        else checkbox.removeAttribute('disabled');
      }
    };

    effect(syncChildren);

    onMount(() => {
      onSlotChange('default', syncChildren);
      syncChildren();

      handle(host, 'change', (e: Event) => {
        if (e.target === host) return;

        e.stopPropagation();

        const val = (e.target as HTMLElement).getAttribute('value') ?? '';

        toggleCheckbox(val, e);
      });
    });

    const legendId = createId('checkbox-group-legend');
    const errorId = `${legendId}-error`;
    const helperId = `${legendId}-helper`;
    const hasError = computed(() => Boolean(props.error.value));
    const hasHelper = computed(() => Boolean(props.helper.value) && !hasError.value);

    return html`
      <fieldset
        role="group"
        aria-required="${() => String(Boolean(props.required.value))}"
        aria-invalid="${() => String(hasError.value)}"
        aria-errormessage="${() => (hasError.value ? errorId : null)}"
        aria-describedby="${() => (hasError.value ? errorId : hasHelper.value ? helperId : null)}">
        <legend id="${legendId}" ?hidden=${() => !props.label.value}>
          ${() => props.label.value}${() => (props.required.value ? html`<span aria-hidden="true"> *</span>` : '')}
        </legend>
        <div class="checkbox-group-items" part="items">
          <slot></slot>
        </div>
        <div class="error-text" id="${errorId}" role="alert" ?hidden=${() => !hasError.value}>
          ${() => props.error.value}
        </div>
        <div class="helper-text" id="${helperId}" ?hidden=${() => !hasHelper.value}>${() => props.helper.value}</div>
      </fieldset>
    `;
  },
  styles: [colorThemeMixin, sizeVariantMixin(), disabledStateMixin(), componentStyles],
  tag: 'bit-checkbox-group',
});

Checkbox

Basic Usage

html
<bit-checkbox>Accept terms and conditions</bit-checkbox>

<script type="module">
  import '@vielzeug/buildit/checkbox';
</script>

Colors

Six semantic colors to match your design language or validation state.

PreviewCode
RTL

Sizes

PreviewCode
RTL

Indeterminate

Use the indeterminate state for "select all" controls where only some items in a sub-list are checked. First click resolves to checked; subsequent clicks toggle normally.

PreviewCode
RTL

States

Disabled

PreviewCode
RTL

Helper & Error Text

Provide contextual feedback directly below the checkbox.

PreviewCode
RTL

Listening for Changes

js
const checkbox = document.querySelector('bit-checkbox');
checkbox.addEventListener('change', (e) => {
  console.log('checked:', e.detail.checked);
  console.log('value:', e.detail.value);
});

Checkbox Group

bit-checkbox-group wraps bit-checkbox elements in a <fieldset>. Set value to a comma-separated string to pre-select options.

Basic Usage

html
<bit-checkbox-group label="Interests" value="sport,music">
  <bit-checkbox value="sport">Sport</bit-checkbox>
  <bit-checkbox value="music">Music</bit-checkbox>
  <bit-checkbox value="travel">Travel</bit-checkbox>
</bit-checkbox-group>

<script type="module">
  import '@vielzeug/buildit/checkbox-group';
  import '@vielzeug/buildit/checkbox';
</script>

Orientation

PreviewCode
RTL

Colors & Sizes

color and size set on the group propagate automatically to all child checkboxes.

PreviewCode
RTL

Validation Feedback

PreviewCode
RTL

Disabled

Disabling the group propagates to all child checkboxes.

PreviewCode
RTL

Form Integration

The group's checked values are stored as a comma-separated value attribute and submitted with any <form> or bit-form.

html
<bit-form id="prefs-form" novalidate>
  <bit-checkbox-group name="contact" label="Preferred contact" required>
    <bit-checkbox value="email">Email</bit-checkbox>
    <bit-checkbox value="phone">Phone</bit-checkbox>
    <bit-checkbox value="sms">SMS</bit-checkbox>
  </bit-checkbox-group>
  <bit-button type="submit">Save Preferences</bit-button>
</bit-form>

<script type="module">
  import '@vielzeug/buildit/form';
  import '@vielzeug/buildit/checkbox-group';
  import '@vielzeug/buildit/checkbox';
  import '@vielzeug/buildit/button';

  document.getElementById('prefs-form').addEventListener('submit', (e) => {
    console.log('contact:', e.detail.formData.get('contact'));
  });

  document.querySelector('bit-checkbox-group').addEventListener('change', (e) => {
    console.log('Checked values:', e.detail.values);
  });
</script>

Select All Pattern

Combine indeterminate state on a parent checkbox with a bit-checkbox-group to build a "select all" control.

html
<bit-checkbox id="select-all" indeterminate>Select all</bit-checkbox>
<bit-checkbox-group id="options" label="Options" value="a">
  <bit-checkbox value="a">Option A</bit-checkbox>
  <bit-checkbox value="b">Option B</bit-checkbox>
  <bit-checkbox value="c">Option C</bit-checkbox>
</bit-checkbox-group>

<script type="module">
  import '@vielzeug/buildit/checkbox';
  import '@vielzeug/buildit/checkbox-group';

  const all = document.getElementById('select-all');
  const group = document.getElementById('options');
  const options = ['a', 'b', 'c'];

  function syncParent() {
    const checked = group.getAttribute('value')?.split(',').filter(Boolean) ?? [];
    if (checked.length === 0) {
      all.removeAttribute('checked');
      all.removeAttribute('indeterminate');
    } else if (checked.length === options.length) {
      all.setAttribute('checked', '');
      all.removeAttribute('indeterminate');
    } else {
      all.removeAttribute('checked');
      all.setAttribute('indeterminate', '');
    }
  }

  all.addEventListener('change', (e) => {
    if (e.detail.checked) {
      group.setAttribute('value', options.join(','));
    } else {
      group.setAttribute('value', '');
    }
    syncParent();
  });

  group.addEventListener('change', syncParent);
  syncParent();
</script>

API Reference

bit-checkbox Attributes

AttributeTypeDefaultDescription
checkedbooleanfalseChecked state
indeterminatebooleanfalseIndeterminate (partially checked) state
disabledbooleanfalseDisable interaction
valuestring'on'Value submitted with the form
namestring''Form field name
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Semantic color for the checked state
size'sm' | 'md' | 'lg''md'Checkbox size
helperstring''Helper text displayed below
errorstring''Error message (marks field invalid)

bit-checkbox Slots

SlotDescription
(default)Checkbox label text

bit-checkbox Parts

PartDescription
checkboxThe checkbox wrapper element
boxThe visual checkbox square
labelThe label text element

bit-checkbox Events

EventDetailDescription
change{ checked: boolean, value: string }Emitted when the checked state changes

bit-checkbox CSS Custom Properties

PropertyDescription
--checkbox-sizeCheckbox dimensions
--checkbox-radiusBorder radius
--checkbox-bgBackground color (unchecked state)
--checkbox-checked-bgBackground color (checked state)
--checkbox-border-colorBorder color
--checkbox-colorCheckmark icon color
--checkbox-font-sizeLabel font size

bit-checkbox-group Attributes

AttributeTypeDefaultDescription
labelstring''Legend text — required for accessibility
valuestring''Comma-separated currently checked values (e.g. "a,b")
namestring''Form field name
orientation'vertical' | 'horizontal''vertical'Layout direction of options
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Color propagated to all child checkboxes
size'sm' | 'md' | 'lg'Size propagated to all child checkboxes
disabledbooleanfalseDisable all checkboxes in the group
requiredbooleanfalseMark the group as required
errorstring''Error message shown below the group (also sets ARIA invalid)
helperstring''Helper text (hidden when error is set)

bit-checkbox-group Slots

SlotDescription
(default)Place bit-checkbox elements here

bit-checkbox-group Events

EventDetailDescription
change{ values: string[] }Full array of currently checked values after any toggle

Accessibility

The checkbox components follow WCAG 2.1 Level AA standards.

bit-checkbox

Keyboard Navigation

  • Space / Enter toggle the focused checkbox; Tab moves focus in and out.

Screen Readers

  • Uses role="checkbox" with aria-checked set to "true", "false", or "mixed" for indeterminate.
  • aria-labelledby links the label; aria-describedby links helper text; aria-errormessage links error text.
  • aria-disabled reflects the disabled state.

bit-checkbox-group

Semantic Structure

  • Renders as a <fieldset> with a <legend> for the label attribute.

Keyboard Navigation

  • Tab moves to the next checkbox within the group.

Screen Readers

  • aria-required and aria-invalid reflect the group validation state; aria-errormessage and aria-describedby link the text nodes.

Best Practices

Do:

  • Always provide a meaningful label on the group — it is the accessible name read before each option.
  • Use indeterminate on a "select all" checkbox to represent partial selection.
  • Use orientation="horizontal" only for short option labels that comfortably fit on one line.
  • Pair error with color="error" to reinforce validation failures visually.
  • Prefer name on the group (not individual checkboxes) when submitting with a form.

Don't:

  • Use bit-checkbox-group for mutually exclusive choices — use bit-radio-group instead.
  • Omit the label attribute on the group; without it the fieldset has no accessible name.
  • Place non-bit-checkbox elements as direct children of bit-checkbox-group.