Skip to content
txbrendan/ui

Checkbox

beta

A box people tick to pick an option, with a built-in label, tones, sizes, and a mixed state.

Preview

tone

size

states

wrap

group

Files to share

field

no text

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/checkbox

Use it for

  • Picking any number of options from a list, like topics to follow.
  • Agreeing to something before a form is sent, like the terms of service.
  • Pass the label text as children. The box and the text share one label, so clicking either one toggles it.
  • When the text has to sit apart from the box, give the box an id and point a label's htmlFor at it.
  • A 'select all' box above a list. Set indeterminate when only some items are picked, and it shows the mixed state.

Avoid it for

  • A setting that takes effect right away. Use a switch.
  • Picking exactly one option from a list. Use a radio group.
  • Options with a hint, a price, or a consequence. Use SelectionGroup multiple with a SelectionCard for each.
  • An id with no label pointing at it. TypeScript accepts the id, but the box then has no name.

Props

  • children

    Visible label text. Without it, TypeScript requires aria-label, aria-labelledby, or an id.

    ReactNode

  • id

    Links a separate label. A label whose htmlFor matches names the box, and clicking it toggles the box.

    string

  • tone

    Color of the checked box.

    "neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"

  • size

    Box size and label text size. Density scales them.

    "sm" | "md" | "lg" · default "md"

  • checked

    Controlled state. Use it with onCheckedChange.

    boolean

  • defaultChecked

    Starting state when you do not control it.

    boolean · default false

  • onCheckedChange

    Called with the new state when someone toggles the box.

    (checked: boolean, details) => void

  • indeterminate

    Shows a dash and sets aria-checked to mixed.

    boolean · default false

  • disabled

    Blocks toggling and removes the box from the tab order. The label text dims too.

    boolean · default false

  • name

    Form field name. The box sends its value with the form when checked.

    string

  • aria-invalid

    Shows the danger border. Set it when validation fails, and link the message with aria-describedby.

    boolean

  • className

    Classes for the box. Later classes win, so className='[--checkbox-size:1.5rem]' makes it bigger.

    string

Accessibility

  • Renders a span with role="checkbox" plus a hidden native checkbox, so the value is sent with the form.
  • Space toggles it. Enter does not toggle it. Enter submits the form, as it does on a native checkbox.
  • TypeScript requires visible children, aria-label, aria-labelledby, or an id. It cannot check that a label points at the id.
  • In server HTML, only aria-label and aria-labelledby name the box. A label from children or an id is linked once JavaScript runs.
  • Clicking the label text also toggles the box, so the target is bigger than the box.
  • The unchecked border uses --input and the focus ring uses --ring. The theme tests hold both to 3:1 against the page and cards.
  • The check mark uses the tone's foreground on its fill, which the theme tests hold to 4.5:1.
  • Press scale and the check mark's pop-in follow --motion-* tokens. Reduced motion turns both off and keeps the fade.

Examples

With a label

<Checkbox name="terms" tone="brand">
  I accept the{" "}
  <a href="/terms" className="underline">
    terms of service
  </a>
</Checkbox>

A list of options

<fieldset className="grid gap-3">
  <legend className="mb-1 text-sm font-medium">Email me about</legend>
  <Checkbox name="topics" value="releases" defaultChecked>
    New releases
  </Checkbox>
  <Checkbox name="topics" value="tips">
    Tips and guides
  </Checkbox>
  <Checkbox name="topics" value="events">
    Events near me
  </Checkbox>
</fieldset>

Select all, with the mixed state

"use client";
import { useState } from "react";

const files = ["report.pdf", "notes.txt", "photo.png"];

function FilePicker() {
  const [picked, setPicked] = useState(["report.pdf"]);
  const all = picked.length === files.length;
  return (
    <div role="group" aria-labelledby="files-label" className="grid gap-3">
      <p id="files-label" className="text-sm font-medium">Files</p>
      <Checkbox
        checked={all}
        indeterminate={picked.length > 0 && !all}
        onCheckedChange={(checked) => setPicked(checked ? files : [])}
      >
        Select all
      </Checkbox>
      <div className="grid gap-3 ps-6">
        {files.map((file) => (
          <Checkbox
            key={file}
            checked={picked.includes(file)}
            onCheckedChange={(checked) => setPicked((now) => (checked ? [...now, file] : now.filter((f) => f !== file)))}
          >
            {file}
          </Checkbox>
        ))}
      </div>
    </div>
  );
}

Controlled

"use client";
import { useState } from "react";

function MarketingConsent() {
  const [checked, setChecked] = useState(false);
  return (
    <Checkbox checked={checked} onCheckedChange={setChecked}>
      Send me product news
    </Checkbox>
  );
}

No visible text, in a table row

<Checkbox aria-label="Select row: Invoice 1042" size="sm" />

Named by a separate label

<div className="flex items-center gap-2">
  <Checkbox id="beta" />
  <label htmlFor="beta" className="text-sm">
    Join the beta
  </label>
</div>