Checkbox Tree
Grouped checkboxes with a parent that goes indeterminate, a tick that draws itself and collapsible groups.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/checkbox-tree.json'use client'
import { useEffect, useRef, useState } from 'react'
import './checkbox-tree.css'
export interface CheckboxGroup { id: string; label: string; items: { id: string; label: string }[] }
export interface CheckboxTreeProps {
groups: CheckboxGroup[]
value?: string[]
defaultValue?: string[]
onValueChange?: (ids: string[]) => void
defaultOpen?: boolean
fill?: 'dark' | 'light'
className?: string
}
function Box({ checked, mixed, onChange, label }: { checked: boolean; mixed?: boolean; onChange: () => void; label: string }) {
const r = useRef<HTMLInputElement>(null)
useEffect(() => { if (r.current) r.current.indeterminate = Boolean(mixed) }, [mixed])
return (
<span className="sd-cbt__box">
<input ref={r} type="checkbox" checked={checked} aria-checked={mixed ? 'mixed' : checked} aria-label={label} onChange={onChange} onClick={(e) => e.stopPropagation()} />
<span className="sd-cbt__vis" aria-hidden>{mixed ? <svg key="m" viewBox="0 0 12 12"><path d="M3 6h6" /></svg> : checked ? <svg key="c" viewBox="0 0 12 12"><path d="m2.5 6.5 2.5 2.5 4.5-5.5" /></svg> : null}</span>
</span>
)
}
/** Grouped checkboxes with indeterminate parents. */
export function CheckboxTree({ groups, value, defaultValue = [], onValueChange, defaultOpen = true, fill = 'dark', className }: CheckboxTreeProps) {
const [inner, setInner] = useState(defaultValue)
const v = value ?? inner
const [closed, setClosed] = useState<string[]>(defaultOpen ? [] : groups.map((g) => g.id))
const set = (n: string[]) => { setInner(n); onValueChange?.(n) }
const toggle = (id: string) => set(v.includes(id) ? v.filter((x) => x !== id) : [...v, id])
return (
<div className={['sd-cbt', className].filter(Boolean).join(' ')} data-fill={fill}>
{groups.map((g) => {
const ids = g.items.map((i) => i.id)
const on = ids.filter((i) => v.includes(i)).length
const all = on === ids.length
const open = !closed.includes(g.id)
return (
<div key={g.id} className="sd-cbt__g">
<div className="sd-cbt__row sd-cbt__row--p" onClick={() => set(all ? v.filter((x) => !ids.includes(x)) : [...new Set([...v, ...ids])])}>
<Box label={`${g.label}, all`} checked={all} mixed={on > 0 && !all} onChange={() => set(all ? v.filter((x) => !ids.includes(x)) : [...new Set([...v, ...ids])])} />
<span className="sd-cbt__lab">{g.label} <span className="sd-cbt__n">{on}/{ids.length}</span></span>
<button type="button" className="sd-cbt__chev" aria-expanded={open} aria-label={`${open ? 'Collapse' : 'Expand'} ${g.label}`} onClick={(e) => { e.stopPropagation(); setClosed((c) => (open ? [...c, g.id] : c.filter((x) => x !== g.id))) }}>
<svg viewBox="0 0 12 12" aria-hidden><path d="m3 4.5 3 3 3-3" /></svg>
</button>
</div>
<div className="sd-cbt__kids" data-closed={!open || undefined}>
<div>
{g.items.map((it) => (
<label key={it.id} className="sd-cbt__row sd-cbt__row--c" style={{ visibility: open ? undefined : 'hidden' }}>
<Box label={it.label} checked={v.includes(it.id)} onChange={() => toggle(it.id)} />
<span className="sd-cbt__lab">{it.label}</span>
</label>
))}
</div>
</div>
</div>
)
})}
</div>
)
}.sd-cbt { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-cbt[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-cbt button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-cbt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-cbt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-cbt { width: min(100%, 320px); padding: 6px; border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-cbt__g + .sd-cbt__g { border-top: 1px solid var(--line); }
.sd-cbt__row { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border-radius: 10px; cursor: pointer; font-size: 14px; }
.sd-cbt__row:hover { background: var(--soft); }
.sd-cbt__row--p { font-weight: 600; }
.sd-cbt__row--c { padding-left: 38px; color: var(--fg); }
.sd-cbt__box { position: relative; flex: none; width: 18px; height: 18px; }
.sd-cbt__box input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.sd-cbt__vis { display: grid; place-items: center; width: 18px; height: 18px; border-radius: 5px; box-shadow: inset 0 0 0 1.5px var(--mute); transition: background-color 160ms, box-shadow 160ms; pointer-events: none; }
.sd-cbt__box input:checked + .sd-cbt__vis, .sd-cbt__box input:indeterminate + .sd-cbt__vis { background: var(--strong); box-shadow: none; color: var(--on); }
.sd-cbt__box input:focus-visible + .sd-cbt__vis { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-cbt__vis svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.sd-cbt__vis path { stroke-dasharray: 16; animation: sd-cbt-draw 260ms ease forwards; }
@keyframes sd-cbt-draw { from { stroke-dashoffset: 16; } to { stroke-dashoffset: 0; } }
.sd-cbt__lab { flex: 1; }
.sd-cbt__n { font-size: 12px; color: var(--mute); font-weight: 400; font-variant-numeric: tabular-nums; }
.sd-cbt__chev { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 6px; color: var(--mute); }
.sd-cbt__chev:hover { color: var(--fg); background: var(--soft) !important; }
.sd-cbt__chev svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: transform 240ms; }
.sd-cbt__chev[aria-expanded='false'] svg { transform: rotate(-90deg); }
.sd-cbt__kids { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 260ms ease; }
.sd-cbt__kids[data-closed] { grid-template-rows: 0fr; }
.sd-cbt__kids > div { overflow: hidden; }
@media (prefers-reduced-motion: reduce) { .sd-cbt *, .sd-cbt *::before, .sd-cbt *::after { animation: none !important; transition: none !important; } }Overview
Each group has a parent checkbox that reflects its children: empty, a dash when some are on, a tick when all are. Clicking the parent selects or clears the whole group.
Groups collapse with a chevron, and the tick strokes draw in rather than appear.
When to use
- Permissions, notification types, export field pickers, filter panels.
When not to use
- Deep trees: use a tree view with lazy loading.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/checkbox-tree.json$
npx shadcn@latest add https://sundaeui.dev/r/checkbox-tree.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/checkbox-tree.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/checkbox-tree.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy checkbox-tree.tsx and checkbox-tree.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { CheckboxTree } from '@/components/sundae/checkbox-tree'
<CheckboxTree />Props
| Prop | Type | Default |
|---|---|---|
groups*Groups. | { id: string; label: string; items: { id: string; label: string }[] }[] | — |
value / defaultValueChecked item ids. | string[] | — |
onValueChangeCalled on change. | (ids: string[]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Native checkboxes; parents use the indeterminate property and aria-checked="mixed".
- Group toggles are buttons with aria-expanded.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-cbt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Checkbox Tree free?
- Yes. Checkbox Tree is free for personal and commercial projects, with full source on this page.
- How do I install Checkbox Tree?
- Run npx shadcn@latest add https://sundaeui.dev/r/checkbox-tree.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Checkbox Tree support keyboard and screen readers?
- Native checkboxes; parents use the indeterminate property and aria-checked="mixed". Group toggles are buttons with aria-expanded.
- Does Checkbox Tree respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Transfer List
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined Password Strength
Forms · Refined