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 slot.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/organization-chart.json
Named registry: npx shadcn-vue@latest add @uipkge/organization-chart Installs to: app/components/ui/organization-chart/

Examples

Props

Name Type / Values Default Required
data OrgNode required
direction
'top-down''left-right'
'top-down' optional
defaultExpanded boolean true optional
showConnectors boolean true optional
zoomable boolean false optional
class HTMLAttributes['class'] 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 (5)

  • app/components/ui/organization-chart/OrganizationChart.vue 4.2 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, ref, watch } from 'vue'
    import { cn } from '@/lib/utils'
    import { organizationChartVariants } from './organization-chart.variants'
    import OrgChartNode from './OrgChartNode.vue'
    import type { OrgNode } from './types'
    
    interface Props {
      data: OrgNode
      direction?: 'top-down' | 'left-right'
      defaultExpanded?: boolean
      showConnectors?: boolean
      zoomable?: boolean
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      direction: 'top-down',
      defaultExpanded: true,
      showConnectors: true,
      zoomable: false,
    })
    
    const emit = defineEmits<{
      nodeClick: [node: OrgNode]
      toggle: [node: OrgNode, expanded: boolean]
    }>()
    
    const expanded = ref<Set<string>>(new Set())
    
    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
    }
    
    function defaultExpand() {
      if (props.defaultExpanded) {
        expanded.value = new Set(collectIds(props.data))
      } else {
        expanded.value = new Set([props.data.id])
      }
    }
    
    watch(
      () => [props.data, props.defaultExpanded],
      () => defaultExpand(),
      { immediate: true },
    )
    
    function toggleNode(node: OrgNode) {
      const next = new Set(expanded.value)
      if (next.has(node.id)) next.delete(node.id)
      else next.add(node.id)
      expanded.value = next
      emit('toggle', node, next.has(node.id))
    }
    
    function isExpanded(node: OrgNode): boolean {
      return expanded.value.has(node.id)
    }
    
    function expandAll() {
      expanded.value = new Set(collectIds(props.data))
    }
    
    function collapseAll() {
      expanded.value = new Set([props.data.id])
    }
    
    const zoom = ref(1)
    function zoomIn() {
      zoom.value = Math.min(2, zoom.value + 0.1)
    }
    function zoomOut() {
      zoom.value = Math.max(0.5, zoom.value - 0.1)
    }
    function resetZoom() {
      zoom.value = 1
    }
    
    const containerStyle = computed(() => ({
      transform: `scale(${zoom.value})`,
      transformOrigin: 'top center',
    }))
    
    defineExpose({ expandAll, collapseAll, zoomIn, zoomOut, resetZoom })
    </script>
    
    <template>
      <div
        data-uipkge
        data-slot="organization-chart"
        :data-direction="direction"
        :class="cn(organizationChartVariants(), props.class)"
      >
        <div v-if="zoomable" class="border-border flex items-center gap-2 border-b px-3 py-2">
          <button
            type="button"
            class="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"
            @click="zoomOut"
          >
    
          </button>
          <span class="text-muted-foreground w-12 text-center text-xs tabular-nums">{{ Math.round(zoom * 100) }}%</span>
          <button
            type="button"
            class="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"
            @click="zoomIn"
          >
            +
          </button>
          <button
            type="button"
            class="text-muted-foreground hover:text-foreground hover:bg-accent ml-1 rounded-md px-2 py-1 text-xs"
            aria-label="Reset zoom"
            @click="resetZoom"
          >
            Reset
          </button>
          <div class="ml-auto flex gap-1">
            <button
              type="button"
              class="text-muted-foreground hover:text-foreground hover:bg-accent rounded-md px-2 py-1 text-xs"
              @click="expandAll"
            >
              Expand all
            </button>
            <button
              type="button"
              class="text-muted-foreground hover:text-foreground hover:bg-accent rounded-md px-2 py-1 text-xs"
              @click="collapseAll"
            >
              Collapse all
            </button>
          </div>
        </div>
        <div class="overflow-auto p-4">
          <div :style="containerStyle" class="transition-transform duration-200">
            <OrgChartNode
              :node="data"
              :depth="0"
              :is-root="true"
              :direction="direction"
              :show-connectors="showConnectors"
              :is-expanded="isExpanded"
              :toggle="toggleNode"
              @node-click="(n) => emit('nodeClick', n)"
            >
              <template #node="{ node }">
                <slot name="node" :node="node" />
              </template>
            </OrgChartNode>
          </div>
        </div>
      </div>
    </template>
  • app/components/ui/organization-chart/OrgChartNode.vue 8.8 kB
    <script setup lang="ts">
    import { computed, defineAsyncComponent } from 'vue'
    import { ChevronDown, ChevronRight } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import type { OrgNode } from './types'
    
    // Self-reference for recursive rendering
    const OrgChartNode = defineAsyncComponent(() => import('./OrgChartNode.vue'))
    
    interface Props {
      node: OrgNode
      depth: number
      isRoot?: boolean
      direction?: 'top-down' | 'left-right'
      showConnectors?: boolean
      isExpanded: (node: OrgNode) => boolean
      toggle: (node: OrgNode) => void
    }
    
    const props = withDefaults(defineProps<Props>(), {
      isRoot: false,
      direction: 'top-down',
      showConnectors: true,
    })
    
    const emit = defineEmits<{
      nodeClick: [node: OrgNode]
    }>()
    
    const open = computed(() => props.isExpanded(props.node))
    const hasChildren = computed(() => !!props.node.children?.length)
    const isHorizontal = computed(() => props.direction === 'left-right')
    const childCount = computed(() => props.node.children?.length ?? 0)
    const isOnlyChild = computed(() => childCount.value <= 1)
    
    function onClick() {
      emit('nodeClick', props.node)
    }
    
    function onToggle(e: Event) {
      e.stopPropagation()
      if (hasChildren.value) props.toggle(props.node)
    }
    
    function initials(name: string): string {
      return name.split(' ').map((p) => p[0]).slice(0, 2).join('').toUpperCase()
    }
    </script>
    
    <template>
      <!-- ══ Top-down (vertical) layout ══ -->
      <div v-if="!isHorizontal" class="org-v" :data-root="isRoot ? '' : undefined">
        <!-- Node card -->
        <div class="org-v-card">
          <div
            class="bg-card hover:bg-accent/50 border-border group relative flex w-52 cursor-pointer flex-col rounded-lg border p-3 shadow-xs transition-colors"
            :class="isRoot ? 'ring-primary/20 ring-2' : ''"
            @click="onClick"
          >
            <div class="flex items-center gap-2.5">
              <img
                v-if="node.avatar"
                :src="node.avatar"
                :alt="node.name"
                class="border-border size-10 shrink-0 rounded-full border object-cover"
              />
              <div
                v-else
                class="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 class="min-w-0 flex-1">
                <p class="truncate text-sm font-semibold">{{ node.name }}</p>
                <p v-if="node.title" class="text-muted-foreground truncate text-xs">{{ node.title }}</p>
              </div>
              <button
                v-if="hasChildren"
                type="button"
                class="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'"
                @click="onToggle"
              >
                <ChevronDown v-if="open" class="size-3.5" />
                <ChevronRight v-else class="size-3.5" />
              </button>
            </div>
            <slot name="node" :node="node" />
          </div>
        </div>
    
        <!-- Children -->
        <div v-if="hasChildren && open" class="org-v-children">
          <!-- Vertical line from parent to horizontal sibling bar -->
          <div v-if="showConnectors" class="org-v-line-down" />
          <div class="org-v-children-row" :data-single="isOnlyChild ? '' : undefined">
            <!-- Horizontal bar connecting siblings (only for 2+ children) -->
            <div v-if="showConnectors && !isOnlyChild" class="org-v-line-across" />
            <OrgChartNode
              v-for="child in node.children"
              :key="child.id"
              :node="child"
              :depth="depth + 1"
              :is-root="false"
              :direction="direction"
              :show-connectors="showConnectors"
              :is-expanded="isExpanded"
              :toggle="toggle"
              @node-click="(n) => emit('nodeClick', n)"
            >
              <template #node="{ node: n }">
                <slot name="node" :node="n" />
              </template>
            </OrgChartNode>
          </div>
        </div>
      </div>
    
      <!-- ══ Left-right (horizontal) layout ══ -->
      <div v-else class="org-h" :data-root="isRoot ? '' : undefined">
        <div class="flex items-start">
          <!-- Node card -->
          <div class="org-h-card">
            <div
              class="bg-card hover:bg-accent/50 border-border group relative flex w-52 cursor-pointer flex-col rounded-lg border p-3 shadow-xs transition-colors"
              :class="isRoot ? 'ring-primary/20 ring-2' : ''"
              @click="onClick"
            >
              <div class="flex items-center gap-2.5">
                <img
                  v-if="node.avatar"
                  :src="node.avatar"
                  :alt="node.name"
                  class="border-border size-10 shrink-0 rounded-full border object-cover"
                />
                <div
                  v-else
                  class="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 class="min-w-0 flex-1">
                  <p class="truncate text-sm font-semibold">{{ node.name }}</p>
                  <p v-if="node.title" class="text-muted-foreground truncate text-xs">{{ node.title }}</p>
                </div>
                <button
                  v-if="hasChildren"
                  type="button"
                  class="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-5 shrink-0 items-center justify-center rounded"
                  @click="onToggle"
                >
                  <ChevronDown v-if="open" class="size-3.5" />
                  <ChevronRight v-else class="size-3.5" />
                </button>
              </div>
              <slot name="node" :node="node" />
            </div>
          </div>
    
          <!-- Children -->
          <template v-if="hasChildren && open">
            <div v-if="showConnectors" class="org-h-line-right" />
            <div class="org-h-children">
              <OrgChartNode
                v-for="child in node.children"
                :key="child.id"
                :node="child"
                :depth="depth + 1"
                :is-root="false"
                :direction="direction"
                :show-connectors="showConnectors"
                :is-expanded="isExpanded"
                :toggle="toggle"
                @node-click="(n) => emit('nodeClick', n)"
              >
                <template #node="{ node: n }">
                  <slot name="node" :node="n" />
                </template>
              </OrgChartNode>
            </div>
          </template>
        </div>
      </div>
    </template>
    
    <style scoped>
    /* ══ 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)));
    }
    </style>
  • app/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>
  • app/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
    }
  • app/components/ui/organization-chart/index.ts 0.3 kB
    export { default as OrganizationChart } from './OrganizationChart.vue'
    export { default as OrgChartNode } from './OrgChartNode.vue'
    export { organizationChartVariants, type OrganizationChartVariants } from './organization-chart.variants'
    export type { OrgNode } from './types'

Raw manifest: https://uipkge.dev/r/vue/organization-chart.json