Skip to content

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.

Checkbox.vue
Select one item, every item, or use the mixed parent checkbox to change the complete set.

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.

Terminal
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

NotificationsField.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

NotificationsField.jsx
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

NotificationsField.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:

vue
<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:

vue
<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 name and value; an unchecked checkbox submits nothing.
  • required participates in native constraint validation.
  • disabled prevents interaction and submission.
  • Form reset restores the initial checked state.
  • readonly does not apply to checkboxes; use disabled when the value cannot change.

Klean does not replace any of these with key handlers or ARIA state machines.

API

PurposeVueReactSvelte
Current valuev-modelchecked, onChangebind:checked
Initial valueinitial modeldefaultCheckedinitial state
Partial stateindeterminateindeterminateindeterminate
Formnative input attributesnative input propsnative attributes
StylingclassclassNameclass

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:

vue
<!-- 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.

  • 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

Checkbox.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

Checkbox.jsx
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

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)}
/>

All open source projects are released under the MIT License.