OrganizationChart
Preview
Basic
Loading…
Preview
Code
ts
import OrganizationChart from '@/components/data/OrganizationChart';src/components/data/OrganizationChart.tsx
AI prompt
text
Build an organization chart component in React + TypeScript + Tailwind CSS: a top-down tree of person cards joined by connector lines, with collapsible subtrees, optional single selection, and a custom card template.
## Look
- Outer box: its OWN horizontal scroller (`overflow-x-auto`, thin scrollbar) — a wide chart scrolls in place instead of scrolling the page sideways. Inside, a `flex w-max min-w-full justify-center gap-8 p-3` list of roots: this centres a narrow chart but lets a wide one start at the left edge (centring on the scroller itself would push a wide chart's left half into unreachable negative scroll). Several roots sit side by side with no connector between them.
- Card: the opaque floating-surface style (white / dark slate-800, slate-200 / dark slate-700 border, `rounded-2xl`, shadow), `min-w-36 max-w-52 px-3 py-2.5`, with a 3px top border as an accent stripe by `tone` — neutral slate-300 / dark slate-600 (default), info indigo-500 / 400, success emerald-500 / 400, warning amber-500 / 400, danger rose-500 / 400.
- Default card contents: a 36px round avatar (an image with `object-cover`, or initials in white semibold text-xs on an indigo-600 circle — the `avatar` string counts as a URL if it contains "/" or ":"), then the name (text-xs semibold slate-800 / dark slate-100) over the title (11px slate-500 / dark slate-400), both truncating.
- Selected: `ring-2 ring-indigo-500` with a 2px offset in white (dark: indigo-400, offset slate-900). Focus: `ring-2 ring-indigo-400` without offset. Both can show at once and still read as two things.
- Connectors are 1px lines in slate-300 / dark slate-600, made from plain positioned boxes — no SVG or canvas, nothing to measure on resize. Under a card with reports: a 10px vertical stub, the collapse badge, another 10px stub, then a row of child cells. Each child cell (`relative px-2 pt-4`, a centred column) draws the LEFT half of the horizontal bar above it (omitted for the first child), the RIGHT half (omitted for the last), and a 16px drop from the bar to its card. Adjacent halves meet, so the bar always spans exactly first-child centre to last-child centre.
- Collapse badge: a 16px-tall pill (`min-w-4 px-1 rounded-full`), slate-300 border on white, 10px semibold slate-500 (dark: slate-600 border, slate-800 fill, slate-400 text); hover indigo-400 border and indigo-600 text (dark indigo-300). Expanded: a 10px `Minus`. Collapsed: a 10px `Plus` and the number of direct reports. When collapsed, the lower stub and the children are not rendered.
## Behaviour
- `selectable` makes cards pressable: a click selects, and pressing the selected card again clears it. `onChange(key | null, node | null)`. `value` is controlled whenever it isn't `undefined` (`null` means nothing selected).
- Collapse is controlled (`collapsedKeys` + `onToggle`, which gets the whole next list) or uncontrolled (`defaultCollapsedKeys`).
- `nodeTemplate(node)` replaces the card's contents; the shell — focus, selection ring, tone stripe, connectors — stays.
## Keyboard
Every card is focusable. Enter/Space toggle selection (when selectable). ArrowDown moves to the first report (if not collapsed), ArrowUp to the manager, ArrowLeft/Right to the previous/next peer. Focus the target with `preventScroll`, then `scrollIntoView({ block: 'nearest', inline: 'nearest' })` so the chart's own box scrolls sideways to reveal an off-screen peer. Ignore keys whose target is a control inside a template (target ≠ the card).
## Accessibility
Nested `<ul>`/`<li>` (root list `aria-label`, default "Organization chart"), so a screen reader hears the reporting lines as list nesting. Selectable cards are `role="button"` with `aria-pressed`; with a template, the card's `aria-label` is the node label. The badge has `aria-expanded` and an `aria-label` like "Collapse Jordan Lee (3 reports)" / "Expand Drew Morgan (1 report)". Connector lines are `aria-hidden`. A template that adds a dropdown must portal it, since the scroller would clip it.
## API
`OrgChartNode = { key: string; label: string; title?: string; avatar?: string; tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; children?: OrgChartNode[] }`. Props: `nodes`, `selectable = false`, `value?`, `onChange?`, `collapsedKeys?`, `defaultCollapsedKeys?`, `onToggle?`, `nodeTemplate?`, `aria-label?`, `className?`.
## Demo
Avery Stone (Director, info) with three reports: Jordan Lee (Engineering lead, success) over Sam Park (Frontend), Riley Chen (Backend) and Morgan Diaz (Platform); Casey Brooks (Design lead, warning) over Quinn Ellis (Product design) and Taylor Reed (Research); Drew Morgan (Operations) over Jamie Fox (Support), collapsed by default. Initials as avatars, selectable, Jordan Lee pre-selected.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { Minus, Plus } from 'lucide-react';
import { cn } from '@/lib/cn';
import Avatar from '@/components/layout/Avatar';
// The accent stripe along each card's top edge, in the kit's five semantic tones.
const TONE = {
neutral: 'border-t-slate-300 dark:border-t-slate-600',
info: 'border-t-indigo-500 dark:border-t-indigo-400',
success: 'border-t-emerald-500 dark:border-t-emerald-400',
warning: 'border-t-amber-500 dark:border-t-amber-400',
danger: 'border-t-rose-500 dark:border-t-rose-400',
} as const;
export type OrgChartTone = keyof typeof TONE;
export interface OrgChartNode {
/** Unique across the whole chart. */
key: string;
label: string;
/** Second line under the label — a role or a department. */
title?: string;
/** An image URL, or up to two initials drawn in `Avatar`'s circle. */
avatar?: string;
tone?: OrgChartTone;
children?: OrgChartNode[];
}
export interface OrganizationChartProps {
/** Usually one root; several draw side by side with no connector between them. */
nodes: OrgChartNode[];
/** Makes cards pressable. Selection is single; pressing the selected card again clears it. */
selectable?: boolean;
value?: string | null;
onChange?: (key: string | null, node: OrgChartNode | null) => void;
/** Controlled collapse; pair with `onToggle`. */
collapsedKeys?: string[];
defaultCollapsedKeys?: string[];
onToggle?: (collapsedKeys: string[]) => void;
/** Replaces the card's contents. The card shell — focus, selection ring, connectors — is kept. */
nodeTemplate?: (node: OrgChartNode) => ReactNode;
'aria-label'?: string;
className?: string;
}
// Anything with a slash or a scheme is a URL; a bare "AK" is initials.
const isUrl = (s: string) => /[/:]/.test(s);
const LINE = 'bg-slate-300 dark:bg-slate-600';
/**
* A top-down chart of cards joined by connector lines.
*
* The connectors are plain flex boxes, not SVG or canvas: each child cell
* draws the left and right HALVES of the horizontal bar above it (the first
* child omits its left half, the last its right), plus a short drop to its
* card. Adjacent cells meet, so the bar is continuous and always spans
* exactly first-child-centre to last-child-centre, at any width, with nothing
* to measure or re-lay-out on resize.
*
* The markup is nested `<ul>`/`<li>`, so a screen reader hears the reporting
* lines as list nesting. Arrow keys walk the same structure: Down to the first
* report, Up to the manager, Left/Right between peers.
*/
export default function OrganizationChart({
nodes,
selectable = false,
value,
onChange,
collapsedKeys,
defaultCollapsedKeys,
onToggle,
nodeTemplate,
'aria-label': ariaLabel = 'Organization chart',
className,
}: OrganizationChartProps) {
const [innerCollapsed, setInnerCollapsed] = useState<string[]>(defaultCollapsedKeys ?? []);
const [innerValue, setInnerValue] = useState<string | null>(null);
const cardRefs = useRef(new Map<string, HTMLDivElement>());
const collapsed = useMemo(() => new Set(collapsedKeys ?? innerCollapsed), [collapsedKeys, innerCollapsed]);
const selected = value !== undefined ? value : innerValue;
// Parent and siblings per key, for arrow-key moves across the structure.
const index = useMemo(() => {
const map = new Map<string, { node: OrgChartNode; parent: string | null; siblings: OrgChartNode[] }>();
const walk = (ns: OrgChartNode[], parent: string | null) =>
ns.forEach((n) => {
map.set(n.key, { node: n, parent, siblings: ns });
if (n.children) walk(n.children, n.key);
});
walk(nodes, null);
return map;
}, [nodes]);
const toggleCollapse = (key: string) => {
const next = new Set(collapsed);
if (next.has(key)) next.delete(key);
else next.add(key);
const list = [...next];
if (collapsedKeys === undefined) setInnerCollapsed(list);
onToggle?.(list);
};
const toggleSelect = (n: OrgChartNode) => {
if (!selectable) return;
const next = selected === n.key ? null : n.key;
if (value === undefined) setInnerValue(next);
onChange?.(next, next ? n : null);
};
const onCardKey = (e: KeyboardEvent<HTMLDivElement>, n: OrgChartNode) => {
// Keys from inside a `nodeTemplate` (a link, a button) bubble up here;
// they belong to that control, not to card navigation.
if (e.target !== e.currentTarget) return;
const entry = index.get(n.key)!;
const at = entry.siblings.indexOf(n);
let target: string | undefined;
switch (e.key) {
case 'Enter':
case ' ':
if (!selectable) return;
toggleSelect(n);
break;
case 'ArrowDown':
target = !collapsed.has(n.key) ? n.children?.[0]?.key : undefined;
break;
case 'ArrowUp':
target = entry.parent ?? undefined;
break;
case 'ArrowLeft':
target = entry.siblings[at - 1]?.key;
break;
case 'ArrowRight':
target = entry.siblings[at + 1]?.key;
break;
default:
return;
}
e.preventDefault();
if (target) {
// `inline: 'nearest'` scrolls the chart's own box sideways to reveal a
// peer that was off-screen, rather than leaving focus somewhere unseen.
const el = cardRefs.current.get(target);
el?.focus({ preventScroll: true });
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
};
const renderCard = (n: OrgChartNode) => {
const isSelected = selectable && selected === n.key;
return (
<div
ref={(el) => {
if (el) cardRefs.current.set(n.key, el);
else cardRefs.current.delete(n.key);
}}
tabIndex={0}
role={selectable ? 'button' : undefined}
aria-pressed={selectable ? isSelected : undefined}
aria-label={nodeTemplate ? n.label : undefined}
onClick={() => toggleSelect(n)}
onKeyDown={(e) => onCardKey(e, n)}
className={cn(
'panel panel-solid relative min-w-36 max-w-52 border-t-[3px] px-3 py-2.5 text-left outline-none transition-shadow',
TONE[n.tone ?? 'neutral'],
selectable && 'cursor-pointer',
// Selection is a solid ring with an offset; focus a lighter one
// without. Both can show at once and still read as two things.
isSelected
? 'ring-2 ring-indigo-500 ring-offset-2 ring-offset-white dark:ring-indigo-400 dark:ring-offset-slate-900'
: 'focus-visible:ring-2 focus-visible:ring-indigo-400',
)}
>
{nodeTemplate ? (
nodeTemplate(n)
) : (
<div className="flex items-center gap-2">
{n.avatar &&
(isUrl(n.avatar) ? (
<Avatar name={n.label} email={n.label} avatarUrl={n.avatar} size="md" />
) : (
<Avatar name={n.avatar} email={n.label} size="md" />
))}
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-slate-800 dark:text-slate-100">{n.label}</p>
{n.title && <p className="truncate text-[11px] text-slate-500 dark:text-slate-400">{n.title}</p>}
</div>
</div>
)}
</div>
);
};
const renderSubtree = (n: OrgChartNode): ReactNode => {
const kids = n.children ?? [];
const open = kids.length > 0 && !collapsed.has(n.key);
return (
<div className="flex flex-col items-center">
{renderCard(n)}
{kids.length > 0 && (
<>
<span aria-hidden className={cn('h-2.5 w-px', LINE)} />
<button
type="button"
onClick={() => toggleCollapse(n.key)}
aria-expanded={open}
aria-label={`${open ? 'Collapse' : 'Expand'} ${n.label} (${kids.length} ${kids.length === 1 ? 'report' : 'reports'})`}
className="inline-flex h-4 min-w-4 items-center justify-center gap-0.5 rounded-full border border-slate-300 bg-white px-1 text-[10px] font-semibold leading-none text-slate-500 transition-colors hover:border-indigo-400 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-400 dark:hover:border-indigo-400 dark:hover:text-indigo-300"
>
{open ? (
<Minus aria-hidden className="h-2.5 w-2.5" strokeWidth={3} />
) : (
<>
<Plus aria-hidden className="h-2.5 w-2.5" strokeWidth={3} />
{kids.length}
</>
)}
</button>
</>
)}
{open && (
<>
<span aria-hidden className={cn('h-2.5 w-px', LINE)} />
<ul className="flex">
{kids.map((c, i) => (
<li key={c.key} className="relative flex flex-col items-center px-2 pt-4">
{i > 0 && <span aria-hidden className={cn('absolute left-0 right-1/2 top-0 h-px', LINE)} />}
{i < kids.length - 1 && <span aria-hidden className={cn('absolute left-1/2 right-0 top-0 h-px', LINE)} />}
<span aria-hidden className={cn('absolute left-1/2 top-0 h-4 w-px', LINE)} />
{renderSubtree(c)}
</li>
))}
</ul>
</>
)}
</div>
);
};
return (
// Its OWN horizontal scroller, which the kit otherwise avoids because an
// `overflow-x-auto` box clips any popover opened inside it. Nothing in a
// chart opens one — a card is a card, the badge toggles in place — so the
// usual cost does not apply, and a wide chart scrolling the whole page
// sideways would be worse. A `nodeTemplate` that adds a dropdown should
// portal it.
//
// `w-max min-w-full` + `justify-center` centres a narrow chart and lets a
// wide one start at the left edge. Centring on the scroller itself would
// push a wide chart's left half to negative scroll, where it cannot be reached.
<div className={cn('overflow-x-auto scrollbar-thin', className)}>
<ul aria-label={ariaLabel} className="flex w-max min-w-full justify-center gap-8 p-3">
{nodes.map((n) => (
<li key={n.key}>{renderSubtree(n)}</li>
))}
</ul>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
nodes* | OrgChartNode[] | — | Usually one root; several draw side by side with no connector between them. |
selectable | boolean | false | Makes cards pressable. Selection is single; pressing the selected card again clears it. |
value | string | null | — | |
onChange | (key: string | null, node: OrgChartNode | null) => void | — | |
collapsedKeys | string[] | — | Controlled collapse; pair with `onToggle`. |
defaultCollapsedKeys | string[] | — | |
onToggle | (collapsedKeys: string[]) => void | — | |
nodeTemplate | (node: OrgChartNode) => ReactNode | — | Replaces the card's contents. The card shell — focus, selection ring, connectors — is kept. |
className | string | — |