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';