← ui

checkbox

ui

a yes or no ticked in a box, a dash for a group partly ticked

npx shadcn add @ag/checkbox
Already installed? Preview an update
npx shadcn add @ag/checkbox --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-11
  • New: inside an inherited Setting, a ghost: the box dashed and unfilled, the tick in the primary over a fifth of it (glass: the material's ghost). Described by the setting's words, read-only while it is locked.
Updated 2026-10-11 · release notes

shelf controls

uses field-context, material, setting-context

used by components

the demo above, verbatim

checkbox.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { Checkbox } from "@/registry/base-nova/ui/checkbox"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Setting } from "@/registry/base-nova/ui/setting"

// #region inherited
/** Settings that fall back to the workspace's: a ghost of the value that applies
 *  until one is set here; × (or ⌥-click, or Delete) clears it back. */
function Inherited() {
  const [digest, setDigest] = React.useState<boolean | undefined>(undefined)
  const [mentions, setMentions] = React.useState<boolean | undefined>(false)
  return (
    <div className="flex flex-col gap-2">
      <Setting
        label="weekly digest"
        value={digest}
        fallback={{ value: true, from: "the workspace" }}
        onValueChange={setDigest}
      >
        {(s) => (
          <Checkbox checked={s.value} onCheckedChange={s.pin}>
            weekly digest
          </Checkbox>
        )}
      </Setting>
      <Setting
        label="mentions"
        value={mentions}
        fallback={{ value: true, from: "the workspace" }}
        onValueChange={setMentions}
      >
        {(s) => (
          <Checkbox checked={s.value} onCheckedChange={s.pin}>
            mentions
          </Checkbox>
        )}
      </Setting>
    </div>
  )
}
// #endregion

// #region boxes
const TOPPINGS = ["basil", "olives", "anchovies"]

function Boxes() {
  const [chosen, setChosen] = React.useState<string[]>(["basil"])
  const all = chosen.length === TOPPINGS.length
  const some = chosen.length > 0 && !all
  return (
    <div className="flex flex-col gap-2.5">
      <Checkbox
        checked={all}
        indeterminate={some}
        onCheckedChange={(on) => setChosen(on ? TOPPINGS : [])}
      >
        every topping
      </Checkbox>
      <div className="flex flex-col gap-2.5 pl-6">
        {TOPPINGS.map((t) => (
          <Checkbox
            key={t}
            checked={chosen.includes(t)}
            onCheckedChange={(on) =>
              setChosen((was) =>
                on ? [...was, t] : was.filter((x) => x !== t),
              )
            }
          >
            {t}
          </Checkbox>
        ))}
      </div>
      <Checkbox disabled>gluten free (sold out)</Checkbox>
      <Checkbox aria-invalid required>
        I accept the terms
      </Checkbox>
    </div>
  )
}
// #endregion

export default function CheckboxDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="materials"
        title="plain and glass"
        description="a box that stands for its group shows a dash while only some of it is ticked; its words press it too."
        with="boxes"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Boxes />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6 text-white">
              <Boxes />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample name="sizes" glance title="three sizes">
        <div className="flex flex-wrap items-center gap-6">
          <Checkbox size="sm" defaultChecked>
            small
          </Checkbox>
          <Checkbox defaultChecked>medium</Checkbox>
          <Checkbox size="lg" defaultChecked>
            large
          </Checkbox>
        </div>
      </Sample>
      <Sample
        name="inherited"
        title="following a default"
        description="the digest follows the workspace (a dashed box); mentions is set here, off. hover either to read where its value comes from."
        with="inherited"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Inherited />
          </div>
          <MaterialProvider material="glass" scheme="dark">
            <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
              <Inherited />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

checkbox.tsx
"use client"

import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"
import { Check, Minus } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
import { fieldProps, useField } from "@/registry/base-nova/ui/field-context"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
  GHOST_EDGE,
  GLASS_GHOST,
  settingProps,
  useSetting,
} from "@/registry/base-nova/ui/setting-context"

// A yes or no, ticked in a box (Base UI's checkbox: a real hidden input, so a form
// submits it, Space ticks it, a label presses it). `indeterminate` is the third state
// a box that stands for a group shows when some of the group is ticked, drawn as a
// dash. Given `children`, the box and its words are one <label>, the words pressing it
// too; without, label it with `aria-label` or a Label's `htmlFor`.
//
// Plain: a sunken box in the field's well tones, filled with the page's primary when
// ticked. `material="glass"`: a small pane of glass, its rim lighting and the text's
// colour coming into it when ticked, so ticked and not differ in light. Wrong
// (`aria-invalid`, or a Base UI Field that judged it), the edge is red in either.
// Inside a `Setting` whose value is inherited, a ghost of the value that applies:
// the box dashed and unfilled, the tick still drawn (glass: the material's ghost).

export type CheckboxSize = "sm" | "md" | "lg"

/** The box, and its words beside it: at the input's type sizes, so a box sits in a
 *  form's rows as a field does. */
const BOX: Record<CheckboxSize, string> = {
  sm: "size-3.5 [&_svg]:size-2.5",
  md: "size-4 [&_svg]:size-3",
  lg: "size-5 [&_svg]:size-3.5",
}
const WORDS: Record<CheckboxSize, string> = {
  sm: "gap-1.5 text-[12px]",
  md: "gap-2 text-[13px]",
  lg: "gap-2.5 text-sm",
}

const BASE =
  "relative inline-grid shrink-0 cursor-pointer place-items-center data-disabled:cursor-not-allowed data-readonly:cursor-default [&_svg]:stroke-[3]"

const PLAIN =
  "rounded-[4px] bg-foreground/[0.04] ring-1 ring-foreground/25 ring-inset transition-[background-color,box-shadow] hover:ring-foreground/40 data-checked:bg-primary data-checked:text-primary-foreground data-checked:ring-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground data-indeterminate:ring-primary aria-invalid:ring-tone-bad/70 data-invalid:ring-tone-bad/70"

const GLASS =
  "[--glass-radius:5px] hover:[--glass-rim:1.4]! data-checked:[--glass-stain:currentColor]! data-checked:[--glass-stain-amount:22%]! data-checked:[--glass-rim:1.8]! data-indeterminate:[--glass-stain:currentColor]! data-indeterminate:[--glass-stain-amount:22%]! data-indeterminate:[--glass-rim:1.8]! aria-invalid:[--glass-stain:var(--tone-bad)]! aria-invalid:[--glass-rim-color:var(--tone-bad)]! aria-invalid:[--glass-rim:2.2]! data-invalid:[--glass-stain:var(--tone-bad)]! data-invalid:[--glass-rim-color:var(--tone-bad)]! data-invalid:[--glass-rim:2.2]!"

/** Inherited (inside a `Setting`, nothing set here): a ghost of the value that
 *  applies. Plain, the box dashed and unfilled, a tick in the primary on a fifth
 *  of it; glass, the material's ghost. */
const PLAIN_GHOST = cn(
  "bg-transparent ring-0 data-checked:bg-primary/20 data-checked:text-primary data-indeterminate:bg-primary/20 data-indeterminate:text-primary",
  GHOST_EDGE,
)

export type CheckboxProps = Omit<
  React.ComponentProps<typeof BaseCheckbox.Root>,
  "className" | "children"
> & {
  size?: CheckboxSize
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  /** Its words: the box and these are one label. */
  children?: React.ReactNode
  className?: string
}

export function Checkbox({
  size = "md",
  material,
  scheme,
  indeterminate,
  children,
  className,
  ref,
  ...props
}: CheckboxProps) {
  const m = useMaterial(material)
  // Over a picture it is read by the part of it under the box, the surface; its
  // words beside it wear the same.
  const [s, here] = useSurfaceScheme<HTMLElement>(scheme, ref)
  const field = useField()
  // Inside a setting: a ghost while inherited, described by where its value comes
  // from, read-only while locked there.
  const setting = useSetting()
  const ghost = setting?.inherited ?? false
  const wired = fieldProps(field, { named: !children })
  const box = (
    <BaseCheckbox.Root
      ref={here}
      data-slot="checkbox"
      data-size={size}
      data-material={m}
      data-inherited={ghost ? "" : undefined}
      indeterminate={indeterminate}
      className={cn(
        BASE,
        BOX[size],
        // With its words the label wears the scheme, the box inside it with them.
        !children && schemeOf(s),
        m === "glass"
          ? [glassVariants({ shape: "surface" }), GLASS, ghost && GLASS_GHOST]
          : [PLAIN, ghost && PLAIN_GHOST],
        // Disabled, it dims; with its words, the label dims them together.
        !children && ["data-disabled:opacity-50", className],
      )}
      // Inside a field: its wiring and read-only, named by the field's label unless
      // its own words name it; what is passed here wins.
      {...wired}
      {...settingProps(setting, wired["aria-describedby"])}
      {...props}
    >
      <BaseCheckbox.Indicator
        data-slot="checkbox-indicator"
        className="grid place-items-center transition-[opacity,scale] duration-150 ease-out data-starting-style:scale-50 data-starting-style:opacity-0 motion-reduce:transition-none"
      >
        {indeterminate ? <Minus aria-hidden /> : <Check aria-hidden />}
      </BaseCheckbox.Indicator>
    </BaseCheckbox.Root>
  )
  if (!children) return box
  return (
    // biome-ignore lint/a11y/noLabelWithoutControl: the checkbox inside is its control
    <label
      data-slot="checkbox-label"
      className={cn(
        "inline-flex cursor-pointer items-center select-none has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-50",
        schemeOf(s),
        WORDS[size],
        m === "glass" ? "text-current/90" : "text-foreground/85",
        className,
      )}
    >
      {box}
      {children}
    </label>
  )
}