{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "nested-checkbox",
  "title": "Nested Checkbox",
  "description": "The checkbox list with parents and children that agree with each other: tick every child and the parent fills in, tick the parent and the whole branch follows, tick some of them and the parent shows the third state — a dash, announced as \"partially checked\" rather than as \"not checked\". Reach for it wherever a form hands out a hierarchy rather than a flat list: permissions, roles, scopes and API-token access on a settings or admin screen, category and facet filters on a storefront or search page, which columns or fields a CSV or report export includes, notification settings crossed with channels (email, push, Slack), per-purpose consent on a privacy or cookie screen, selecting files and folders for an upload, a download, a backup or a bulk move, choosing which locations, teams, projects or environments a rule applies to, picking a taxonomy or tag subtree, and the \"select all\" on a nested table of rows. Common asks it answers: \"nested checkbox react\", \"parent child checkbox\", \"checkbox tree react\", \"tri-state checkbox\", \"three state checkbox\", \"indeterminate checkbox react\", \"half checked checkbox\", \"partially selected checkbox\", \"aria-checked mixed\", \"mixed state checkbox\", \"select all with children react\", \"check all children when parent is checked\", \"parent checkbox updates when children change\", \"permission tree component\", \"scopes checkbox tree\", \"category tree with checkboxes\", \"folder tree with checkboxes\", \"shadcn nested checkbox\", \"shadcn checkbox tree\", \"shadcn indeterminate checkbox\", \"radix checkbox indeterminate dash\", \"react-checkbox-tree alternative\", \"rc-tree checkable alternative\", \"MUI TreeView with checkboxes equivalent\", \"antd Tree checkable equivalent\", \"how to set indeterminate in react\", \"input indeterminate react no prop\", \"checkbox dash shows but screen reader says unchecked\", \"recursive checkbox state react\", \"nested checkbox three levels\", \"disabled child breaks select all\", \"親子チェックボックス react\", \"全選択 子要素 連動\", \"中間状態 チェックボックス\", \"indeterminate 実装\". Official shadcn/ui has nothing for this, and the gap is narrower and more surprising than usual. Measured today over all 63 catalogue entries (62 are fetchable — questionnaire is listed and 404s on both style tracks) and 223,287 bytes of their source: indeterminate, mixed, aria-checked, partially, subtree and descendant are every one of them a zero hit, while checkbox itself appears 13 times. So the checkbox is there and the third state is not. Reading its source: the indicator holds a CheckIcon and nothing else — no dash, and no branch on which state it is in — and every colour rule on the box is keyed to data-[state=checked], so the indeterminate state the Radix primitive underneath it can represent has no appearance of its own in the wrapper at all. Composing on that component and passing the third state through is the obvious move and it does not produce a third state, which is why this one is self-contained. Within this registry the neighbours are close enough to be worth naming. tree-view is the ARIA tree pattern — one tab stop for the whole tree, arrow keys, rows that open and close: that is hierarchy you navigate, this is hierarchy you select, and the two are routinely wanted on the same screen. multi-select and tag-input put chips inside a single field and choose from a flat list. bulk-action-bar is about the rows already selected rather than the act of selecting them, and filter-chips shows which conditions are in force. Nothing here held a checkbox before this. Six things separate it from the thirty-line version, and five of them are invisible until somebody is already relying on it. First, and the reason it is not an <input type=\"checkbox\">: the third state is a value, aria-checked=\"mixed\", not a dash drawn in CSS. indeterminate is a DOM property with no HTML attribute behind it, so React cannot express it in JSX — it has to be assigned to the node through a ref after mount, which means the server render, the first paint, a snapshot test and any crawler all see aria-checked=\"false\" on a row that is actually part-selected. That is the exact bug where the dash is on screen and a screen reader says \"not checked\", and it survives every visual review because the picture is right. Each row here is a role=\"checkbox\" button, so the state is an attribute and the markup is true at every moment. Second, the roll-up runs in both directions or it is visibly broken. A parent's state comes up from its children and is never stored — keep a parent's own boolean beside the children's and you have two sources of truth that disagree the moment either side is touched — while a press on a parent goes back down and writes every leaf beneath it. Implement only the upward half and pressing a parent does nothing; only the downward half and the parent goes stale as soon as a child is pressed on its own. Third, it recurses. A parent of parents of leaves rolls up through every level, which is the part that looks finished at two levels deep and breaks the day somebody nests a third — and the state and the write are each one post-order pass over the tree, not a walk per row. Fourth, disabled rows, which is where the careful implementations still go wrong, because the question has two different answers. What a parent displays counts its disabled children: a group claiming \"all of it\" while a visibly empty row sits underneath is lying about what will be submitted. What a press changes excludes them: a locked permission must not be granted by pressing its group. Decide the direction of the press from the displayed state and a parent held at mixed by one disabled-and-off row can never clear itself — it is not \"checked\", so every press reads as \"turn it all on\" and the thing can be pressed forever without changing. The direction here comes from the leaves it may touch, so the group clears on the first press. Disabling a parent disables its whole subtree, and a row with nothing togglable beneath it reports itself as such instead of looking live and doing nothing. Fifth, ids it does not recognise are carried through untouched rather than rebuilt from what is on screen, so a tree that is filtered, paginated or lazily loaded does not quietly discard the choices made on another page — and the emitted array keeps the order it arrived in, appending additions in tree order, so it is stable across presses instead of reshuffling on every change. Sixth, the locked rows are aria-disabled rather than carrying the disabled attribute, so they stay focusable and somebody tabbing through can still discover that a permission exists and is held shut; a node's description is wired up with aria-describedby from outside the control, so it is announced after the name instead of swallowed into it; and each subtree is a role=\"group\" named by the row above it, so entering it says which group these rows belong to. The value is the set of checked leaf ids — group ids never appear in it, because a derived state that is also saved is the drift described above — controlled with value plus onChange (handed the next ids and the node that was pressed) or uncontrolled with defaultValue. collectLeafIds, nodeCheckState and toggleCheckboxNode are exported as pure functions, so a check-all, an \"is this whole branch on\" test for a payload, and the roll-up itself are all usable headless. Each checkbox is its own tab stop the way a list of checkboxes in a form is, and Space toggles. Pass a name to submit the selection with a plain form POST as one hidden input per id. Styled entirely with shadcn tokens (primary, primary-foreground, input, background, ring, muted-foreground), so it follows light and dark mode, and lucide-react for the two icons is the only dependency — no Radix, no state library.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/nested-checkbox.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Minus } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface CheckboxNode {\n  /**\n   * Unique across the whole tree. Leaf ids are the component's value — they are what\n   * `onChange` hands back and what a `name` submits.\n   */\n  id: string\n  label: React.ReactNode\n  /**\n   * A node with at least one child is a group: its state is computed from the children\n   * and never stored. A node with no children — including `children: []` — is a leaf and\n   * is checkable in its own right, because a group holding nothing has no selection to\n   * roll up. (`tree-view` draws this line differently: there an empty `children` array is\n   * an empty folder, because it still has a disclosure arrow to open.)\n   */\n  children?: CheckboxNode[]\n  /** Disables this row and, if it is a group, everything beneath it. */\n  disabled?: boolean\n  /**\n   * Secondary line under the label — the sentence a permission or a notification setting\n   * needs. Rendered outside the checkbox and wired up with `aria-describedby`, so it is\n   * announced after the name instead of becoming part of it.\n   */\n  description?: React.ReactNode\n}\n\n/** `\"mixed\"` is the third state: some of the subtree is on, not all of it. */\nexport type CheckState = \"checked\" | \"unchecked\" | \"mixed\"\n\ninterface NestedCheckboxProps {\n  data: CheckboxNode[]\n  /** Controlled set of checked leaf ids. Pair with `onChange` to own the state. */\n  value?: string[]\n  /** Checked leaf ids on first render when uncontrolled. */\n  defaultValue?: string[]\n  /** Handed the next set of checked leaf ids, and the node that was clicked. */\n  onChange?: (checkedIds: string[], toggled: CheckboxNode) => void\n  /** Disables every row. */\n  disabled?: boolean\n  /**\n   * Submits the checked ids with a plain form POST: one hidden input per id, all under\n   * this name. Only needed for a form that posts itself — `onChange` is the usual route.\n   */\n  name?: string\n  /** Indent per level, in pixels. */\n  indent?: number\n  className?: string\n  /** Accessible name for the whole group (or wire `aria-labelledby` to a visible heading). */\n  \"aria-label\"?: string\n  \"aria-labelledby\"?: string\n}\n\nconst isGroup = (node: CheckboxNode) => (node.children?.length ?? 0) > 0\n\n/**\n * Every leaf id in the tree, in document order.\n *\n * Leaf ids are the whole value space: a check-all is `onChange(collectLeafIds(data))`, and\n * a group id never appears in a value because a group's state is derived rather than held.\n */\nexport function collectLeafIds(nodes: CheckboxNode[]): string[] {\n  const out: string[] = []\n  const walk = (list: CheckboxNode[]) => {\n    for (const node of list) {\n      if (isGroup(node)) walk(node.children as CheckboxNode[])\n      else out.push(node.id)\n    }\n  }\n  walk(nodes)\n  return out\n}\n\n/**\n * The state of every node in the tree, keyed by id — one post-order pass, so a deep tree\n * costs one walk rather than one walk per row.\n *\n * A group is `\"checked\"` when all of its children are, `\"unchecked\"` when none of them is,\n * and `\"mixed\"` otherwise. Disabled children are counted: what a group displays is the\n * truth about its subtree, and a group that said \"all of it\" while a visibly unchecked row\n * sat underneath would be lying about what is going to be submitted. The consequence is\n * deliberate — a leaf that is disabled *and* off holds its ancestors at mixed permanently,\n * because that is what the selection is. What a *click* on a group changes is the separate\n * question, and `toggleCheckboxNode` answers it differently.\n */\nfunction buildStates(\n  nodes: CheckboxNode[],\n  checked: Set<string>,\n  into: Map<string, CheckState> = new Map()\n): Map<string, CheckState> {\n  for (const node of nodes) {\n    if (!isGroup(node)) {\n      into.set(node.id, checked.has(node.id) ? \"checked\" : \"unchecked\")\n      continue\n    }\n    const children = node.children as CheckboxNode[]\n    buildStates(children, checked, into)\n    let on = 0\n    let off = 0\n    for (const child of children) {\n      const state = into.get(child.id)\n      if (state === \"checked\") on++\n      else if (state === \"unchecked\") off++\n    }\n    into.set(\n      node.id,\n      on === children.length ? \"checked\" : off === children.length ? \"unchecked\" : \"mixed\"\n    )\n  }\n  return into\n}\n\n/**\n * The state one node shows for a given selection — `\"mixed\"` when part of its subtree is on.\n *\n * Useful outside the component for the payload question: a server that wants the group id\n * when a whole branch is on can ask `nodeCheckState(group, value) === \"checked\"`.\n */\nexport function nodeCheckState(node: CheckboxNode, checkedIds: Iterable<string>): CheckState {\n  return buildStates([node], new Set(checkedIds)).get(node.id) as CheckState\n}\n\n/**\n * How many leaves each node can actually change, keyed by id — the same single pass as\n * `buildStates`, for the same reason: asking each row to walk its own subtree turns one\n * render of a deep tree into a walk per row.\n *\n * A count of zero means a press cannot do anything: the row is disabled, an ancestor is,\n * or every leaf beneath it is. Disabling a group disables its whole subtree, so the three\n * collapse into this one number.\n */\nfunction buildTogglableCounts(\n  nodes: CheckboxNode[],\n  inheritedDisabled: boolean,\n  into: Map<string, number> = new Map()\n): Map<string, number> {\n  for (const node of nodes) {\n    const nodeDisabled = inheritedDisabled || node.disabled === true\n    if (!isGroup(node)) {\n      into.set(node.id, nodeDisabled ? 0 : 1)\n      continue\n    }\n    const children = node.children as CheckboxNode[]\n    buildTogglableCounts(children, nodeDisabled, into)\n    let total = 0\n    for (const child of children) total += into.get(child.id) ?? 0\n    into.set(node.id, total)\n  }\n  return into\n}\n\n/** Descendant leaf ids of `node` that a click is allowed to change, in document order. */\nfunction togglableLeaves(node: CheckboxNode, inheritedDisabled: boolean): string[] {\n  const disabled = inheritedDisabled || node.disabled === true\n  if (!isGroup(node)) return disabled ? [] : [node.id]\n  const out: string[] = []\n  for (const child of node.children as CheckboxNode[]) {\n    out.push(...togglableLeaves(child, disabled))\n  }\n  return out\n}\n\nfunction findNode(\n  nodes: CheckboxNode[],\n  id: string,\n  inheritedDisabled = false\n): { node: CheckboxNode; inheritedDisabled: boolean } | null {\n  for (const node of nodes) {\n    if (node.id === id) return { node, inheritedDisabled }\n    if (isGroup(node)) {\n      const hit = findNode(\n        node.children as CheckboxNode[],\n        id,\n        inheritedDisabled || node.disabled === true\n      )\n      if (hit) return hit\n    }\n  }\n  return null\n}\n\n/**\n * The next value after clicking `id` — the roll-up, downward.\n *\n * Both directions have to exist or the thing is visibly broken: a group's *display* comes\n * up from its children (`buildStates`), and a click on a group goes back down and writes\n * every leaf beneath it. Implement only the first and pressing a group does nothing;\n * only the second and a group goes stale the moment a child is pressed.\n *\n * Which way it writes is decided by the togglable leaves, not by the state on screen. A\n * group showing `\"mixed\"` purely because one disabled leaf is off would otherwise never be\n * able to clear itself: it is not `\"checked\"`, so every press would read as \"turn it all\n * on\", and the group could be pressed forever without anything changing.\n *\n * Ids the tree does not contain are carried through untouched, so a tree that is filtered,\n * paginated or lazily loaded does not quietly drop the selections it cannot currently see.\n */\nexport function toggleCheckboxNode(\n  nodes: CheckboxNode[],\n  id: string,\n  checkedIds: string[]\n): string[] {\n  const hit = findNode(nodes, id)\n  if (!hit) return checkedIds\n  const leaves = togglableLeaves(hit.node, hit.inheritedDisabled)\n  if (leaves.length === 0) return checkedIds\n\n  const before = new Set(checkedIds)\n  const allOn = leaves.every((leafId) => before.has(leafId))\n  const next = new Set(before)\n  for (const leafId of leaves) {\n    if (allOn) next.delete(leafId)\n    else next.add(leafId)\n  }\n\n  // Keep the caller's order for what survives and append what was added in tree order, so\n  // the array is stable across presses instead of reshuffling on every change.\n  const kept = [...new Set(checkedIds)].filter((leafId) => next.has(leafId))\n  const added = leaves.filter((leafId) => !before.has(leafId) && next.has(leafId))\n  return [...kept, ...added]\n}\n\n/**\n * A checkbox list with parents and children that agree with each other: tick every child\n * and the parent fills in, tick the parent and the whole branch follows, tick some and the\n * parent shows the third state. Permissions, roles and scopes; category and facet filters;\n * which columns or fields an export includes; notification settings per channel; per-item\n * consent on a privacy screen; picking files and folders.\n *\n * Pass a `data` array of `{ id, label, children?, disabled?, description? }` — a node with\n * children is a group, a node without one is a leaf. The value is the set of checked *leaf*\n * ids, controlled (`value` + `onChange`) or uncontrolled (`defaultValue`); group ids are\n * never stored, because a group whose state is both derived and saved is two sources of\n * truth waiting to disagree.\n *\n * The third state is a value, `aria-checked=\"mixed\"`, and not a dash drawn in CSS. Each row\n * is therefore a `role=\"checkbox\"` button rather than an `<input type=\"checkbox\">`:\n * `indeterminate` is a DOM property with no HTML attribute behind it, so React has no way\n * to express it in JSX and it has to be assigned to the node through a ref after mount.\n * Everything that reads markup instead of a live DOM — the server render, the first paint,\n * a snapshot test, a crawler — then sees `aria-checked=\"false\"` on a row that is actually\n * part-selected, which is the bug where the dash is on screen and a screen reader says\n * \"not checked\". Here the state is an attribute, so it is true at every moment.\n *\n * Each checkbox is its own tab stop, the way a list of checkboxes in a form is, and Space\n * toggles. Distinct from `tree-view`, which is the ARIA tree pattern — one tab stop, arrow\n * keys, rows that open and close. That one is for hierarchy you *navigate*; this is for\n * hierarchy you *select*.\n */\nexport function NestedCheckbox({\n  data,\n  value,\n  defaultValue,\n  onChange,\n  disabled = false,\n  name,\n  indent = 24,\n  className,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n}: NestedCheckboxProps) {\n  const controlled = value !== undefined\n  const [internal, setInternal] = React.useState<string[]>(\n    () => (controlled ? value : defaultValue) ?? []\n  )\n  const checked = controlled ? (value as string[]) : internal\n\n  const reactId = React.useId()\n  const states = React.useMemo(() => buildStates(data, new Set(checked)), [data, checked])\n  const togglable = React.useMemo(\n    () => buildTogglableCounts(data, disabled),\n    [data, disabled]\n  )\n\n  function toggle(node: CheckboxNode, rowDisabled: boolean) {\n    if (rowDisabled) return\n    const next = toggleCheckboxNode(data, node.id, checked)\n    if (next === checked) return\n    if (!controlled) setInternal(next)\n    onChange?.(next, node)\n  }\n\n  // Label ids are built from each row's position, not from `node.id`, which is free-form:\n  // an id holding a space would split the space-separated token list that aria-describedby\n  // and the group's aria-labelledby are made of, leaving dangling references behind.\n  const renderNodes = (\n    nodes: CheckboxNode[],\n    level: number,\n    prefix: string,\n    inheritedDisabled: boolean\n  ) =>\n    nodes.map((node, index) => {\n      const group = isGroup(node)\n      const rowDisabled = inheritedDisabled || disabled || node.disabled === true\n      const state = states.get(node.id) ?? \"unchecked\"\n      const labelId = `${prefix}-${index}`\n      const descId = `${labelId}-d`\n      // A row with nothing togglable underneath it cannot be changed by a press, so it says\n      // so rather than looking live and doing nothing when pressed.\n      const inert = (togglable.get(node.id) ?? 0) === 0\n\n      return (\n        <li key={node.id}>\n          <div style={{ paddingLeft: level * indent }} className=\"py-0.5\">\n            <button\n              type=\"button\"\n              role=\"checkbox\"\n              aria-checked={state === \"mixed\" ? \"mixed\" : state === \"checked\"}\n              // aria-disabled, not the `disabled` attribute: a disabled row stays focusable,\n              // so somebody tabbing through can still find out that the permission exists\n              // and is locked. `disabled` would take it out of the tab order and hide it.\n              aria-disabled={inert || undefined}\n              aria-describedby={node.description ? descId : undefined}\n              onClick={() => toggle(node, inert)}\n              className={cn(\n                \"group flex items-center gap-2 rounded-sm text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n                inert ? \"cursor-not-allowed opacity-50\" : \"cursor-pointer\"\n              )}\n            >\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  \"flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border shadow-sm transition-colors\",\n                  state === \"unchecked\"\n                    ? \"border-input bg-background\"\n                    : \"border-primary bg-primary text-primary-foreground\",\n                  !inert && state === \"unchecked\" && \"group-hover:border-primary\"\n                )}\n              >\n                {state === \"checked\" ? (\n                  <Check className=\"h-3.5 w-3.5\" strokeWidth={3} />\n                ) : state === \"mixed\" ? (\n                  <Minus className=\"h-3.5 w-3.5\" strokeWidth={3} />\n                ) : null}\n              </span>\n              <span id={labelId} className={cn(\"text-sm\", group && \"font-medium\")}>\n                {node.label}\n              </span>\n            </button>\n\n            {node.description && (\n              <p\n                id={descId}\n                // Aligned under the label rather than under the box: the width of the box\n                // plus the gap, which is independent of `indent`.\n                style={{ paddingLeft: 24 }}\n                className=\"text-xs text-muted-foreground\"\n              >\n                {node.description}\n              </p>\n            )}\n          </div>\n\n          {group && (\n            // Named by the row above it, so a screen reader entering the subtree says which\n            // group these rows belong to instead of reading them as a flat list.\n            <ul role=\"group\" aria-labelledby={labelId}>\n              {renderNodes(\n                node.children as CheckboxNode[],\n                level + 1,\n                labelId,\n                rowDisabled\n              )}\n            </ul>\n          )}\n        </li>\n      )\n    })\n\n  return (\n    <div className={cn(\"text-foreground\", className)}>\n      <ul aria-label={ariaLabel} aria-labelledby={ariaLabelledby}>\n        {renderNodes(data, 0, `${reactId}-n`, false)}\n      </ul>\n      {name &&\n        checked.map((id) => <input key={id} type=\"hidden\" name={name} value={id} />)}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
