An ink-filled checkbox with mixed state support and a visible label.
pnpm dlx shadcn@latest add @latheui/checkboxFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add radix-uiCopy the source into your project.
"use client"
import * as React from "react"
import { Checkbox as Primitive } from "radix-ui"
import { cn } from "@/lib/utils"
export type CheckboxProps = React.ComponentProps<typeof Primitive.Root> & {
label: React.ReactNode
}
export function Checkbox({
label,
className,
id: idProp,
...props
}: CheckboxProps) {
const generated = React.useId()
const id = idProp ?? generated
return (
<div className={cn("flex items-center gap-2 text-sm", className)}>
<Primitive.Root
id={id}
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm border border-input bg-background outline-none transition-colors duration-150 motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50"
{...props}
>
{/* Kept mounted, so the marks can draw themselves out as well as in. */}
<Primitive.Indicator forceMount className="group">
<svg
aria-hidden="true"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
// A hidden mark also goes transparent once it has drawn out: Safari otherwise paints its
// round line cap as a dot in the empty box.
className="size-3 [&_path]:opacity-0 [&_path]:[transition:stroke-dashoffset_200ms_ease-out,opacity_0s_linear_200ms] [&_path]:[stroke-dasharray:1] [&_path]:[stroke-dashoffset:1] motion-reduce:[&_path]:transition-none"
>
<path
d="M2.5 6.5 5 9l4.5-6"
pathLength={1}
className="group-data-[state=checked]:opacity-100 group-data-[state=checked]:[stroke-dashoffset:0] group-data-[state=checked]:[transition:stroke-dashoffset_200ms_ease-out,opacity_0s]"
/>
<path
d="M2.75 6h6.5"
pathLength={1}
className="group-data-[state=indeterminate]:opacity-100 group-data-[state=indeterminate]:[stroke-dashoffset:0] group-data-[state=indeterminate]:[transition:stroke-dashoffset_200ms_ease-out,opacity_0s]"
/>
</svg>
</Primitive.Indicator>
</Primitive.Root>
<label htmlFor={id} className="cursor-pointer">
{label}
</label>
</div>
)
}Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| label* (required) | ReactNode | — | Visible label linked to the control. |
| checked / defaultChecked | boolean | "indeterminate" | — | Current or initial state. Clicks toggle between true and false; "indeterminate" is only ever set by your code. |
| onCheckedChange | (checked: boolean | "indeterminate") => void | — | Selection callback. |
| name / required / disabled | native form props | — | Radix form integration. |
Clicks only toggle between checked and unchecked, so the mixed state comes from your code. Derive it from child checkboxes, as in this select-all group.
"use client"
import * as React from "react"
import { Checkbox } from "@/components/ui/checkbox"
const projects = ["Website", "Mobile app"]
export default function Demo() {
const [selected, setSelected] = React.useState<string[]>(["Website"])
const all = selected.length === projects.length ? true : selected.length > 0 ? "indeterminate" : false
return (
<div className="space-y-3">
<Checkbox label="All projects" checked={all} onCheckedChange={(c) => setSelected(c === true ? projects : [])} />
<div className="space-y-3 ps-6">
{projects.map((p) => (
<Checkbox
key={p}
label={p}
checked={selected.includes(p)}
onCheckedChange={(c) => setSelected((s) => (c === true ? [...s, p] : s.filter((x) => x !== p)))}
/>
))}
</div>
</div>
)
}