# Org chart 1: organization tree

> An organization chart drawn as a real tree: connected person cards, a count toggle on every team, search that opens the path to a person, a details panel with manager and direct reports, and the standard tree keys.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/orgchart-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/orgchart-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/orgchart-1-light-2d5c74d4ab30ce94.png
- Registry item: https://ui.ballmac.com/r/orgchart-1.json
- Categories: blocks, orgchart
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/orgchart-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/cover-art.json`, `https://ui.ballmac.com/r/direction.json`, `https://ui.ballmac.com/r/match-highlight.json`, `https://ui.ballmac.com/r/skeleton.json`, `https://ui.ballmac.com/r/state-view.json`

## Files

### `registry/ballmac/components/blocks/orgchart-1/orgchart-1.tsx`

```tsx
// Ballmac UI: Org Chart 1. https://ui.ballmac.com/blocks/orgchart-1
"use client"

import * as React from "react"
import { useReducedMotion } from "motion/react"
import { ChevronsDownUp, ChevronsUpDown, Mail, MapPin, Minus, Network, Search, UserRound, UsersRound } from "lucide-react"

import { Button } from "@/components/ballmac/button"
import { CoverArt } from "@/components/ballmac/cover-art"
import { MatchHighlight, matchesAll, queryTokens } from "@/components/ballmac/match-highlight"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { useDirection } from "@/lib/ballmac/direction"
import { cn } from "@/lib/utils"

type Org1Person = {
  id: string
  name: string
  title: string
  /** The team, shown as a small label. */
  team?: string
  location?: string
  email?: string
  /** The people who report to this person. */
  reports?: Org1Person[]
}

type Org1Props = Omit<React.ComponentProps<"section">, "title"> &
  StateProps & {
    /** The top of the organization. */
    root?: Org1Person
    title?: string
    description?: string
    /** How many levels are open at first. 1 shows only the top. */
    defaultExpandedDepth?: number
    /** The person whose details are open at first. */
    defaultSelectedId?: string
    /** Called when a person is chosen. */
    onSelect?: (person: Org1Person) => void
  }

const sample: Org1Person = {
  id: "mara",
  name: "Mara Ellison",
  title: "Chief Executive",
  location: "Lisbon",
  email: "mara@acme.com",
  reports: [
    {
      id: "theo",
      name: "Theo Nakamura",
      title: "Chief Technology Officer",
      team: "Engineering",
      location: "Osaka",
      email: "theo@acme.com",
      reports: [
        { id: "priya", name: "Priya Shah", title: "Head of Platform", team: "Engineering", location: "Lisbon", email: "priya@acme.com", reports: [{ id: "diego", name: "Diego Marín", title: "Staff Engineer", team: "Platform", location: "Madrid" }, { id: "kofi", name: "Kofi Mensah", title: "Senior Engineer", team: "Platform", location: "Accra" }] },
        { id: "lena", name: "Lena Fischer", title: "Head of Product Engineering", team: "Engineering", location: "Berlin", reports: [{ id: "yuki", name: "Yuki Tanaka", title: "Engineer", team: "Web", location: "Tokyo" }, { id: "oliver", name: "Oliver Reed", title: "Engineer", team: "Desktop", location: "Leeds" }] },
      ],
    },
    {
      id: "noor",
      name: "Noor Haddad",
      title: "Head of Design",
      team: "Design",
      location: "Porto",
      email: "noor@acme.com",
      reports: [
        { id: "amara", name: "Amara Okoye", title: "Product Designer", team: "Design", location: "Lagos" },
        { id: "jordan", name: "Jordan Lee", title: "Design Systems Lead", team: "Design", location: "Lisbon" },
        { id: "ines", name: "Ines Costa", title: "Researcher", team: "Design", location: "Porto" },
      ],
    },
    {
      id: "sam",
      name: "Sam Okafor",
      title: "Head of Customer Support",
      team: "Support",
      location: "Dublin",
      email: "sam@acme.com",
      reports: [
        { id: "alex", name: "Alex Kim", title: "Support Lead", team: "Support", location: "Dublin" },
        { id: "mia", name: "Mia Laurent", title: "Support Specialist", team: "Support", location: "Paris" },
      ],
    },
    { id: "ana", name: "Ana Souza", title: "Head of Operations", team: "Operations", location: "Lisbon", email: "ana@acme.com" },
  ],
}

const initials = (name: string) => name.split(" ").map((w) => w[0]).join("").slice(0, 2)

function walk(p: Org1Person, depth = 0, parent?: string, out: Map<string, { person: Org1Person; parent?: string; depth: number }> = new Map()) {
  out.set(p.id, { person: p, parent, depth })
  p.reports?.forEach((r) => walk(r, depth + 1, p.id, out))
  return out
}
const countBelow = (p: Org1Person): number => (p.reports ?? []).reduce((n, r) => n + 1 + countBelow(r), 0)

function OrgSkeleton() {
  return (
    <div className="grid justify-items-center gap-8 py-8" aria-hidden="true">
      <Skeleton className="h-20 w-52 rounded-2xl" />
      <div className="flex flex-wrap justify-center gap-4">
        {[0, 1, 2].map((i) => (
          <Skeleton key={i} className="h-20 w-52 max-w-full rounded-2xl" />
        ))}
      </div>
    </div>
  )
}

/**
 * An organization chart built as a real tree: connected cards, a toggle on each team, search that opens the path to a person, a panel with
 * their details and the people who report to them, and the standard tree keys (arrows to move, Left and Right to collapse and expand, Enter to
 * choose). The connectors mirror in right-to-left.
 */
function Org1({
  root = sample,
  title = "Our organization",
  description = "Who leads what, and who reports to whom. Select a person to see their team.",
  defaultExpandedDepth = 2,
  defaultSelectedId,
  onSelect,
  state = "ready",
  onRetry,
  emptyState,
  errorState,
  className,
  ...props
}: Org1Props) {
  const id = React.useId()
  const reduce = useReducedMotion()
  const dir = useDirection()
  const treeRef = React.useRef<HTMLUListElement>(null)
  const scrollerRef = React.useRef<HTMLDivElement>(null)
  const index = React.useMemo(() => (state === "empty" ? null : walk(root)), [root, state])
  const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set([...walk(root)].filter(([, v]) => v.depth < defaultExpandedDepth && v.person.reports?.length).map(([k]) => k)))
  const [selectedId, setSelectedId] = React.useState<string | undefined>(defaultSelectedId ?? root.id)
  const [activeId, setActiveId] = React.useState(root.id)
  const [query, setQuery] = React.useState("")
  const [announce, setAnnounce] = React.useState("")
  const ready = state === "ready" || state === "empty"

  const tokens = queryTokens(query)
  const matches = index && tokens.length ? [...index.values()].filter((v) => matchesAll(`${v.person.name} ${v.person.title} ${v.person.team ?? ""}`, tokens)).map((v) => v.person) : []
  const matchIds = new Set(matches.map((m) => m.id))
  const selected = index && selectedId ? index.get(selectedId)?.person : undefined

  // Open with the chosen person (or the top) in the middle of the view, not at the left edge of a wide tree.
  React.useEffect(() => {
    const center = () => {
      const box = scrollerRef.current
      const el = box?.querySelector<HTMLElement>(`[data-node="${defaultSelectedId ?? root.id}"] > div`)
      if (!box || !el) return
      const b = box.getBoundingClientRect()
      const r = el.getBoundingClientRect()
      box.scrollLeft += r.left + r.width / 2 - (b.left + b.width / 2)
      if (defaultSelectedId) box.scrollTop += Math.max(0, r.top - b.top - b.height / 2 + r.height / 2)
    }
    // Once now, and again after the direction and fonts have settled, so it holds in right-to-left too.
    center()
    const raf = requestAnimationFrame(center)
    const later = setTimeout(center, 200)
    return () => {
      cancelAnimationFrame(raf)
      clearTimeout(later)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [state, dir])

  const focusNode = (nodeId: string) => {
    setActiveId(nodeId)
    requestAnimationFrame(() => {
      const el = treeRef.current?.querySelector<HTMLElement>(`[data-node="${nodeId}"]`)
      el?.focus({ preventScroll: true })
      el?.scrollIntoView({ block: "nearest", inline: "center", behavior: reduce ? "auto" : "smooth" })
    })
  }
  const choose = (p: Org1Person) => {
    setSelectedId(p.id)
    onSelect?.(p)
  }
  const toggle = (nodeId: string, open?: boolean) =>
    setExpanded((s) => {
      const n = new Set(s)
      const next = open ?? !n.has(nodeId)
      if (next) n.add(nodeId)
      else n.delete(nodeId)
      return n
    })
  const reveal = (p: Org1Person) => {
    if (!index) return
    const next = new Set(expanded)
    for (let at = index.get(p.id)?.parent; at; at = index.get(at)?.parent) next.add(at)
    setExpanded(next)
    choose(p)
    focusNode(p.id)
    setAnnounce(`Showing ${p.name}, ${p.title}`)
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLElement>) {
    const item = (e.target as HTMLElement).closest<HTMLElement>("[role=treeitem]")
    if (!item || e.target !== item || !index) return
    const nodeId = item.dataset.node!
    const info = index.get(nodeId)!
    const items = [...(treeRef.current?.querySelectorAll<HTMLElement>("[role=treeitem]") ?? [])]
    const at = items.indexOf(item)
    const open = expanded.has(nodeId)
    const hasKids = !!info.person.reports?.length
    const go = (el?: HTMLElement) => el && (e.preventDefault(), focusNode(el.dataset.node!))
    if (e.key === "ArrowDown") go(items[at + 1])
    else if (e.key === "ArrowUp") go(items[at - 1])
    else if (e.key === "Home") go(items[0])
    else if (e.key === "End") go(items[items.length - 1])
    else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
      const forward = (e.key === "ArrowRight") !== (getComputedStyle(item).direction === "rtl")
      e.preventDefault()
      if (forward) {
        if (hasKids && !open) {
          toggle(nodeId, true)
          setAnnounce(`${info.person.name}'s team opened`)
        } else if (hasKids) focusNode(info.person.reports![0]!.id)
      } else if (hasKids && open) {
        toggle(nodeId, false)
        setAnnounce(`${info.person.name}'s team closed`)
      } else if (info.parent) focusNode(info.parent)
    } else if (e.key === "Enter" || e.key === " ") {
      e.preventDefault()
      choose(info.person)
    }
  }

  const renderNode = (p: Org1Person, level: number, place: "root" | "only" | "first" | "middle" | "last"): React.ReactNode => {
    const kids = p.reports ?? []
    const open = expanded.has(p.id)
    const below = countBelow(p)
    const isSel = selectedId === p.id
    return (
      <li
        key={p.id}
        role="treeitem"
        data-node={p.id}
        aria-level={level}
        aria-expanded={kids.length ? open : undefined}
        aria-selected={isSel}
        aria-labelledby={`${id}-n-${p.id}`}
        tabIndex={activeId === p.id ? 0 : -1}
        onFocus={(e) => e.target === e.currentTarget && setActiveId(p.id)}
        className={cn("relative flex flex-col items-center px-2 outline-none", place !== "root" && "pt-6")}
      >
        {place !== "root" ? (
          <>
            <span aria-hidden="true" className="bg-border absolute start-1/2 top-0 h-6 w-px" />
            {place !== "only" ? <span aria-hidden="true" className={cn("bg-border absolute top-0 h-px", place === "first" ? "start-1/2 end-0" : place === "last" ? "start-0 end-1/2" : "inset-x-0")} /> : null}
          </>
        ) : null}
        <div
          onClick={() => choose(p)}
          className={cn(
            "bg-card relative w-56 cursor-pointer rounded-2xl border p-3 shadow-xs transition-[border-color,box-shadow] duration-150 hover:border-foreground/30 hover:shadow-md motion-reduce:transition-none",
            isSel && "border-foreground ring-foreground ring-1",
            matchIds.has(p.id) && !isSel && "ring-chart-3 border-chart-3 ring-2",
            "[li:focus-visible>&]:ring-ring/60 [li:focus-visible>&]:ring-[3px]"
          )}
        >
          <div className="flex items-center gap-3">
            <span aria-hidden="true" className="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border text-xs font-semibold">
              <CoverArt seed={p.name} variant="mesh" className="absolute inset-0" />
              <span className="relative">{initials(p.name)}</span>
            </span>
            <div className="min-w-0 flex-1">
              <p id={`${id}-n-${p.id}`} className="truncate text-sm leading-tight font-semibold">
                <MatchHighlight text={p.name} query={tokens} />
                <span className="sr-only">, {p.title}{p.team ? `, ${p.team}` : ""}{kids.length ? `, ${below} ${below === 1 ? "person" : "people"} in their organization` : ""}</span>
              </p>
              <p aria-hidden="true" className="text-muted-foreground mt-0.5 truncate text-xs">
                <MatchHighlight text={p.title} query={tokens} />
              </p>
            </div>
          </div>
          {p.team ? (
            <p aria-hidden="true" className="text-muted-foreground mt-2.5 flex items-center gap-1.5 text-[11px]">
              <UsersRound className="size-3" />
              {p.team}
            </p>
          ) : null}
          {kids.length ? (
            <button
              type="button"
              tabIndex={-1}
              aria-hidden="true"
              onClick={(e) => {
                e.stopPropagation()
                toggle(p.id)
              }}
              className={cn("bg-background hover:bg-accent absolute -bottom-3 start-1/2 z-10 flex h-6 min-w-6 -translate-x-1/2 items-center justify-center rounded-full border px-1.5 text-[11px] font-medium tabular-nums shadow-sm rtl:translate-x-1/2")}
            >
              {open ? <Minus className="size-3" /> : kids.length}
            </button>
          ) : null}
        </div>
        {kids.length && open ? (
          <>
            <span aria-hidden="true" className="bg-border h-6 w-px" />
            <ul role="group" className="flex justify-center">
              {kids.map((c, i) => renderNode(c, level + 1, kids.length === 1 ? "only" : i === 0 ? "first" : i === kids.length - 1 ? "last" : "middle"))}
            </ul>
          </>
        ) : null}
      </li>
    )
  }

  const manager = selected && index ? index.get(selected.id)?.parent : undefined

  const content =
    !index ? (
      <div className="bg-card rounded-2xl border px-6 py-16 text-center">
        <span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full">
          <Network className="size-6" />
        </span>
        <h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "No organization chart yet"}</h2>
        <p className="text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty">{emptyState?.description ?? "Add people and who they report to, and the chart draws itself."}</p>
        {emptyState?.action}
      </div>
    ) : (
      <div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
        <div className="min-w-0">
          <div className="mb-4 flex flex-wrap items-center gap-2">
            <div className="bg-background focus-within:ring-ring/50 flex h-9 min-w-0 flex-1 basis-56 items-center gap-2 rounded-md border px-2.5 focus-within:ring-[3px]">
              <Search aria-hidden="true" className="text-muted-foreground size-4" />
              <input type="search" aria-label="Find a person" placeholder="Find a person, a role or a team" value={query} onChange={(e) => setQuery(e.target.value)} className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none" />
              {tokens.length ? <span className="text-muted-foreground text-xs tabular-nums">{matches.length}</span> : null}
            </div>
            <Button variant="outline" size="sm" onClick={() => setExpanded(new Set([...index.values()].filter((v) => v.person.reports?.length).map((v) => v.person.id)))}>
              <ChevronsUpDown aria-hidden="true" />
              Expand all
            </Button>
            <Button variant="outline" size="sm" onClick={() => setExpanded(new Set())}>
              <ChevronsDownUp aria-hidden="true" />
              Collapse
            </Button>
          </div>
          {tokens.length ? (
            <ul aria-label="Matches" className="mb-4 flex flex-wrap gap-2">
              {matches.slice(0, 6).map((m) => (
                <li key={m.id}>
                  <button type="button" onClick={() => reveal(m)} className="hover:bg-accent focus-visible:ring-ring/50 rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-[3px]">
                    <MatchHighlight text={m.name} query={tokens} /> <span className="text-muted-foreground">· {m.title}</span>
                  </button>
                </li>
              ))}
              {!matches.length ? <li className="text-muted-foreground text-sm">Nobody matches “{query.trim()}”.</li> : null}
            </ul>
          ) : null}
          <div ref={scrollerRef} tabIndex={-1} className="bg-card relative max-h-[40rem] overflow-auto rounded-2xl border p-6 pb-10" onKeyDown={onKeyDown}>
            <ul ref={treeRef} role="tree" aria-label="Organization chart" className="mx-auto flex w-max min-w-full justify-center">
              {renderNode(root, 1, "root")}
            </ul>
          </div>
        </div>

        <aside aria-labelledby={`${id}-person`} className="bg-card min-w-0 rounded-2xl border p-5 lg:sticky lg:top-6">
          {selected ? (
            <>
              <div className="flex items-center gap-4">
                <span aria-hidden="true" className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full border text-lg font-semibold">
                  <CoverArt seed={selected.name} variant="mesh" className="absolute inset-0" />
                  <span className="relative">{initials(selected.name)}</span>
                </span>
                <div className="min-w-0">
                  <h2 id={`${id}-person`} className="text-lg leading-tight font-semibold tracking-tight">
                    {selected.name}
                  </h2>
                  <p className="text-muted-foreground mt-0.5 text-sm text-pretty">{selected.title}</p>
                </div>
              </div>
              <dl className="mt-5 grid gap-2.5 text-sm">
                {selected.team ? (
                  <div className="flex items-center gap-2.5">
                    <dt className="sr-only">Team</dt>
                    <UsersRound aria-hidden="true" className="text-muted-foreground size-4 shrink-0" />
                    <dd>{selected.team}</dd>
                  </div>
                ) : null}
                {selected.location ? (
                  <div className="flex items-center gap-2.5">
                    <dt className="sr-only">Location</dt>
                    <MapPin aria-hidden="true" className="text-muted-foreground size-4 shrink-0" />
                    <dd>{selected.location}</dd>
                  </div>
                ) : null}
                {selected.email ? (
                  <div className="flex items-center gap-2.5">
                    <dt className="sr-only">Email</dt>
                    <Mail aria-hidden="true" className="text-muted-foreground size-4 shrink-0" />
                    <dd className="min-w-0 truncate">
                      <a href={`mailto:${selected.email}`} className="focus-visible:ring-ring/50 rounded-sm underline underline-offset-4 outline-none focus-visible:ring-[3px]">
                        {selected.email}
                      </a>
                    </dd>
                  </div>
                ) : null}
              </dl>
              {manager ? (
                <div className="mt-5 border-t pt-4">
                  <p className="text-muted-foreground mb-2 text-xs font-medium">Reports to</p>
                  <button type="button" onClick={() => reveal(index.get(manager)!.person)} className="hover:bg-accent focus-visible:ring-ring/50 -mx-2 flex w-[calc(100%+1rem)] items-center gap-2.5 rounded-lg px-2 py-1.5 text-start text-sm outline-none focus-visible:ring-[3px]">
                    <UserRound aria-hidden="true" className="text-muted-foreground size-4" />
                    {index.get(manager)!.person.name}
                    <span className="text-muted-foreground truncate text-xs">{index.get(manager)!.person.title}</span>
                  </button>
                </div>
              ) : null}
              {selected.reports?.length ? (
                <div className="mt-5 border-t pt-4">
                  <p className="text-muted-foreground mb-2 text-xs font-medium">
                    {selected.reports.length} direct {selected.reports.length === 1 ? "report" : "reports"}
                    {countBelow(selected) > selected.reports.length ? `, ${countBelow(selected)} in the organization` : ""}
                  </p>
                  <ul className="grid gap-0.5">
                    {selected.reports.map((r) => (
                      <li key={r.id}>
                        <button type="button" onClick={() => reveal(r)} className="hover:bg-accent focus-visible:ring-ring/50 -mx-2 flex w-[calc(100%+1rem)] items-center gap-2.5 rounded-lg px-2 py-1.5 text-start text-sm outline-none focus-visible:ring-[3px]">
                          <span className="truncate">{r.name}</span>
                          <span className="text-muted-foreground ms-auto truncate text-xs">{r.title}</span>
                        </button>
                      </li>
                    ))}
                  </ul>
                </div>
              ) : null}
            </>
          ) : (
            <p id={`${id}-person`} className="text-muted-foreground text-sm">
              Select a person to see their details.
            </p>
          )}
        </aside>
      </div>
    )

  return (
    <section data-slot="orgchart-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-6xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
      <header className="mb-8 max-w-2xl">
        <h1 id={`${id}-title`} className="text-4xl font-semibold tracking-[-0.04em] text-balance">
          {title}
        </h1>
        <p className="text-muted-foreground mt-3 text-base text-pretty">{description}</p>
      </header>
      <StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the organization chart" loading={<OrgSkeleton />} error={{ title: errorState?.title ?? "The organization chart could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}>
        {content}
      </StateView>
      <p aria-live="polite" className="sr-only">
        {announce}
      </p>
    </section>
  )
}

export { Org1, type Org1Props, type Org1Person }
```
