a yes or no ticked in a box, a dash for a group partly ticked
npx shadcn add @ag/checkboxAlready installed? Preview an update
npx shadcn add @ag/checkbox --dry-runYour 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.
the demo above, verbatim
"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
"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>
)
}