Checkbox
Checkbox represents one independent yes/no value or membership in a set. It is a real form control, so the browser keeps Space activation, clickable labels, required validation, disabled behavior, submitted values, and reset semantics.
The common path is one Checkbox inside one visible label. Related choices use a native fieldset. Partial list selection uses the same component with indeterminate; there is no group, label, or indicator-component ceremony.
Installation
One command detects Vue, React, or Svelte and installs one framework-native source file:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add checkbox- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
The installation creates no initializer, provider, klean-ui.json, alias questionnaire, generated class helper, or Klean runtime dependency.
Usage
Vue
<script setup>
import { ref } from 'vue'
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
const notifications = ref(false)
</script>
<template>
<label class="flex cursor-pointer items-start gap-3">
<Checkbox v-model="notifications" name="notifications" />
<span>
<span class="block font-medium">Deployment notifications</span>
<span class="text-sm text-gray-500">
Tell me when a deploy finishes.
</span>
</span>
</label>
</template>
React
import { useState } from 'react'
import Checkbox from '@/components/ui/checkbox/Checkbox.jsx'
export default function NotificationsField() {
const [notifications, setNotifications] = useState(false)
return (
<label className="flex cursor-pointer items-start gap-3">
<Checkbox
checked={notifications}
onChange={(event) => setNotifications(event.target.checked)}
name="notifications"
/>
<span>
<span className="block font-medium">Deployment notifications</span>
<span className="text-sm text-gray-500">
Tell me when a deploy finishes.
</span>
</span>
</label>
)
}
Svelte
<script>
import Checkbox from '@/components/ui/checkbox/Checkbox.svelte'
let notifications = $state(false)
</script>
<label class="flex cursor-pointer items-start gap-3">
<Checkbox bind:checked={notifications} name="notifications" />
<span>
<span class="block font-medium">Deployment notifications</span>
<span class="text-sm text-gray-500"> Tell me when a deploy finishes. </span>
</span>
</label>
The binding syntax changes, but every version produces the same native checkbox and keeps the visible label in application markup.
Which control should I use?
Use Checkbox when each value can stand independently: remember me, include retained data, subscribe to an event, or select a row. A set of checkboxes can have zero, one, or many selected values.
Use a Switch for an immediate on/off setting whose new state takes effect as soon as it changes. Use Radio when exactly one of a small visible set may be chosen. Use Select when one choice comes from a longer fixed list. Use Button for an action rather than persistent form state.
Checkbox is not a Switch or Select variant. Those controls communicate different state and keyboard expectations.
Labels, descriptions, and errors
A real <label> may wrap Checkbox or target its id. Wrapping is the tersest API and makes the entire visible row clickable:
<label class="flex cursor-pointer items-start gap-3">
<Checkbox
v-model="form.confirmed"
name="confirmed"
required
:aria-invalid="Boolean(form.errors.confirmed)"
aria-describedby="confirmed-help confirmed-error"
class="mt-0.5"
/>
<span>
<span class="block font-medium">I have reviewed this transfer</span>
<span id="confirmed-help" class="text-sm text-gray-500">
Check the recipient and amount before continuing.
</span>
<span id="confirmed-error" class="empty:hidden text-sm text-red-700">
{{ form.errors.confirmed }}
</span>
</span>
</label>The application owns the text, deterministic IDs, validation timing, and business consequence. Checkbox forwards the relationships without introducing a Field configuration language.
Groups and collection values
When several checkboxes answer one visible question, use fieldset and legend. Each choice still receives its own label and submitted value.
Vue keeps its native checkbox collection behavior: v-model can contain an array or Set, and true-value and false-value remain available for a single non-boolean value. React uses ordinary checked, defaultChecked, and onChange. Svelte uses bind:checked and native event attributes. Collection stores and product selection rules stay with the application that owns them.
Indeterminate selection
indeterminate presents a parent checkbox as partially selected when some, but not all, children are checked:
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected"
aria-controls="row-one row-two row-three"
@change="selectAll($event.target.checked)"
/>The browser exposes that native control as mixed to assistive technology. Indeterminate is presentation, not a third submitted value. Activating it produces an ordinary checked or unchecked state, and the application updates the child collection.
Native form behavior
- Space toggles the focused checkbox.
- Activating an associated label toggles it.
- A checked checkbox submits its
nameandvalue; an unchecked checkbox submits nothing. requiredparticipates in native constraint validation.disabledprevents interaction and submission.- Form reset restores the initial checked state.
readonlydoes not apply to checkboxes; usedisabledwhen the value cannot change.
Klean does not replace any of these with key handlers or ARIA state machines.
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Current value | v-model | checked, onChange | bind:checked |
| Initial value | initial model | defaultChecked | initial state |
| Partial state | indeterminate | indeterminate | indeterminate |
| Form | native input attributes | native input props | native attributes |
| Styling | class | className | class |
The component exposes its native element for explicit focus recovery and stable data-slot="checkbox", data-state, data-disabled, and data-invalid styling or test hooks. It has no variant, tone, size, group, label, indicator, or part-class props.
Styling
The default is a neutral native checkbox whose accent follows its current text color. Caller Tailwind merges last:
<!-- Compact operational control -->
<Checkbox class="size-3.5 text-white focus-visible:outline-white" />
<!-- Destructive confirmation -->
<Checkbox class="mt-0.5 text-red-600 focus-visible:outline-red-600" />
<!-- High-contrast sign-in form -->
<Checkbox class="text-black focus-visible:outline-black" />For a selectable card or chip, visually hide Checkbox with sr-only and style its wrapping label with has-[:checked] or peer-* utilities. That complete product treatment belongs in the recipe, not in a visual variant API.
Durable state
Checkbox preserves native reset and form behavior but does not persist every boolean automatically. The owning form, server record, URL, or storage policy decides whether a checked value should survive navigation or reload. Indeterminate state is normally derived from the durable child selection rather than stored separately.
Related components
- Radio — one mutually exclusive choice from a short visible list.
- Input — arbitrary free-form text.
- Select — one persistent value from a fixed list.
- Button — an action rather than checked form state.
- Menu — actions and navigation in a temporary popup.
- Dialog — the owning confirmation task; Checkbox may confirm one fact inside it.
Complete framework source
Vue
<script setup>
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
onUpdated,
ref,
useAttrs
} from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const props = defineProps({
indeterminate: { type: Boolean, default: false }
})
const model = defineModel({ default: false })
const attrs = useAttrs()
const element = ref()
const checked = ref(false)
let form
const forwardedAttrs = computed(() => {
const {
class: _class,
type: _type,
'data-slot': _dataSlot,
'data-state': _dataState,
'data-disabled': _dataDisabled,
'data-invalid': _dataInvalid,
...rest
} = attrs
return rest
})
const disabled = computed(
() => attrs.disabled !== undefined && attrs.disabled !== false
)
const invalid = computed(
() => attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true'
)
const state = computed(() =>
props.indeterminate
? 'indeterminate'
: checked.value
? 'checked'
: 'unchecked'
)
function applyElementState() {
if (!element.value) return
element.value.indeterminate = props.indeterminate
checked.value = element.value.checked
}
function valuesMatch(left, right) {
return Object.is(left, right)
}
function resetModelFromElement() {
if (!element.value) return
const nextChecked = element.value.checked
const current = model.value
const inputValue = attrs.value ?? 'on'
if (Array.isArray(current)) {
const index = current.findIndex((item) => valuesMatch(item, inputValue))
if (nextChecked && index === -1) model.value = [...current, inputValue]
if (!nextChecked && index !== -1) {
model.value = current.filter((_, itemIndex) => itemIndex !== index)
}
} else if (current instanceof Set) {
const next = new Set(current)
if (nextChecked) next.add(inputValue)
else next.delete(inputValue)
model.value = next
} else {
model.value = nextChecked
? (attrs['true-value'] ?? true)
: (attrs['false-value'] ?? false)
}
checked.value = nextChecked
}
function handleReset() {
queueMicrotask(resetModelFromElement)
}
function handleChange() {
nextTick(applyElementState)
}
onMounted(() => {
applyElementState()
element.value.defaultChecked = element.value.checked
form = element.value.form
form?.addEventListener('reset', handleReset)
})
onUpdated(applyElementState)
onBeforeUnmount(() => {
form?.removeEventListener('reset', handleReset)
})
defineExpose({
element,
focus: (options) => element.value?.focus(options)
})
</script>
<template>
<input
ref="element"
v-model="model"
v-bind="forwardedAttrs"
type="checkbox"
data-slot="checkbox"
:data-state="state"
:data-disabled="disabled ? '' : undefined"
:data-invalid="invalid ? '' : undefined"
:class="
twMerge(
[
'size-4 shrink-0 cursor-pointer appearance-auto accent-current text-gray-950 outline-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950',
'disabled:cursor-not-allowed disabled:opacity-50',
'aria-invalid:focus-visible:outline-red-600',
'dark:text-white dark:focus-visible:outline-white dark:aria-invalid:focus-visible:outline-red-500'
],
attrs.class
)
"
@change="handleChange"
/>
</template>
React
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'
const BASE_CLASSES = [
'size-4 shrink-0 cursor-pointer appearance-auto accent-current text-gray-950 outline-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950',
'disabled:cursor-not-allowed disabled:opacity-50',
'aria-invalid:focus-visible:outline-red-600',
'dark:text-white dark:focus-visible:outline-white dark:aria-invalid:focus-visible:outline-red-500'
]
function assignRef(ref, value) {
if (typeof ref === 'function') ref(value)
else if (ref) ref.current = value
}
const Checkbox = forwardRef(function Checkbox(
{
checked,
defaultChecked = false,
indeterminate = false,
disabled = false,
'aria-invalid': ariaInvalid,
className,
onChange,
'data-slot': _dataSlot,
'data-state': _dataState,
'data-disabled': _dataDisabled,
'data-invalid': _dataInvalid,
...props
},
forwardedRef
) {
const elementRef = useRef(null)
const controlled = checked !== undefined
const [localChecked, setLocalChecked] = useState(Boolean(defaultChecked))
const resolvedChecked = controlled ? Boolean(checked) : localChecked
const invalid = ariaInvalid === true || ariaInvalid === 'true'
const setElement = useCallback(
(node) => {
elementRef.current = node
if (node) node.indeterminate = Boolean(indeterminate)
assignRef(forwardedRef, node)
},
[forwardedRef, indeterminate]
)
useEffect(() => {
const node = elementRef.current
if (!node) return
node.indeterminate = Boolean(indeterminate)
}, [indeterminate])
useEffect(() => {
const node = elementRef.current
const form = node?.form
if (!form || controlled) return
function handleReset() {
queueMicrotask(() => setLocalChecked(node.defaultChecked))
}
form.addEventListener('reset', handleReset)
return () => form.removeEventListener('reset', handleReset)
}, [controlled])
function handleChange(event) {
if (!controlled) setLocalChecked(event.target.checked)
onChange?.(event)
}
return (
<input
{...props}
ref={setElement}
type="checkbox"
checked={controlled ? checked : undefined}
defaultChecked={controlled ? undefined : defaultChecked}
disabled={disabled}
aria-invalid={ariaInvalid}
data-slot="checkbox"
data-state={
indeterminate
? 'indeterminate'
: resolvedChecked
? 'checked'
: 'unchecked'
}
data-disabled={disabled ? '' : undefined}
data-invalid={invalid ? '' : undefined}
className={twMerge(BASE_CLASSES, className)}
onChange={handleChange}
/>
)
})
export default Checkbox
Svelte
<script>
import { twMerge } from "tailwind-merge";
const BASE_CLASSES = [
"size-4 shrink-0 cursor-pointer appearance-auto accent-current text-gray-950 outline-none",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:focus-visible:outline-red-600",
"dark:text-white dark:focus-visible:outline-white dark:aria-invalid:focus-visible:outline-red-500",
];
let {
checked = $bindable(false),
indeterminate = false,
disabled = false,
"aria-invalid": ariaInvalid,
class: className,
"data-slot": _dataSlot,
"data-state": _dataState,
"data-disabled": _dataDisabled,
"data-invalid": _dataInvalid,
...props
} = $props();
let element = $state();
let state = $derived(
indeterminate ? "indeterminate" : checked ? "checked" : "unchecked",
);
let invalid = $derived(ariaInvalid === true || ariaInvalid === "true");
$effect(() => {
if (element) element.indeterminate = Boolean(indeterminate);
});
$effect(() => {
const node = element;
if (!node) return;
node.defaultChecked = node.checked;
const form = node.form;
if (!form) return;
function handleReset() {
queueMicrotask(() => {
checked = node.checked;
});
}
form.addEventListener("reset", handleReset);
return () => form.removeEventListener("reset", handleReset);
});
export function getElement() {
return element;
}
export function focus(options) {
element?.focus(options);
}
</script>
<input
{...props}
bind:this={element}
bind:checked
type="checkbox"
{disabled}
aria-invalid={ariaInvalid}
data-slot="checkbox"
data-state={state}
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
class={twMerge(BASE_CLASSES, className)}
/>