bpdm/ui
Form

Checkbox

An accessible checkbox — sizes, indeterminate and invalid states, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Checkbox toggles a single value on or off, with support for an indeterminate ("some selected") state. Built on Radix — controlled (checked

  • onCheckedChange) or uncontrolled (defaultChecked). In Angular it's <bpdm-checkbox>, driven by [(checked)], [(ngModel)], or reactive forms.

Usage

Pair the checkbox with a <label> linked by id so clicking the text toggles it.

checkbox-usage.tsx
import { Checkbox } from '@bpdm/ui/checkbox';

export function Terms() {
  return (
    <div className="flex items-center gap-2.5">
      <Checkbox id="terms" defaultChecked />
      <label htmlFor="terms">Accept terms &amp; conditions</label>
    </div>
  );
}
checkbox-usage.ts
import { Component } from '@angular/core';
import { BpdmCheckbox } from '@bpdm/ng';

@Component({
  selector: 'terms',
  imports: [BpdmCheckbox],
  templateUrl: './checkbox-usage.html',
})
export class Terms {}
checkbox-usage.html
<label class="flex items-center gap-2.5">
  <bpdm-checkbox [checked]="true" /> Accept terms &amp; conditions
</label>

States

Unchecked, checked, indeterminate, and disabled (on or off). Set checked="indeterminate" (React) / indeterminate (Angular) for the mixed state.

unchecked
checked
indeterminate
disabled
disabled on
checkbox-states.tsx
import { Checkbox } from '@bpdm/ui/checkbox';

export function States() {
  return (
    <div className="flex items-center gap-4">
      <Checkbox aria-label="Unchecked" />
      <Checkbox defaultChecked aria-label="Checked" />
      <Checkbox checked="indeterminate" aria-label="Indeterminate" />
      <Checkbox disabled aria-label="Disabled" />
      <Checkbox disabled defaultChecked aria-label="Disabled and checked" />
    </div>
  );
}
checkbox-states.component.ts
import { Component } from '@angular/core';
import { BpdmCheckbox } from '@bpdm/ng';

@Component({
  selector: 'app-states',
  standalone: true,
  imports: [BpdmCheckbox],
  template: `
    <div class="flex items-center gap-4">
      <bpdm-checkbox aria-label="Unchecked" />
      <bpdm-checkbox [checked]="true" aria-label="Checked" />
      <bpdm-checkbox indeterminate aria-label="Indeterminate" />
      <bpdm-checkbox disabled aria-label="Disabled" />
      <bpdm-checkbox disabled [checked]="true" aria-label="Disabled and checked" />
    </div>
  `,
})
export class StatesComponent {}

Sizes

The size prop offers sm, md (default), and lg.

sm
md
lg
checkbox-sizes.tsx
import { Checkbox } from '@bpdm/ui/checkbox';

export function Sizes() {
  return (
    <div className="flex items-center gap-4">
      <Checkbox size="sm" defaultChecked aria-label="Small" />
      <Checkbox size="md" defaultChecked aria-label="Medium" />
      <Checkbox size="lg" defaultChecked aria-label="Large" />
    </div>
  );
}
checkbox-sizes.component.ts
import { Component } from '@angular/core';
import { BpdmCheckbox } from '@bpdm/ng';

@Component({
  selector: 'app-sizes',
  standalone: true,
  imports: [BpdmCheckbox],
  template: `
    <div class="flex items-center gap-4">
      <bpdm-checkbox size="sm" [checked]="true" aria-label="Small" />
      <bpdm-checkbox size="md" [checked]="true" aria-label="Medium" />
      <bpdm-checkbox size="lg" [checked]="true" aria-label="Large" />
    </div>
  `,
})
export class SizesComponent {}

Invalid

Set aria-invalid for the error state (turns destructive-coloured) and link an error message with aria-describedby so screen readers announce it.

You must accept before continuing.

checkbox-invalid.tsx
import { Checkbox } from '@bpdm/ui/checkbox';

export function Invalid() {
  return (
    <div className="flex flex-col gap-1.5">
      <div className="flex items-center gap-2.5">
        <Checkbox id="agree" aria-invalid aria-describedby="agree-err" />
        <label htmlFor="agree">I agree to the terms</label>
      </div>
      <p id="agree-err" className="text-xs text-destructive">
        You must accept before continuing.
      </p>
    </div>
  );
}
checkbox-invalid.component.ts
import { Component } from '@angular/core';
import { BpdmCheckbox } from '@bpdm/ng';

@Component({
  selector: 'app-invalid',
  standalone: true,
  imports: [BpdmCheckbox],
  template: `
    <div class="flex flex-col gap-1.5">
      <label class="flex items-center gap-2.5">
        <bpdm-checkbox aria-invalid="true" aria-describedby="agree-err" /> I agree to the terms
      </label>
      <p id="agree-err" class="text-xs text-destructive">You must accept before continuing.</p>
    </div>
  `,
})
export class InvalidComponent {}

Checkbox group

A set of independent options — each is its own checkbox with a linked label.

checkbox-group.tsx
import { Checkbox } from '@bpdm/ui/checkbox';

const prefs = [
  { id: 'g-email', label: 'Email notifications', checked: true },
  { id: 'g-sms', label: 'SMS notifications', checked: false },
  { id: 'g-push', label: 'Push notifications', checked: true },
];

export function Preferences() {
  return (
    <div className="flex flex-col gap-3">
      {prefs.map((o) => (
        <div key={o.id} className="flex items-center gap-2.5">
          <Checkbox id={o.id} defaultChecked={o.checked} />
          <label htmlFor={o.id}>{o.label}</label>
        </div>
      ))}
    </div>
  );
}
checkbox-group.component.ts
import { Component } from '@angular/core';
import { BpdmCheckbox } from '@bpdm/ng';

@Component({
  selector: 'app-preferences',
  standalone: true,
  imports: [BpdmCheckbox],
  template: `
    @for (o of prefs; track o.label) {
      <label class="flex items-center gap-2.5">
        <bpdm-checkbox [(checked)]="o.on" /> {{ o.label }}
      </label>
    }
  `,
})
export class PreferencesComponent {
  prefs = [
    { label: 'Email notifications', on: true },
    { label: 'SMS notifications', on: false },
    { label: 'Push notifications', on: true },
  ];
}

Select all (indeterminate)

A parent checkbox reflects its children: checked when all are on, indeterminate when only some are, unchecked when none. Toggling it sets them all.

checkbox-select-all.tsx
import { useState } from 'react';
import { Checkbox } from '@bpdm/ui/checkbox';

const toppings = ['Cheese', 'Mushrooms', 'Olives'];

export function SelectAll() {
  const [checked, setChecked] = useState([true, false, false]);
  const all = checked.every(Boolean);
  const some = checked.some(Boolean);
  const parent = all ? true : some ? 'indeterminate' : false;

  return (
    <div className="flex flex-col gap-3">
      <label className="flex items-center gap-2.5 font-medium">
        <Checkbox
          checked={parent}
          onCheckedChange={(v) => setChecked(toppings.map(() => v === true))}
        />
        Select all
      </label>
      {toppings.map((t, i) => (
        <label key={t} className="flex items-center gap-2.5 ps-1">
          <Checkbox
            checked={checked[i]}
            onCheckedChange={(v) =>
              setChecked((prev) => prev.map((c, j) => (j === i ? v === true : c)))
            }
          />
          {t}
        </label>
      ))}
    </div>
  );
}

indeterminate is a separate input from [(checked)], so compute it from the children in your component:

checkbox-select-all.component.ts
import { Component, computed, signal } from '@angular/core';
import { BpdmCheckbox } from '@bpdm/ng';

@Component({
  selector: 'app-select-all',
  standalone: true,
  imports: [BpdmCheckbox],
  template: `
    <label class="flex items-center gap-2.5 font-medium">
      <bpdm-checkbox
        [checked]="allChecked()"
        [indeterminate]="someChecked() && !allChecked()"
        (checkedChange)="toggleAll($event)" />
      Select all
    </label>
    @for (t of toppings(); track t.name) {
      <label class="flex items-center gap-2.5 ps-1">
        <bpdm-checkbox [checked]="t.on" (checkedChange)="set(t.name, $event)" /> {{ t.name }}
      </label>
    }
  `,
})
export class SelectAllComponent {
  readonly toppings = signal([
    { name: 'Cheese', on: true },
    { name: 'Mushrooms', on: false },
    { name: 'Olives', on: false },
  ]);

  readonly allChecked = computed(() => this.toppings().every((t) => t.on));
  readonly someChecked = computed(() => this.toppings().some((t) => t.on));

  set(name: string, on: boolean): void {
    this.toppings.update((list) => list.map((t) => (t.name === name ? { ...t, on } : t)));
  }
  toggleAll(on: boolean): void {
    this.toppings.update((list) => list.map((t) => ({ ...t, on })));
  }
}

API

PropTypeDefaultDescription
checked / defaultCheckedboolean | 'indeterminate'Controlled / uncontrolled state. 'indeterminate' shows the mixed glyph.
onCheckedChange(checked: boolean | 'indeterminate') => voidReact — fires on toggle. Angular uses [(checked)] / [(ngModel)].
indeterminatebooleanfalseAngular — mixed state (React uses checked="indeterminate").
sizesm | md | lgmdControl size.
disabledbooleanfalseDisable the control.
aria-invalidbooleanError state.
idstringControl id (label association / testing), forwarded to the checkbox.
aria-label / aria-labelledby / aria-describedbystringAccessible name / description, forwarded to the checkbox.

In React, all native Radix Checkbox props (name, value, required, id, aria-*, data-*, …) are spread onto the control and the ref is forwarded. In Angular, size / indeterminate / aria-invalid / id / aria-label / aria-labelledby / aria-describedby are @Inputs on <bpdm-checkbox>, which is also a ControlValueAccessor ([(ngModel)] / reactive forms) with a standalone [(checked)].

Accessibility

  • A true role="checkbox" with aria-checked (true / false / mixed) and keyboard support (Space toggles; Tab moves focus with a visible ring).
  • Give it an accessible name — link a <label> by id (htmlFor in React, for in Angular), wrap the control in the label, or pass aria-label / aria-labelledby. These reach the control in both frameworks.
  • Set aria-invalid when validation fails and link the message with aria-describedby so assistive tech reads the error.
  • The indeterminate state is communicated as aria-checked="mixed", not by the glyph alone — and the check/dash glyph is aria-hidden, so nothing is announced twice.

Internationalization

  • The checkbox renders no text of its own, so there's nothing to translate inside it — pass a translated accessible name (aria-label, or the <label> you associate) and translated help/error text via aria-describedby.
  • It's RTL-safe by construction: the control is a symmetric box with no physical spacing, so it mirrors correctly under dir="rtl" (the box/label order flips with your layout's writing direction). See the Internationalization guide.

On this page