UIPackage
Menu

Organization Chart

organization-chart ui
Edit on GitHub

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

$ npx 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
}

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