Tree View
Vue data-displayIndented tree of expandable nodes — file browsers, taxonomy editors, nested settings. Discord-style elbow connectors, lazy-load branches, and full keyboard navigation.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-view.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-view.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-view.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-view.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/tree-view
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
items | TreeViewItem[] | — | required |
class | HTMLAttributes['class'] | — | optional |
showIcons | boolean | true | optional |
showCheckboxes | boolean | false | optional |
defaultExpanded | boolean | false | optional |
selectedId | string | null | null | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
TreeViewContext interface TreeViewContext {
expandedIds: Ref<Set<string>>
selectedId: Ref<string | null>
showIcons: Ref<boolean>
showCheckboxes: Ref<boolean>
toggle: (item: TreeViewItem) => void
select: (item: TreeViewItem) => void
} TreeViewItem interface TreeViewItem {
id: string
label: string
icon?: typeof File
children?: TreeViewItem[]
disabled?: boolean
selected?: boolean
expanded?: boolean
[key: string]: unknown
} Dependencies
Files (5)
-
app/components/ui/tree-view/TreeView.vue 1.9 kB
<script setup lang="ts"> import { onMounted, provide, ref, toRef, watch, type HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { TREE_VIEW_CONTEXT } from './context' import type { TreeViewItem } from './types' interface Props { items: TreeViewItem[] class?: HTMLAttributes['class'] showIcons?: boolean showCheckboxes?: boolean defaultExpanded?: boolean selectedId?: string | null } const props = withDefaults(defineProps<Props>(), { showIcons: true, showCheckboxes: false, defaultExpanded: false, selectedId: null, }) const emit = defineEmits<{ select: [item: TreeViewItem] toggle: [item: TreeViewItem] 'update:selectedId': [id: string | null] }>() const expandedIds = ref<Set<string>>(new Set()) const selectedId = ref<string | null>(props.selectedId) watch( () => props.selectedId, (val) => { selectedId.value = val }, ) function toggle(item: TreeViewItem) { if (item.disabled) return const next = new Set(expandedIds.value) if (next.has(item.id)) next.delete(item.id) else next.add(item.id) expandedIds.value = next emit('toggle', item) } function select(item: TreeViewItem) { if (item.disabled) return selectedId.value = item.id emit('select', item) emit('update:selectedId', item.id) } provide(TREE_VIEW_CONTEXT, { expandedIds, selectedId, showIcons: toRef(props, 'showIcons'), showCheckboxes: toRef(props, 'showCheckboxes'), toggle, select, }) onMounted(() => { if (!props.defaultExpanded) return const next = new Set<string>() const walk = (items: TreeViewItem[]) => { for (const it of items) { if (it.children?.length) { next.add(it.id) walk(it.children) } } } walk(props.items) expandedIds.value = next }) </script> <template> <div :class="cn('text-sm', props.class)" role="tree"> <TreeViewNode v-for="(item, i) in items" :key="item.id" :item="item" :depth="0" :is-last="i === items.length - 1" /> </div> </template> -
app/components/ui/tree-view/TreeViewNode.vue 4.5 kB
<script setup lang="ts"> import { computed, inject } from 'vue' import { ChevronRight, File, Folder, FolderOpen } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { TREE_VIEW_CONTEXT } from './context' import type { TreeViewItem } from './types' interface Props { item: TreeViewItem depth: number isLast?: boolean } const props = withDefaults(defineProps<Props>(), { isLast: false, }) const _maybeCtx = inject(TREE_VIEW_CONTEXT) if (!_maybeCtx) throw new Error('TreeViewNode must be used inside <TreeView>') const ctx: NonNullable<typeof _maybeCtx> = _maybeCtx const isExpanded = computed(() => ctx.expandedIds.value.has(props.item.id)) const isSelected = computed(() => ctx.selectedId.value === props.item.id) const hasChildren = computed(() => !!(props.item.children && props.item.children.length)) const Icon = computed(() => { if (!ctx.showIcons.value) return null if (props.item.icon) return props.item.icon if (!hasChildren.value) return File return isExpanded.value ? FolderOpen : Folder }) function handleToggle(e: Event) { e.stopPropagation() ctx.toggle(props.item) } function handleSelect() { if (props.item.disabled) return ctx.select(props.item) } // 20px per level. Connector lives in parent's gutter (depth - 1). const INDENT = 20 const rowPadLeft = computed(() => `${props.depth * INDENT + 4}px`) const connectorLeft = computed(() => `${(props.depth - 1) * INDENT + 10}px`) </script> <template> <div role="treeitem" :aria-expanded="hasChildren ? isExpanded : undefined" class="relative"> <!-- Discord-style elbow + trunk for non-root nodes. The elbow points from the parent's chevron column down to this row's center; the trunk continues to the next sibling at this depth (omitted on the last sibling). --> <span v-if="depth > 0" aria-hidden="true" class="border-border pointer-events-none absolute top-0 h-4 w-3 rounded-bl-md border-b border-l" :style="{ left: connectorLeft }" /> <span v-if="depth > 0 && !isLast" aria-hidden="true" class="bg-border pointer-events-none absolute top-4 bottom-0 w-px" :style="{ left: connectorLeft }" /> <!-- Row --> <div :class=" cn( 'group relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md pr-2 text-sm transition-colors', 'hover:bg-accent hover:text-accent-foreground', 'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none', item.disabled && 'cursor-not-allowed opacity-50', isSelected && 'bg-accent text-accent-foreground font-medium', ) " :style="{ paddingLeft: rowPadLeft }" :tabindex="item.disabled ? -1 : 0" role="button" @click="handleSelect" @keydown.enter.prevent="handleSelect" @keydown.space.prevent="handleSelect" > <!-- Chevron (or 16px spacer for leaves so labels align) --> <button v-if="hasChildren" type="button" :class=" cn( 'focus-visible:ring-ring flex size-4 shrink-0 items-center justify-center rounded transition-transform duration-150 focus-visible:ring-2 focus-visible:outline-none', 'hover:bg-foreground/10', isExpanded && 'rotate-90', ) " :aria-label="isExpanded ? 'Collapse' : 'Expand'" @click="handleToggle" > <ChevronRight class="text-muted-foreground size-3.5" aria-hidden="true" /> </button> <span v-else class="size-4 shrink-0" /> <!-- Checkbox --> <input v-if="ctx.showCheckboxes.value" type="checkbox" :checked="item.selected" :disabled="item.disabled" class="border-input bg-background text-primary focus:ring-ring focus-visible:ring-ring size-3.5 shrink-0 rounded focus:ring-1 focus-visible:ring-2 focus-visible:outline-none" @change="handleSelect" @click.stop /> <!-- Icon --> <component :is="Icon" v-if="Icon" :class="cn('size-4 shrink-0', hasChildren ? 'text-primary' : 'text-muted-foreground')" /> <!-- Label --> <span class="flex-1 truncate">{{ item.label }}</span> </div> <!-- Children --> <div v-if="hasChildren && isExpanded" role="group"> <TreeViewNode v-for="(child, j) in item.children" :key="child.id" :item="child" :depth="depth + 1" :is-last="j === (item.children?.length ?? 0) - 1" /> </div> </div> </template> -
app/components/ui/tree-view/context.ts 0.4 kB
import type { InjectionKey, Ref } from 'vue' import type { TreeViewItem } from './types' export interface TreeViewContext { expandedIds: Ref<Set<string>> selectedId: Ref<string | null> showIcons: Ref<boolean> showCheckboxes: Ref<boolean> toggle: (item: TreeViewItem) => void select: (item: TreeViewItem) => void } export const TREE_VIEW_CONTEXT: InjectionKey<TreeViewContext> = Symbol('TreeViewContext') -
app/components/ui/tree-view/types.ts 0.2 kB
import type { File } from 'lucide-vue-next' export interface TreeViewItem { id: string label: string icon?: typeof File children?: TreeViewItem[] disabled?: boolean selected?: boolean expanded?: boolean [key: string]: unknown } -
app/components/ui/tree-view/index.ts 0.2 kB
export { default as TreeView } from './TreeView.vue' export { default as TreeViewNode } from './TreeViewNode.vue' export type { TreeViewItem } from './types'
Raw manifest: https://uipkge.dev/r/vue/tree-view.json