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 slot.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/organization-chart.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/organization-chart.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/organization-chart.json $ bunx 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
} npm dependencies
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