Org Chart
A drill-down org chart: open a manager’s reports, see team sizes, search a name to reveal its path, step back up.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/org-chart.json'use client'
import { useMemo, useState } from 'react'
import './org-chart.css'
export interface OrgNode { id: string; name: string; title: string; reports?: OrgNode[] }
export interface OrgChartProps {
root: OrgNode
search?: boolean
onSelect?: (id: string) => void
fill?: 'dark' | 'light'
className?: string
}
const size = (n: OrgNode): number => (n.reports ?? []).reduce((a, r) => a + 1 + size(r), 0)
const ini = (s: string) => s.split(' ').map((x) => x[0]).join('').slice(0, 2)
/** Drill-down org chart with path and search. */
export function OrgChart({ root, search = true, onSelect, fill = 'dark', className }: OrgChartProps) {
const [path, setPath] = useState<OrgNode[]>([root])
const [hit, setHit] = useState<string | null>(null)
const [q, setQ] = useState('')
const all = useMemo(() => { const out: { n: OrgNode; trail: OrgNode[] }[] = []; const walk = (n: OrgNode, t: OrgNode[]) => { out.push({ n, trail: t }); (n.reports ?? []).forEach((r) => walk(r, [...t, n])) }; walk(root, []); return out }, [root])
const cur = path[path.length - 1]
const go = (n: OrgNode) => { setHit(null); onSelect?.(n.id); if (n.reports?.length) setPath([...path, n]) ; else setHit(n.id) }
const find = (s: string) => {
setQ(s)
const m = s.trim() && all.find((x) => x.n.name.toLowerCase().includes(s.trim().toLowerCase()))
if (m) { setPath(m.trail.length ? m.trail : [root]); setHit(m.n.id) } else if (!s.trim()) setHit(null)
}
const reports = cur.reports ?? []
return (
<div className={['sd-org', className].filter(Boolean).join(' ')} data-fill={fill}>
{search && <input className="sd-org__q" aria-label="Find a person" placeholder="Find a person" value={q} onChange={(e) => find(e.target.value)} />}
<nav className="sd-org__crumbs" aria-label="Path">
{path.map((n, i) => <span key={n.id}>{i > 0 && '› '}<button type="button" aria-current={i === path.length - 1 ? 'true' : undefined} onClick={() => { setPath(path.slice(0, i + 1)); setHit(null) }}>{n.name.split(' ')[0]}</button></span>)}
</nav>
<div key={cur.id} className="sd-org__mgr">
<span className="sd-org__av" aria-hidden>{ini(cur.name)}</span>
<span className="sd-org__who">{cur.name}<small>{cur.title} · {size(cur)} in org</small></span>
</div>
<div className="sd-org__stem" aria-hidden />
<div className="sd-org__grid" role="list" aria-label={`Reports to ${cur.name}`}>
{reports.map((r, i) => (
<button key={r.id} type="button" role="listitem" className="sd-org__p" data-hit={hit === r.id || undefined} style={{ animationDelay: `${i * 40}ms` }}
aria-label={`${r.name}, ${r.title}${r.reports?.length ? `, ${size(r)} reports. Open` : ''}`} onClick={() => go(r)}>
<span className="sd-org__av" aria-hidden>{ini(r.name)}</span>
<span className="sd-org__who">{r.name}<small>{r.title}</small></span>
{!!r.reports?.length && <span className="sd-org__n" aria-hidden>{size(r)}</span>}
</button>
))}
</div>
<p className="sd-org__msg" aria-live="polite">{q.trim() && !hit ? `No one called “${q}”` : hit ? `Found ${all.find((x) => x.n.id === hit)?.n.name}` : `${reports.length} direct reports`}</p>
</div>
)
}.sd-org { --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-org[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-org :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-org :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-org .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-org { width: min(100%, 340px); padding: 14px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-org__q { width: 100%; height: 36px; margin-bottom: 10px; padding: 0 12px; border: 0; border-radius: 10px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13.5px; outline: 0; }
.sd-org__q:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-org__q::placeholder { color: var(--mute); }
.sd-org__crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; margin-bottom: 12px; font-size: 12.5px; color: var(--mute); }
.sd-org__crumbs button { padding: 3px 6px; border-radius: 6px; color: var(--mute) !important; }
.sd-org__crumbs button:hover { background: var(--soft); color: var(--fg) !important; }
.sd-org__crumbs [aria-current] { color: var(--fg) !important; font-weight: 600; }
.sd-org__mgr { display: flex; align-items: center; gap: 10px; padding: 12px; border-radius: 14px; background: var(--strong); color: var(--on); animation: sd-org-in 260ms ease-out; }
.sd-org__mgr .sd-org__av { background: color-mix(in srgb, var(--on) 14%, transparent); }
.sd-org__mgr small { color: color-mix(in srgb, var(--on) 65%, transparent); }
.sd-org__stem { width: 2px; height: 14px; margin: 0 auto; background: var(--line); }
.sd-org__grid { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-top: 12px; border-top: 2px solid var(--line); }
.sd-org__p { display: flex; align-items: center; gap: 9px; padding: 10px; border-radius: 12px; text-align: left; box-shadow: inset 0 0 0 1px var(--line); animation: sd-org-in 260ms ease-out backwards; transition: background-color 120ms; }
.sd-org__p:hover { background: var(--soft); }
.sd-org__p[data-hit] { box-shadow: inset 0 0 0 2px var(--fg); }
.sd-org__av { display: grid; place-items: center; width: 32px; height: 32px; flex: none; border-radius: 99px; background: var(--soft); font-size: 11.5px; font-weight: 700; }
.sd-org__who { min-width: 0; flex: 1; font-size: 13px; font-weight: 600; line-height: 1.25; }
.sd-org__who small { display: block; font-size: 11.5px; font-weight: 400; color: var(--mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-org__n { padding: 2px 6px; border-radius: 99px; background: var(--soft); font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; }
.sd-org__msg { margin: 10px 2px 0; font-size: 12px; color: var(--mute); }
@keyframes sd-org-in { from { opacity: 0; transform: translateY(6px); } }
@media (prefers-reduced-motion: reduce) { .sd-org *, .sd-org *::before, .sd-org *::after { animation: none !important; transition: none !important; } }Overview
Starts at the top with direct reports. Tap a manager to focus them: their reports appear below and a breadcrumb shows the path back.
Search finds anyone and jumps to their manager with them highlighted. Each card shows how many people roll up to them.
When to use
- Company directories, onboarding, team pages.
When not to use
- Very wide orgs on small screens: use a list.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/org-chart.json$
npx shadcn@latest add https://sundaeui.dev/r/org-chart.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/org-chart.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/org-chart.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 org-chart.tsx and org-chart.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 { OrgChart } from '@/components/sundae/org-chart'
<OrgChart />Props
| Prop | Type | Default |
|---|---|---|
root*Nested people with reports. | OrgNode | — |
onSelectPerson chosen. | (id: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- People are buttons; the breadcrumb is a nav landmark with the current level marked.
- Search results are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-org; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Org Chart free?
- Yes. Org Chart is free for personal and commercial projects, with full source on this page.
- How do I install Org Chart?
- Run npx shadcn@latest add https://sundaeui.dev/r/org-chart.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Org Chart support keyboard and screen readers?
- People are buttons; the breadcrumb is a nav landmark with the current level marked. Search results are announced.
- Does Org Chart respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →JSON Viewer
Data · Refined- Pro
Treemap
Data · Refined OKR Tracker
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined