Organization Chart
organization-chart ui Organization hierarchy visualization with a tree of nodes (name, title, avatar). Expand/collapse branches, top-down or left-right direction, connector lines, optional zoom/pan controls, node click events, and a customizable node render prop.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://uipkge.dev/r/react/organization-chart.json $ npx shadcn@latest add https://uipkge.dev/r/react/organization-chart.json $ yarn dlx shadcn@latest add https://uipkge.dev/r/react/organization-chart.json $ bunx shadcn@latest add https://uipkge.dev/r/react/organization-chart.json Named registry:
npx shadcn@latest add @uipkge-react/organization-chart Installs to: components/ui/organization-chart/ Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
data | OrgNode | — | required |
direction | 'top-down' | 'left-right' | — | optional |
defaultExpanded | boolean | — | optional |
showConnectors | boolean | — | optional |
zoomable | boolean | — | optional |
onNodeClick | (node: OrgNode) => void | — | optional |
onToggle | (node: OrgNode, expanded: boolean) => void | — | optional |
renderNode | (node: OrgNode) => React.ReactNode | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
OrgNode interface OrgNode {
id: string
name: string
title?: string
avatar?: string
department?: string
children?: OrgNode[]
[key: string]: unknown
} npm dependencies
Files installed (6)
-
components/ui/organization-chart/OrganizationChart.tsx 5.2 kB
import * as React from 'react' import { cn } from '@/lib/utils' import { organizationChartVariants } from './organization-chart.variants' import { OrgChartNode } from './OrgChartNode' import type { OrgNode } from './types' import './organization-chart.css' export interface OrganizationChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick'> { data: OrgNode direction?: 'top-down' | 'left-right' defaultExpanded?: boolean showConnectors?: boolean zoomable?: boolean onNodeClick?: (node: OrgNode) => void onToggle?: (node: OrgNode, expanded: boolean) => void renderNode?: (node: OrgNode) => React.ReactNode } function collectIds(node: OrgNode, acc: string[] = []): string[] { acc.push(node.id) if (node.children) for (const c of node.children) collectIds(c, acc) return acc } const OrganizationChart = React.forwardRef<HTMLDivElement, OrganizationChartProps>( ( { data, direction = 'top-down', defaultExpanded = true, showConnectors = true, zoomable = false, className, onNodeClick, onToggle, renderNode, ...props }, ref, ) => { const [expanded, setExpanded] = React.useState<Set<string>>(() => { if (defaultExpanded) return new Set(collectIds(data)) return new Set([data.id]) }) const [zoom, setZoom] = React.useState(1) // Re-sync expanded state when data or defaultExpanded changes React.useEffect(() => { if (defaultExpanded) { setExpanded(new Set(collectIds(data))) } else { setExpanded(new Set([data.id])) } }, [data, defaultExpanded]) function toggleNode(node: OrgNode) { setExpanded((prev) => { const next = new Set(prev) if (next.has(node.id)) next.delete(node.id) else next.add(node.id) onToggle?.(node, next.has(node.id)) return next }) } function isExpanded(node: OrgNode): boolean { return expanded.has(node.id) } function expandAll() { setExpanded(new Set(collectIds(data))) } function collapseAll() { setExpanded(new Set([data.id])) } function zoomIn() { setZoom((z) => Math.min(2, z + 0.1)) } function zoomOut() { setZoom((z) => Math.max(0.5, z - 0.1)) } function resetZoom() { setZoom(1) } // Imperative handle mirroring Vue's defineExpose React.useImperativeHandle(ref, () => ({ expandAll, collapseAll, zoomIn, zoomOut, resetZoom, })) const containerStyle = React.useMemo( () => ({ transform: `scale(${zoom})`, transformOrigin: 'top center', }), [zoom], ) return ( <div data-uipkge="" data-slot="organization-chart" data-direction={direction} className={cn(organizationChartVariants(), className)} {...props} > {zoomable && ( <div className="border-border flex items-center gap-2 border-b px-3 py-2"> <button type="button" className="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-7 items-center justify-center rounded-md text-sm" aria-label="Zoom out" onClick={zoomOut} > − </button> <span className="text-muted-foreground w-12 text-center text-xs tabular-nums"> {Math.round(zoom * 100)}% </span> <button type="button" className="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-7 items-center justify-center rounded-md text-sm" aria-label="Zoom in" onClick={zoomIn} > + </button> <button type="button" className="text-muted-foreground hover:text-foreground hover:bg-accent ml-1 rounded-md px-2 py-1 text-xs" aria-label="Reset zoom" onClick={resetZoom} > Reset </button> <div className="ml-auto flex gap-1"> <button type="button" className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-md px-2 py-1 text-xs" onClick={expandAll} > Expand all </button> <button type="button" className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-md px-2 py-1 text-xs" onClick={collapseAll} > Collapse all </button> </div> </div> )} <div className="overflow-auto p-4"> <div style={containerStyle} className="transition-transform duration-200"> <OrgChartNode node={data} depth={0} isRoot direction={direction} showConnectors={showConnectors} isExpanded={isExpanded} toggle={toggleNode} onNodeClick={onNodeClick} renderNode={renderNode} /> </div> </div> </div> ) }, ) OrganizationChart.displayName = 'OrganizationChart' export { OrganizationChart } -
components/ui/organization-chart/OrgChartNode.tsx 5.3 kB
import * as React from 'react' import { ChevronDown, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import type { OrgNode } from './types' export interface OrgChartNodeProps { node: OrgNode depth: number isRoot?: boolean direction?: 'top-down' | 'left-right' showConnectors?: boolean isExpanded: (node: OrgNode) => boolean toggle: (node: OrgNode) => void onNodeClick?: (node: OrgNode) => void renderNode?: (node: OrgNode) => React.ReactNode } function initials(name: string): string { return name.split(' ').map((p) => p[0]).slice(0, 2).join('').toUpperCase() } const OrgChartNode = React.forwardRef<HTMLDivElement, OrgChartNodeProps>( ( { node, depth, isRoot = false, direction = 'top-down', showConnectors = true, isExpanded, toggle, onNodeClick, renderNode }, ref, ) => { const open = isExpanded(node) const hasChildren = !!node.children?.length const isHorizontal = direction === 'left-right' const childCount = node.children?.length ?? 0 const isOnlyChild = childCount <= 1 function onClick() { onNodeClick?.(node) } function onToggle(e: React.MouseEvent) { e.stopPropagation() if (hasChildren) toggle(node) } const cardClass = cn( 'group relative flex w-52 cursor-pointer flex-col rounded-lg border p-3 shadow-xs transition-colors', 'border-border bg-card hover:bg-accent/50', isRoot && 'ring-2 ring-primary/20', ) const avatarBlock = ( <> {node.avatar ? ( <img src={node.avatar} alt={node.name} className="border-border size-10 shrink-0 rounded-full border object-cover" /> ) : ( <div className="bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-semibold"> {initials(node.name)} </div> )} <div className="min-w-0 flex-1"> <p className="truncate text-sm font-semibold">{node.name}</p> {node.title && <p className="text-muted-foreground truncate text-xs">{node.title}</p>} </div> {hasChildren && ( <button type="button" className="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-5 shrink-0 items-center justify-center rounded" aria-expanded={open} aria-label={open ? 'Collapse' : 'Expand'} onClick={onToggle} > {open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} </button> )} </> ) // ══ Top-down (vertical) layout ══ if (!isHorizontal) { return ( <div ref={ref} className="org-v" data-root={isRoot ? '' : undefined}> {/* Node card */} <div className="org-v-card"> <div className={cardClass} onClick={onClick}> <div className="flex items-center gap-2.5"> {avatarBlock} </div> {renderNode?.(node)} </div> </div> {/* Children */} {hasChildren && open && ( <div className="org-v-children"> {showConnectors && <div className="org-v-line-down" />} <div className="org-v-children-row" data-single={isOnlyChild ? '' : undefined}> {showConnectors && !isOnlyChild && <div className="org-v-line-across" />} {node.children!.map((child) => ( <OrgChartNode key={child.id} node={child} depth={depth + 1} isRoot={false} direction={direction} showConnectors={showConnectors} isExpanded={isExpanded} toggle={toggle} onNodeClick={onNodeClick} renderNode={renderNode} /> ))} </div> </div> )} </div> ) } // ══ Left-right (horizontal) layout ══ return ( <div ref={ref} className="org-h" data-root={isRoot ? '' : undefined}> <div className="flex items-start"> {/* Node card */} <div className="org-h-card"> <div className={cardClass} onClick={onClick}> <div className="flex items-center gap-2.5"> {avatarBlock} </div> {renderNode?.(node)} </div> </div> {/* Children */} {hasChildren && open && ( <> {showConnectors && <div className="org-h-line-right" />} <div className="org-h-children"> {node.children!.map((child) => ( <OrgChartNode key={child.id} node={child} depth={depth + 1} isRoot={false} direction={direction} showConnectors={showConnectors} isExpanded={isExpanded} toggle={toggle} onNodeClick={onNodeClick} renderNode={renderNode} /> ))} </div> </> )} </div> </div> ) }, ) OrgChartNode.displayName = 'OrgChartNode' export { OrgChartNode } -
components/ui/organization-chart/organization-chart.variants.ts 0.3 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' export const organizationChartVariants = cva('bg-background rounded-lg border') export type OrganizationChartVariants = VariantProps<typeof organizationChartVariants> -
components/ui/organization-chart/organization-chart.css 2.4 kB
/* ══ Vertical (top-down) layout ══ */ .org-v { display: flex; flex-direction: column; align-items: center; } /* Vertical line from horizontal bar up to each child card */ .org-v:not([data-root]) .org-v-card { position: relative; padding-top: 20px; } .org-v:not([data-root]) .org-v-card::before { content: ''; position: absolute; top: 0; left: 50%; width: 1px; height: 20px; background: var(--color-border, hsl(var(--border))); } .org-v-children { display: flex; flex-direction: column; align-items: center; } /* Vertical line from parent down to the sibling bar */ .org-v-line-down { width: 1px; height: 20px; background: var(--color-border, hsl(var(--border))); } .org-v-children-row { display: flex; flex-direction: row; gap: 24px; position: relative; padding-top: 20px; } /* Horizontal bar: spans from the center of the first child to the center of the last child. We use a full-width bar with the first/last child vertical lines connecting to it. The bar itself is positioned using the half-width of the first and last cards (w-52 = 208px, half = 104px). */ .org-v-line-across { position: absolute; top: 0; left: 104px; /* half of w-52 (208px) — center of first child */ right: 104px; /* half of w-52 — center of last child */ height: 1px; background: var(--color-border, hsl(var(--border))); } /* When single child, no horizontal bar needed — just the vertical line */ .org-v-children-row[data-single] .org-v-line-across { display: none; } /* ══ Horizontal (left-right) layout ══ */ .org-h { display: flex; flex-direction: column; gap: 12px; } /* Horizontal line from parent to children column */ .org-h-line-right { width: 20px; height: 1px; background: var(--color-border, hsl(var(--border))); margin-top: 40px; flex-shrink: 0; } .org-h-children { display: flex; flex-direction: column; gap: 12px; position: relative; } /* Vertical line connecting siblings in horizontal mode */ .org-h-children::before { content: ''; position: absolute; left: 0; top: 40px; bottom: 40px; width: 1px; background: var(--color-border, hsl(var(--border))); } /* Horizontal line from vertical bar to each child */ .org-h:not([data-root]) .org-h-card { position: relative; padding-left: 20px; } .org-h:not([data-root]) .org-h-card::before { content: ''; position: absolute; top: 40px; left: 0; width: 20px; height: 1px; background: var(--color-border, hsl(var(--border))); } -
components/ui/organization-chart/types.ts 0.2 kB
export interface OrgNode { id: string name: string title?: string avatar?: string department?: string children?: OrgNode[] [key: string]: unknown } -
components/ui/organization-chart/index.ts 0.3 kB
export { OrganizationChart, type OrganizationChartProps } from './OrganizationChart' export { OrgChartNode, type OrgChartNodeProps } from './OrgChartNode' export { organizationChartVariants, type OrganizationChartVariants } from './organization-chart.variants' export type { OrgNode } from './types'
Raw manifest: https://uipkge.dev/r/react/organization-chart.json