{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "org-chart",
  "type": "registry:ui",
  "title": "Org chart",
  "description": "A collapsible organisation chart with connector lines, initials avatars, team colours, per node counts and arrow key navigation.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/org-chart.tsx",
      "type": "registry:ui",
      "target": "components/ui/org-chart.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { ChevronDownIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/** A collapsible organisation chart with connector lines, initials avatars, team colours, per node counts and arrow key navigation. */\ntype OrgTone = \"accent\" | \"info\" | \"success\" | \"warning\" | \"neutral\"\n\ntype OrgNode = {\n  id: string\n  name: string\n  role: string\n  /** Team name. Each team gets a colour, in order of first appearance. */\n  team?: string\n  /** Letters in the avatar. Derived from the name when absent. */\n  initials?: string\n  children?: OrgNode[]\n}\n\ntype OrgChartProps = {\n  /** The root person. Defaults to Northwind's leadership, four levels deep with the lower branches folded. */\n  data?: OrgNode\n  /** Nodes at or above this depth start expanded. The root is depth 0. */\n  defaultExpandDepth?: number\n  /** Extra node ids that start expanded. */\n  defaultExpanded?: string[]\n  /** Controlled selected node id. */\n  selectedId?: string\n  defaultSelectedId?: string\n  /** Called when a node is clicked or activated. */\n  onSelect?: (node: OrgNode) => void\n  /** Pin team names to colours. Unlisted teams are coloured in order. */\n  teamTones?: Partial<Record<string, OrgTone>>\n  \"aria-label\"?: string\n  className?: string\n}\n\nconst SAMPLE: OrgNode = {\n  id: \"maya\",\n  name: \"Maya Chen\",\n  role: \"CEO\",\n  team: \"Leadership\",\n  children: [\n    {\n      id: \"theo\",\n      name: \"Theo Okafor\",\n      role: \"CTO\",\n      team: \"Engineering\",\n      children: [\n        {\n          id: \"priya\",\n          name: \"Priya Raman\",\n          role: \"VP Engineering\",\n          team: \"Engineering\",\n          children: [\n            { id: \"lena\", name: \"Lena Fischer\", role: \"Head of Design\", team: \"Design\" },\n            { id: \"sam\", name: \"Sam Lowe\", role: \"Staff Engineer\", team: \"Engineering\" },\n          ],\n        },\n      ],\n    },\n    {\n      id: \"diego\",\n      name: \"Diego Alvarez\",\n      role: \"CFO\",\n      team: \"Finance\",\n      children: [\n        { id: \"noor\", name: \"Noor Haddad\", role: \"Controller\", team: \"Finance\" },\n        { id: \"jonas\", name: \"Jonas Weber\", role: \"FP&A Lead\", team: \"Finance\" },\n      ],\n    },\n    {\n      id: \"aisha\",\n      name: \"Aisha Bello\",\n      role: \"COO\",\n      team: \"Operations\",\n      children: [\n        { id: \"yuki\", name: \"Yuki Tanaka\", role: \"People Lead\", team: \"Operations\" },\n        { id: \"marcus\", name: \"Marcus Reid\", role: \"Head of Logistics\", team: \"Operations\" },\n        { id: \"ravi\", name: \"Ravi Mehta\", role: \"Head of Support\", team: \"Operations\" },\n      ],\n    },\n  ],\n}\n\nconst TONES: OrgTone[] = [\"accent\", \"info\", \"success\", \"warning\", \"neutral\"]\nconst TONE: Record<OrgTone, { stripe: string; avatar: string }> = {\n  accent: { stripe: \"bg-accent\", avatar: \"bg-accent-soft text-accent-fg\" },\n  info: { stripe: \"bg-info\", avatar: \"bg-info/12 text-info\" },\n  success: { stripe: \"bg-success\", avatar: \"bg-success/12 text-success\" },\n  warning: { stripe: \"bg-warning\", avatar: \"bg-warning/14 text-warning\" },\n  neutral: { stripe: \"bg-fg-subtle\", avatar: \"bg-sunken text-fg-muted\" },\n}\nconst MORPH = { type: \"spring\", bounce: 0.16, duration: 0.5 } as const\nconst EXIT = { duration: 0.16, ease: [0.3, 0, 0.8, 0.15] } as const\n\nconst initialsOf = (name: string) =>\n  name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase() ?? \"\")\n    .join(\"\")\n\ntype Info = { node: OrgNode; parent: string | null; siblings: string[]; depth: number; descendants: number }\n\nfunction index(root: OrgNode): Map<string, Info> {\n  const map = new Map<string, Info>()\n  const walk = (n: OrgNode, parent: string | null, siblings: string[], depth: number): number => {\n    let count = 0\n    const kids = n.children ?? []\n    const ids = kids.map((k) => k.id)\n    for (const k of kids) count += 1 + walk(k, n.id, ids, depth + 1)\n    map.set(n.id, { node: n, parent, siblings, depth, descendants: count })\n    return count\n  }\n  walk(root, null, [root.id], 0)\n  return map\n}\n\ntype Ctx = {\n  info: Map<string, Info>\n  expanded: Set<string>\n  toggle: (id: string, to?: boolean) => void\n  selected: string | null\n  tabStop: string\n  choose: (node: OrgNode) => void\n  focus: (id: string | undefined) => void\n  setFocused: (id: string) => void\n  refs: React.MutableRefObject<Map<string, HTMLButtonElement>>\n  tone: (team?: string) => OrgTone\n  reduce: boolean\n}\nconst OrgContext = React.createContext<Ctx | null>(null)\n\nfunction Node({ node, root = false }: { node: OrgNode; root?: boolean }) {\n  const ctx = React.useContext(OrgContext)!\n  const meta = ctx.info.get(node.id)!\n  const kids = node.children ?? []\n  const open = kids.length > 0 && ctx.expanded.has(node.id)\n  const tone = TONE[ctx.tone(node.team)]\n  const selected = ctx.selected === node.id\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    const sib = meta.siblings\n    const i = sib.indexOf(node.id)\n    const key = e.key\n    if (key === \"ArrowRight\") ctx.focus(sib[Math.min(sib.length - 1, i + 1)])\n    else if (key === \"ArrowLeft\") ctx.focus(sib[Math.max(0, i - 1)])\n    else if (key === \"Home\") ctx.focus(sib[0])\n    else if (key === \"End\") ctx.focus(sib[sib.length - 1])\n    else if (key === \"ArrowDown\") {\n      if (!kids.length) return\n      if (!open) ctx.toggle(node.id, true)\n      else ctx.focus(kids[0].id)\n    } else if (key === \"ArrowUp\") {\n      if (meta.parent) ctx.focus(meta.parent)\n    } else if (key === \"Enter\" || key === \" \") {\n      ctx.choose(node)\n      if (kids.length) ctx.toggle(node.id)\n    } else return\n    e.preventDefault()\n  }\n\n  return (\n    <motion.li\n      layout={!ctx.reduce}\n      transition={ctx.reduce ? { duration: 0 } : MORPH}\n      data-slot=\"org-chart-node\"\n      className={cn(\n        \"relative flex flex-col items-center px-1.5 pb-3\",\n        !root && [\n          \"pt-6\",\n          \"before:absolute before:top-0 before:right-1/2 before:h-6 before:w-1/2 before:border-t before:border-border-strong before:content-['']\",\n          \"after:absolute after:top-0 after:left-1/2 after:h-6 after:w-1/2 after:border-t after:border-l after:border-border-strong after:content-['']\",\n          \"first:before:hidden first:after:rounded-tl-lg\",\n          \"last:before:rounded-tr-lg last:before:border-r last:after:hidden\",\n          \"only:before:block only:before:rounded-none only:before:border-t-0 only:before:border-r\",\n        ]\n      )}\n    >\n      <div className=\"relative\">\n        <button\n          ref={(el) => {\n            if (el) ctx.refs.current.set(node.id, el)\n            else ctx.refs.current.delete(node.id)\n          }}\n          type=\"button\"\n          tabIndex={ctx.tabStop === node.id ? 0 : -1}\n          aria-expanded={kids.length ? open : undefined}\n          aria-current={selected ? \"true\" : undefined}\n          aria-label={`${node.name}, ${node.role}${node.team ? `, ${node.team}` : \"\"}${kids.length ? `, ${meta.descendants} ${meta.descendants === 1 ? \"report\" : \"reports\"}` : \"\"}`}\n          data-selected={selected || undefined}\n          onFocus={() => ctx.setFocused(node.id)}\n          onClick={() => {\n            ctx.choose(node)\n            ctx.focus(node.id)\n          }}\n          onKeyDown={onKeyDown}\n          data-slot=\"org-chart-card\"\n          className={cn(\n            \"relative flex w-40 items-center gap-2.5 overflow-hidden rounded-lg border bg-raised p-2.5 text-left shadow-raised outline-none\",\n            \"transition-[border-color,box-shadow] duration-[140ms] ease-hairline\",\n            \"hover:border-border-strong focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg\",\n            selected ? \"border-accent-line shadow-[0_0_0_3px_var(--accent-soft)]\" : \"border-border\"\n          )}\n        >\n          <span aria-hidden className={cn(\"absolute inset-x-0 top-0 h-0.5\", tone.stripe)} />\n          <span aria-hidden className={cn(\"grid size-8 shrink-0 place-items-center rounded-full text-[11px] font-semibold tracking-[0.02em]\", tone.avatar)}>\n            {node.initials ?? initialsOf(node.name)}\n          </span>\n          <span className=\"min-w-0 flex-1\">\n            <span className=\"block truncate text-[0.8125rem] leading-[1.3] font-medium text-fg\">{node.name}</span>\n            <span className=\"block truncate text-[11px] leading-[1.35] text-fg-muted\">{node.role}</span>\n          </span>\n        </button>\n        {kids.length ? (\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-hidden\n            onClick={(e) => {\n              e.stopPropagation()\n              ctx.toggle(node.id)\n            }}\n            data-slot=\"org-chart-toggle\"\n            className={cn(\n              \"absolute -bottom-2.5 left-1/2 z-10 inline-flex h-5 -translate-x-1/2 items-center gap-0.5 rounded-full border border-border bg-raised pr-1 pl-1.5 font-mono text-[10px] text-fg-muted shadow-key outline-none\",\n              \"transition-[border-color,color] duration-[70ms] ease-hairline hover:border-border-strong hover:text-fg\"\n            )}\n          >\n            <span className=\"tabular-nums\">{meta.descendants}</span>\n            <ChevronDownIcon className={cn(\"size-3 transition-transform duration-[140ms] ease-hairline motion-reduce:transition-none\", open && \"rotate-180\")} />\n          </button>\n        ) : null}\n      </div>\n      <AnimatePresence initial={false}>\n        {open ? (\n          <motion.ul\n            key=\"children\"\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1, transition: ctx.reduce ? { duration: 0 } : MORPH }}\n            exit={{ height: 0, opacity: 0, transition: ctx.reduce ? { duration: 0 } : EXIT }}\n            className=\"relative flex items-start justify-center overflow-hidden pt-6 before:absolute before:top-0 before:left-1/2 before:h-6 before:w-px before:bg-border-strong before:content-['']\"\n          >\n            {kids.map((k) => (\n              <Node key={k.id} node={k} />\n            ))}\n          </motion.ul>\n        ) : null}\n      </AnimatePresence>\n    </motion.li>\n  )\n}\n\n/**\n * An org chart for people directories and admin consoles. Cards carry an\n * initials avatar, name, role and a team coloured stripe; a pill under each\n * manager shows how many people report up and folds the branch with a\n * spring. Connectors are plain CSS borders, the tree scrolls sideways when\n * it is wider than its container, and the arrow keys walk siblings, reports\n * and managers.\n */\nfunction OrgChart({\n  data = SAMPLE,\n  defaultExpandDepth = 0,\n  defaultExpanded = [\"theo\"],\n  selectedId,\n  defaultSelectedId,\n  onSelect,\n  teamTones,\n  \"aria-label\": ariaLabel = \"Organization chart\",\n  className,\n}: OrgChartProps) {\n  const reduce = useReducedMotion() ?? false\n  const info = React.useMemo(() => index(data), [data])\n  const [expanded, setExpanded] = React.useState(() => {\n    const s = new Set(defaultExpanded)\n    info.forEach((m, id) => {\n      if (m.node.children?.length && m.depth <= defaultExpandDepth) s.add(id)\n    })\n    return s\n  })\n  const [innerSelected, setInnerSelected] = React.useState<string | null>(defaultSelectedId ?? null)\n  const selected = selectedId ?? innerSelected\n  const [focused, setFocused] = React.useState<string | null>(null)\n  const refs = React.useRef(new Map<string, HTMLButtonElement>())\n  const scrollRef = React.useRef<HTMLDivElement>(null)\n  React.useEffect(() => {\n    const el = scrollRef.current\n    if (el) el.scrollLeft = (el.scrollWidth - el.clientWidth) / 2\n  }, [])\n\n  const teams = React.useMemo(() => {\n    const order: string[] = []\n    info.forEach((m) => {\n      if (m.node.team && !order.includes(m.node.team)) order.push(m.node.team)\n    })\n    return order\n  }, [info])\n  const tone = React.useCallback(\n    (team?: string): OrgTone => {\n      if (!team) return \"neutral\"\n      return teamTones?.[team] ?? TONES[teams.indexOf(team) % TONES.length]\n    },\n    [teams, teamTones]\n  )\n\n  /* Only visible nodes can hold the tab stop. */\n  const visible = (id: string) => {\n    let p = info.get(id)?.parent ?? null\n    while (p) {\n      if (!expanded.has(p)) return false\n      p = info.get(p)?.parent ?? null\n    }\n    return true\n  }\n  const tabStop = (focused && info.has(focused) && visible(focused) ? focused : null) ?? (selected && info.has(selected) && visible(selected) ? selected : null) ?? data.id\n\n  const ctx = React.useMemo<Ctx>(\n    () => ({\n      info,\n      expanded,\n      selected,\n      tabStop,\n      refs,\n      tone,\n      reduce,\n      setFocused,\n      toggle: (id, to) => {\n        const want = to ?? !expanded.has(id)\n        if (!want && focused) {\n          /* Collapsing a branch that holds focus: hand focus to the manager. */\n          let p = info.get(focused)?.parent ?? null\n          while (p) {\n            if (p === id) {\n              setFocused(id)\n              refs.current.get(id)?.focus()\n              break\n            }\n            p = info.get(p)?.parent ?? null\n          }\n        }\n        setExpanded((prev) => {\n          const next = new Set(prev)\n          if (want) next.add(id)\n          else next.delete(id)\n          return next\n        })\n      },\n      choose: (node) => {\n        if (selectedId === undefined) setInnerSelected(node.id)\n        onSelect?.(node)\n      },\n      focus: (id) => {\n        if (!id) return\n        setFocused(id)\n        requestAnimationFrame(() => refs.current.get(id)?.focus())\n      },\n    }),\n    [info, expanded, focused, selected, tabStop, tone, reduce, selectedId, onSelect]\n  )\n\n  return (\n    <OrgContext.Provider value={ctx}>\n      <div ref={scrollRef} data-slot=\"org-chart\" className={cn(\"w-full max-w-4xl overflow-x-auto overscroll-x-contain rounded-xl border border-border bg-surface text-fg shadow-raised\", className)}>\n        <ul aria-label={ariaLabel} className=\"flex w-max min-w-full justify-center px-4 py-5\">\n          <Node node={data} root />\n        </ul>\n      </div>\n    </OrgContext.Provider>\n  )\n}\n\nexport { OrgChart }\nexport type { OrgChartProps, OrgNode, OrgTone }\n"
    }
  ]
}
