UIPackage
Menu

Framework

Change language

Boilerplate repo

Employee Org Chart Tree

blockhr

Interactive organizational hierarchy tree with manager reporting lines, team counts, multi-level expandable branches, department filter pills, employee search, and an employee profile quick info drawer.

Also available for Vue ->

Installation

$npx shadcn@latest add https://uipkge.dev/r/react/employee-org-chart-tree.json
Named registry:npx shadcn@latest add @uipkge-react/employee-org-chart-treeInstalls to:components/blocks/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
titlestringoptional
subtitlestringoptional
orgDataEmployeeNodeoptional
initialSelectedIdstringoptional
initialDepartmentstringoptional
initialSearchstringoptional
initialExpandedIdsstring[]optional
initialDrawerOpenbooleanoptional
classNamestringoptional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

DepartmentCount
interface DepartmentCount {
  label: string
  value: string
  count: number
}
EmployeeNode
interface EmployeeNode {
  id: string
  name: string
  role: string
  department: 'Executive' | 'Engineering' | 'Design' | 'Product' | 'Operations'
  email: string
  phone: string
  location: string
  avatar: string
  initials: string
  reportsCount: number
  teamHeadcount: number
  managerId?: string
  managerName?: string
  managerRole?: string
  startDate: string
  tenure: string
  bio: string
  skills: string[]
  status: 'active' | 'on-leave'
  children?: EmployeeNode[]
}

Files installed (6)

  • components/blocks/EmployeeOrgChartTree.tsx14.4 kB
    'use client'
    
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    import { EmployeeProfileDrawer } from './EmployeeProfileDrawer'
    import { OrgChartNodeCard, getDeptBadgeClasses } from './OrgChartNodeCard'
    import { OrgChartToolbar } from './OrgChartToolbar'
    import { DEFAULT_ORG_DATA } from './org-chart-data'
    import type { EmployeeNode } from './org-chart-types'
    
    export type { EmployeeNode }
    
    export interface EmployeeOrgChartTreeProps {
      title?: string
      subtitle?: string
      orgData?: EmployeeNode
      initialSelectedId?: string
      initialDepartment?: string
      initialSearch?: string
      initialExpandedIds?: string[]
      initialDrawerOpen?: boolean
      className?: string
    }
    
    function flatten(node: EmployeeNode): EmployeeNode[] {
      const result: EmployeeNode[] = [node]
      if (node.children) {
        for (const child of node.children) {
          result.push(...flatten(child))
        }
      }
      return result
    }
    
    export function EmployeeOrgChartTree({
      title = 'Company Organizational Chart',
      subtitle = '148 Employees across 5 Departments',
      orgData = DEFAULT_ORG_DATA,
      initialSelectedId = 'emp-ceo',
      initialDepartment = 'All',
      initialSearch = '',
      initialExpandedIds = ['emp-ceo', 'emp-eng-vp', 'emp-prod-vp', 'emp-ops-vp'],
      initialDrawerOpen = true,
      className,
    }: EmployeeOrgChartTreeProps) {
      const canvasRef = React.useRef<HTMLDivElement | null>(null)
    
      React.useEffect(() => {
        const el = canvasRef.current
        if (el) el.scrollLeft = Math.max(0, (el.scrollWidth - el.clientWidth) / 2)
      }, [])
    
      const allEmployees = React.useMemo(() => flatten(orgData), [orgData])
    
      const [selectedEmployeeId, setSelectedEmployeeId] = React.useState<string>(initialSelectedId)
      const [selectedDepartment, setSelectedDepartment] = React.useState<string>(initialDepartment)
      const [searchQuery, setSearchQuery] = React.useState<string>(initialSearch)
      const [isDrawerOpen, setIsDrawerOpen] = React.useState<boolean>(initialDrawerOpen)
      const [zoomLevel, setZoomLevel] = React.useState<number>(100)
      const [emailCopied, setEmailCopied] = React.useState<boolean>(false)
      const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set(initialExpandedIds))
    
      const departmentCounts = React.useMemo(
        () => [
          { label: 'All', value: 'All', count: allEmployees.length },
          {
            label: 'Executive',
            value: 'Executive',
            count: allEmployees.filter((e) => e.department === 'Executive').length,
          },
          {
            label: 'Engineering',
            value: 'Engineering',
            count: allEmployees.filter((e) => e.department === 'Engineering').length,
          },
          { label: 'Design', value: 'Design', count: allEmployees.filter((e) => e.department === 'Design').length },
          { label: 'Product', value: 'Product', count: allEmployees.filter((e) => e.department === 'Product').length },
          {
            label: 'Operations',
            value: 'Operations',
            count: allEmployees.filter((e) => e.department === 'Operations').length,
          },
        ],
        [allEmployees],
      )
    
      const selectedEmployee = React.useMemo(() => {
        return allEmployees.find((e) => e.id === selectedEmployeeId) || orgData
      }, [allEmployees, selectedEmployeeId, orgData])
    
      const matchingEmployeeIds = React.useMemo(() => {
        const q = searchQuery.trim().toLowerCase()
        if (!q) return new Set<string>()
    
        const matches = new Set<string>()
        for (const emp of allEmployees) {
          if (
            emp.name.toLowerCase().includes(q) ||
            emp.role.toLowerCase().includes(q) ||
            emp.department.toLowerCase().includes(q) ||
            emp.location.toLowerCase().includes(q) ||
            emp.skills.some((s) => s.toLowerCase().includes(q))
          ) {
            matches.add(emp.id)
          }
        }
        return matches
      }, [allEmployees, searchQuery])
    
      React.useEffect(() => {
        if (searchQuery.trim()) {
          const allParentIds = allEmployees.filter((e) => e.children && e.children.length > 0).map((e) => e.id)
          setExpandedIds(new Set(allParentIds))
        }
      }, [searchQuery, allEmployees])
    
      const isExpanded = (id: string): boolean => expandedIds.has(id)
    
      const toggleExpand = (id: string) => {
        setExpandedIds((prev) => {
          const next = new Set(prev)
          if (next.has(id)) {
            next.delete(id)
          } else {
            next.add(id)
          }
          return next
        })
      }
    
      const expandAll = () => {
        const allParentIds = allEmployees.filter((e) => e.children && e.children.length > 0).map((e) => e.id)
        setExpandedIds(new Set(allParentIds))
      }
    
      const collapseAll = () => {
        setExpandedIds(new Set())
      }
    
      const selectEmployee = (emp: EmployeeNode) => {
        setSelectedEmployeeId(emp.id)
        setIsDrawerOpen(true)
      }
    
      const selectSelectedManager = () => {
        const manager = allEmployees.find((e) => e.id === selectedEmployee?.managerId)
        if (manager) selectEmployee(manager)
      }
    
      const copyEmail = (email: string) => {
        navigator.clipboard.writeText(email)
        setEmailCopied(true)
        setTimeout(() => {
          setEmailCopied(false)
        }, 2000)
      }
    
      const adjustZoom = (delta: number) => {
        setZoomLevel((prev) => Math.min(130, Math.max(70, prev + delta)))
      }
    
      const resetZoom = () => {
        setZoomLevel(100)
      }
    
      const isNodeDimmed = (node: EmployeeNode): boolean => {
        if (selectedDepartment !== 'All') {
          if (selectedDepartment === 'Executive' && node.department !== 'Executive') return true
          if (selectedDepartment === 'Engineering' && node.department !== 'Engineering' && node.department !== 'Executive')
            return true
          if (selectedDepartment === 'Design' && node.department !== 'Design' && node.department !== 'Executive')
            return true
          if (selectedDepartment === 'Product' && node.department !== 'Product' && node.department !== 'Executive')
            return true
          if (selectedDepartment === 'Operations' && node.department !== 'Operations' && node.department !== 'Executive')
            return true
        }
    
        if (searchQuery.trim().length > 0) {
          const isDirectMatch = matchingEmployeeIds.has(node.id)
          const hasMatchingDescendant = flatten(node).some((n) => matchingEmployeeIds.has(n.id))
          return !isDirectMatch && !hasMatchingDescendant
        }
    
        return false
      }
    
      const isNodeHighlighted = (node: EmployeeNode): boolean => {
        if (searchQuery.trim().length > 0) {
          return matchingEmployeeIds.has(node.id)
        }
        return false
      }
    
      return (
        <div
          data-slot="employee-org-chart-tree"
          className={cn('bg-background border-border flex flex-col rounded-xl border shadow-xs', className)}
        >
          {/* Header & Toolbar Controls */}
          <OrgChartToolbar
            title={title}
            subtitle={subtitle}
            searchQuery={searchQuery}
            isDrawerOpen={isDrawerOpen}
            departmentCounts={departmentCounts}
            selectedDepartment={selectedDepartment}
            matchingCount={matchingEmployeeIds.size}
            zoomLevel={zoomLevel}
            onSearchChange={setSearchQuery}
            onToggleDrawer={() => setIsDrawerOpen((prev) => !prev)}
            onDepartmentSelect={setSelectedDepartment}
            onExpandAll={expandAll}
            onCollapseAll={collapseAll}
            onAdjustZoom={adjustZoom}
            onResetZoom={resetZoom}
          />
    
          {/* Main Content: Canvas & Quick Info Drawer */}
          <div className="relative flex flex-1 flex-col lg:flex-row">
            {/* Tree Hierarchy Node Canvas */}
            <div ref={canvasRef} className="bg-muted/10 relative flex-1 overflow-x-auto overflow-y-visible p-6 md:p-10">
              <div
                className="flex min-w-max flex-col items-center transition-transform duration-200"
                style={{ transform: `scale(${zoomLevel / 100})`, transformOrigin: 'top center' }}
              >
                {/* LEVEL 0: CEO Node */}
                <div className="flex flex-col items-center">
                  <OrgChartNodeCard
                    node={orgData}
                    isSelected={selectedEmployeeId === orgData.id}
                    isHighlighted={isNodeHighlighted(orgData)}
                    isDimmed={isNodeDimmed(orgData)}
                    isExpanded={isExpanded(orgData.id)}
                    reportsLabel="Direct reports: 3 VPs"
                    headcountLabel={`${orgData.teamHeadcount} total org`}
                    expandLabel="Expand Executive Team"
                    collapseLabel="Collapse Executive Team"
                    onSelect={selectEmployee}
                    onToggleExpand={toggleExpand}
                  />
    
                  {/* Connector stem down from CEO */}
                  {isExpanded(orgData.id) && Boolean(orgData.children?.length) && <div className="bg-border h-8 w-px" />}
    
                  {/* LEVEL 1: VPs and Directors */}
                  {isExpanded(orgData.id) && Boolean(orgData.children?.length) && (
                    <div className="relative flex items-start gap-10 pt-0">
                      {/* Horizontal branching rail connecting Level 1 nodes */}
                      {(orgData.children?.length ?? 0) > 1 && (
                        <div className="bg-border absolute top-0 right-[16.666%] left-[16.666%] h-px" />
                      )}
    
                      {/* Iterate Level 1 VPs */}
                      {orgData.children?.map((vp) => (
                        <div key={vp.id} className="flex flex-col items-center">
                          {/* Top stem connecting rail to VP card */}
                          <div className="bg-border h-8 w-px" />
    
                          {/* VP Node Card */}
                          <OrgChartNodeCard
                            node={vp}
                            isSelected={selectedEmployeeId === vp.id}
                            isHighlighted={isNodeHighlighted(vp)}
                            isDimmed={isNodeDimmed(vp)}
                            isExpanded={isExpanded(vp.id)}
                            reportsLabel={`Direct reports: ${vp.reportsCount} teams`}
                            headcountLabel={`${vp.teamHeadcount} staff`}
                            expandLabel={`Expand (${vp.children?.length || 0} Leads)`}
                            collapseLabel="Collapse Branch"
                            onSelect={selectEmployee}
                            onToggleExpand={toggleExpand}
                          />
    
                          {/* Connector down from VP */}
                          {isExpanded(vp.id) && Boolean(vp.children?.length) && <div className="bg-border h-8 w-px" />}
    
                          {/* LEVEL 2: Staff Leads & Managers */}
                          {isExpanded(vp.id) && Boolean(vp.children?.length) && (
                            <div className="relative flex items-start gap-6 pt-0">
                              {/* Horizontal rail for Level 2 nodes */}
                              {vp.children?.length === 3 && (
                                <div className="bg-border absolute top-0 right-[16.666%] left-[16.666%] h-px" />
                              )}
                              {vp.children?.length === 2 && (
                                <div className="bg-border absolute top-0 right-[25%] left-[25%] h-px" />
                              )}
    
                              {/* Iterate Level 2 Leads */}
                              {vp.children?.map((lead) => (
                                <div key={lead.id} className="flex flex-col items-center">
                                  <div className="bg-border h-8 w-px" />
    
                                  {/* Lead Node Card */}
                                  <OrgChartNodeCard
                                    node={lead}
                                    isSelected={selectedEmployeeId === lead.id}
                                    isHighlighted={isNodeHighlighted(lead)}
                                    isDimmed={isNodeDimmed(lead)}
                                    isExpanded={isExpanded(lead.id)}
                                    reportsLabel={`${lead.reportsCount} reports`}
                                    headcountLabel={`${lead.teamHeadcount} members`}
                                    expandLabel={`Team (${lead.children?.length || 0})`}
                                    collapseLabel="Collapse"
                                    onSelect={selectEmployee}
                                    onToggleExpand={toggleExpand}
                                  />
    
                                  {/* Connector down from Lead */}
                                  {isExpanded(lead.id) && Boolean(lead.children?.length) && (
                                    <div className="bg-border h-8 w-px" />
                                  )}
    
                                  {/* LEVEL 3: Team ICs / Senior Contributors */}
                                  {isExpanded(lead.id) && Boolean(lead.children?.length) && (
                                    <div className="relative flex items-start gap-4 pt-0">
                                      {lead.children?.length === 2 && (
                                        <div className="bg-border absolute top-0 right-[25%] left-[25%] h-px" />
                                      )}
    
                                      {/* Iterate Level 3 ICs */}
                                      {lead.children?.map((ic) => (
                                        <div key={ic.id} className="flex flex-col items-center">
                                          <div className="bg-border h-8 w-px" />
    
                                          {/* IC Card */}
                                          <OrgChartNodeCard
                                            node={ic}
                                            isSelected={selectedEmployeeId === ic.id}
                                            isHighlighted={isNodeHighlighted(ic)}
                                            isDimmed={isNodeDimmed(ic)}
                                            reportsLabel={`${ic.reportsCount} direct`}
                                            headcountLabel={`${ic.teamHeadcount} member`}
                                            onSelect={selectEmployee}
                                          />
                                        </div>
                                      ))}
                                    </div>
                                  )}
                                </div>
                              ))}
                            </div>
                          )}
                        </div>
                      ))}
                    </div>
                  )}
                </div>
              </div>
            </div>
    
            {/* Quick Info Side Profile Drawer */}
            {isDrawerOpen && (
              <EmployeeProfileDrawer
                employee={selectedEmployee}
                emailCopied={emailCopied}
                onCopyEmail={copyEmail}
                getDeptBadgeClasses={getDeptBadgeClasses}
                onClose={() => setIsDrawerOpen(false)}
                onSelectEmployee={selectEmployee}
                onViewManager={selectSelectedManager}
              />
            )}
          </div>
        </div>
      )
    }
    
  • components/blocks/EmployeeProfileDrawer.tsx8.9 kB
  • components/blocks/OrgChartToolbar.tsx6.6 kB
  • components/blocks/OrgChartNodeCard.tsx5.6 kB
  • components/blocks/org-chart-types.ts1.4 kB
  • components/blocks/org-chart-data.ts23.2 kB

Raw manifest:https://uipkge.dev/r/react/employee-org-chart-tree.json