Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Checkbox

A selection control for multiple independent options.

A selection control for toggling one or more independent options, styled per platform — an MD3 rounded-square container on Android, a circular selection control on iOS.

Installation

npx touchcn add checkbox

Usage

<tcn-checkbox [(checked)]="agreed">I agree</tcn-checkbox>
import { TcnCheckbox } from '@/components/ui/checkbox';

<TcnCheckbox checked={agreed} onCheckedChange={setAgreed}>
  I agree
</TcnCheckbox>
<script setup lang="ts">
import { TcnCheckbox } from '@/components/ui/checkbox';
</script>

<template>
  <TcnCheckbox v-model="agreed">I agree</TcnCheckbox>
</template>

The label is projected as children so the whole control is a single tap and focus target.

Props

Prop Type Default Description
checked boolean | 'indeterminate' false Checked state (two-way bound).
onCheckedChange (checked: boolean) => void Change callback (React).
indeterminate boolean false Indeterminate state (Angular model).
disabled boolean false Disable the checkbox.

On React, TcnCheckbox forwards Radix Checkbox props; pass checked="indeterminate" for the mixed state. On Angular the mixed state is a separate two-way indeterminate model.

Source · Angular
export * from './tcn-checkbox';
import { booleanAttribute, Component, computed, input, model } from '@angular/core';

/**
 * Checkbox with a real `role="checkbox"` button for native semantics (Space
 * toggles, `aria-checked` reflects state including `mixed` for indeterminate).
 * The label is projected into the button so the whole control is one tap and
 * focus target and the text supplies the accessible name. A single markup shape
 * is forked by the cascade — MD3 rounded square vs iOS circle — and `data-state`
 * drives every visual (`theme.css`).
 */
@Component({
  selector: 'tcn-checkbox',
  template: `
    <button
      type="button"
      role="checkbox"
      class="tcn-checkbox"
      [attr.aria-label]="ariaLabel()"
      [attr.aria-checked]="ariaChecked()"
      [attr.data-state]="state()"
      [disabled]="disabled()"
      (click)="toggle()"
    >
      <span class="tcn-checkbox-box">
        <span class="tcn-checkbox-state" aria-hidden="true"></span>
        @if (state() === 'checked') {
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M5 12l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
        } @else if (state() === 'indeterminate') {
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M6 12h12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
          </svg>
        }
      </span>
      <ng-content />
    </button>
  `,
  host: { class: 'inline-flex' },
})
export class TcnCheckbox {
  readonly checked = model(false);
  readonly indeterminate = model(false);
  readonly disabled = input(false, { transform: booleanAttribute });
  /** Accessible name for a checkbox rendered without slotted label text. */
  readonly ariaLabel = input<string | null>(null, { alias: 'aria-label' });

  protected readonly state = computed(() =>
    this.indeterminate() ? 'indeterminate' : this.checked() ? 'checked' : 'unchecked',
  );
  protected readonly ariaChecked = computed(() =>
    this.indeterminate() ? 'mixed' : this.checked() ? 'true' : 'false',
  );

  protected toggle(): void {
    if (this.disabled()) {
      return;
    }
    if (this.indeterminate()) {
      this.indeterminate.set(false);
      this.checked.set(true);
      return;
    }
    this.checked.update((value) => !value);
  }
}
Source · React
export * from './tcn-checkbox';
import { forwardRef, useState } from 'react';
import type { ComponentPropsWithoutRef } from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cn } from '@touchcn/core';

export interface TcnCheckboxProps
  extends Omit<ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>, 'asChild' | 'onCheckedChange'> {
  /** Fires with the next boolean state on toggle (never `indeterminate`). */
  onCheckedChange?: (checked: boolean) => void;
}

/**
 * Checkbox. Radix supplies the accessible `button[role="checkbox"]` with
 * `aria-checked` / `data-state` (including `indeterminate`) and keyboard
 * handling; the label is passed as children so it renders inside the button —
 * one tap and focus target, the text serving as the accessible name. The box +
 * icon replicate the platform fork by cascade (MD3 rounded square, iOS circle),
 * with the active state mirrored locally so the icon can be rendered
 * conditionally (same markup as the Angular component) while supporting both
 * controlled and uncontrolled usage.
 */
export const TcnCheckbox = forwardRef<
  React.ElementRef<typeof CheckboxPrimitive.Root>,
  TcnCheckboxProps
>(function TcnCheckbox({ checked, defaultChecked, onCheckedChange, className, children, ...props }, ref) {
  const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false);
  const isControlled = checked !== undefined;
  const currentChecked = isControlled ? checked : internalChecked;
  const state =
    currentChecked === 'indeterminate' ? 'indeterminate' : currentChecked ? 'checked' : 'unchecked';

  const handleChange = (next: boolean) => {
    if (!isControlled) {
      setInternalChecked(next);
    }
    onCheckedChange?.(next);
  };

  return (
    <CheckboxPrimitive.Root
      ref={ref}
      className={cn('tcn-checkbox', className)}
      checked={currentChecked}
      onCheckedChange={handleChange}
      {...props}
    >
      <span className="tcn-checkbox-box">
        <span className="tcn-checkbox-state" aria-hidden="true" />
        {state === 'checked' && (
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path
              d="M5 12l4 4L19 7"
              stroke="currentColor"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        )}
        {state === 'indeterminate' && (
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M6 12h12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
          </svg>
        )}
      </span>
      {children}
    </CheckboxPrimitive.Root>
  );
});
Source · Vue
<script setup lang="ts">
import { CheckboxRoot } from 'reka-ui';

defineProps<{ disabled?: boolean }>();

/**
 * Checkbox. reka-ui `CheckboxRoot` supplies the accessible
 * `button[role="checkbox"]` with `aria-checked` / `data-state` (including
 * `indeterminate`) and keyboard handling; the label is passed as the default
 * slot so it renders inside the button — one tap and focus target, the text
 * serving as the accessible name. The box + icon replicate the platform fork by
 * cascade (MD3 rounded square, iOS circle); the icon is chosen from the reka
 * `state` slot prop (same markup as the React / Angular component) while
 * supporting both controlled (`v-model`) and uncontrolled usage.
 */
const model = defineModel<boolean | 'indeterminate'>({ default: false });
</script>

<template>
  <CheckboxRoot v-model="model" :disabled="disabled" class="tcn-checkbox">
    <template #default="{ state }">
      <span class="tcn-checkbox-box">
        <span class="tcn-checkbox-state" aria-hidden="true" />
        <svg v-if="state === true" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path
            d="M5 12l4 4L19 7"
            stroke="currentColor"
            stroke-width="2.5"
            stroke-linecap="round"
            stroke-linejoin="round"
          />
        </svg>
        <svg
          v-else-if="state === 'indeterminate'"
          width="14"
          height="14"
          viewBox="0 0 24 24"
          fill="none"
          aria-hidden="true"
        >
          <path d="M6 12h12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
        </svg>
      </span>
      <slot />
    </template>
  </CheckboxRoot>
</template>
export { default as TcnCheckbox } from './TcnCheckbox.vue';

Last updated on July 24, 2026

Was this page helpful?