File Tree
A keyboard-navigable file tree whose folders spring open and whose selection glides between rows.
storefrontsrc/components/PricingTable.vue
- src
- components
- AppHeader.vue
- PricingTable.vue
- UserMenu.vue
- composables
- assets
- App.vue
- main.ts
- style.css
- public
- package.json
- README.md
- vite.config.ts
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/file-tree.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/file-tree/ (plus lib/utils.ts if you don't have it):
vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { FileTreeNode, FileTreeSlots } from './context'
import { useReducedMotion } from 'motion-v'
import { computed, nextTick, provide, ref, useId } from 'vue'
import { cn } from '@/lib/utils'
import { FILE_TREE_KEY, isFolder, nodeId } from './context'
import FileTreeItem from './FileTreeItem.vue'
const props = withDefaults(defineProps<{
items: FileTreeNode[]
/** Accessible name for the tree. */
label?: string
/** Indentation per level, in pixels. */
indent?: number
class?: HTMLAttributes['class']
}>(), {
label: 'Files',
indent: 16,
})
/** Id (path) of the selected node. */
const selected = defineModel<string | undefined>('selected', { default: undefined })
/** Ids of the expanded folders. */
const expanded = defineModel<string[]>('expanded', { default: () => [] })
const emit = defineEmits<{ select: [node: FileTreeNode, id: string] }>()
const slots = defineSlots<FileTreeSlots>()
const root = ref<HTMLElement | null>(null)
const focused = ref<string | undefined>()
const reduced = useReducedMotion()
interface Row { id: string, node: FileTreeNode, parentId?: string }
// Every node currently visible, in reading order — drives keyboard navigation.
const rows = computed(() => {
const out: Row[] = []
const walk = (nodes: FileTreeNode[], parentId?: string) => {
for (const node of nodes) {
const id = nodeId(node, parentId)
out.push({ id, node, parentId })
if (isFolder(node) && expanded.value.includes(id))
walk(node.children!, id)
}
}
walk(props.items)
return out
})
// Roving tabindex: exactly one item is tabbable.
const tabbable = computed(() => {
const ids = rows.value.map(r => r.id)
if (focused.value && ids.includes(focused.value))
return focused.value
if (selected.value && ids.includes(selected.value))
return selected.value
return ids[0]
})
function setExpanded(id: string, value: boolean) {
const has = expanded.value.includes(id)
if (value && !has)
expanded.value = [...expanded.value, id]
else if (!value && has)
expanded.value = expanded.value.filter(e => e !== id)
}
function activate(id: string, node: FileTreeNode) {
focus(id)
if (isFolder(node))
setExpanded(id, !expanded.value.includes(id))
if (!node.disabled) {
selected.value = id
emit('select', node, id)
}
}
async function focus(id: string | undefined) {
if (!id)
return
focused.value = id
await nextTick()
const el = root.value?.querySelector<HTMLElement>(`[data-tree-id="${CSS.escape(id)}"]`)
el?.focus()
}
function onKeydown(e: KeyboardEvent) {
const list = rows.value
const index = list.findIndex(r => r.id === tabbable.value)
const row = list[index]
if (!row)
return
const folder = isFolder(row.node)
const open = folder && expanded.value.includes(row.id)
switch (e.key) {
case 'ArrowDown':
focus(list[index + 1]?.id)
break
case 'ArrowUp':
focus(list[index - 1]?.id)
break
case 'Home':
focus(list[0]?.id)
break
case 'End':
focus(list.at(-1)?.id)
break
case 'ArrowRight':
if (folder && !open)
setExpanded(row.id, true)
else if (open)
focus(list[index + 1]?.id)
break
case 'ArrowLeft':
if (open)
setExpanded(row.id, false)
else
focus(row.parentId)
break
case 'Enter':
case ' ':
activate(row.id, row.node)
break
case '*': {
// Expand every sibling folder, per the WAI-ARIA tree pattern.
const siblings = list.filter(r => r.parentId === row.parentId && isFolder(r.node))
expanded.value = [...new Set([...expanded.value, ...siblings.map(r => r.id)])]
break
}
default: {
// Type-ahead: jump to the next visible item starting with the typed character.
if (e.key.length !== 1 || e.metaKey || e.ctrlKey || e.altKey)
return
const char = e.key.toLowerCase()
const ordered = [...list.slice(index + 1), ...list.slice(0, index + 1)]
const match = ordered.find(r => r.node.name.toLowerCase().startsWith(char))
if (!match)
return
focus(match.id)
}
}
e.preventDefault()
}
provide(FILE_TREE_KEY, {
selected,
tabbable,
setFocused: (id) => { focused.value = id },
isExpanded: id => expanded.value.includes(id),
activate,
highlightId: `vx-file-tree-${useId()}`,
reduced,
indent: props.indent,
})
</script>
<template>
<ul
ref="root"
role="tree"
:aria-label="props.label"
:class="cn('m-0 w-full list-none p-1 font-mono text-sm', props.class)"
@keydown="onKeydown"
>
<FileTreeItem
v-for="(node, i) in props.items"
:key="nodeId(node)"
:node="node"
:level="1"
:posinset="i + 1"
:setsize="props.items.length"
>
<template v-if="slots.icon" #icon="scope">
<slot name="icon" v-bind="scope" />
</template>
<template v-if="slots.label" #label="scope">
<slot name="label" v-bind="scope" />
</template>
</FileTreeItem>
</ul>
</template>vue
<script setup lang="ts">
import type { FileTreeNode, FileTreeSlots } from './context'
import { AnimatePresence, motion } from 'motion-v'
import { computed, inject } from 'vue'
import { cn } from '@/lib/utils'
import { FILE_TREE_KEY, isFolder, nodeId } from './context'
import FileTreeIcon from './FileTreeIcon.vue'
const props = defineProps<{
node: FileTreeNode
parentId?: string
level: number
posinset: number
setsize: number
}>()
const slots = defineSlots<FileTreeSlots>()
const ctx = inject(FILE_TREE_KEY)!
const id = computed(() => nodeId(props.node, props.parentId))
const folder = computed(() => isFolder(props.node))
const open = computed(() => folder.value && ctx.isExpanded(id.value))
const selected = computed(() => ctx.selected.value === id.value)
const spring = { type: 'spring', stiffness: 380, damping: 34 } as const
const transition = computed(() => (ctx.reduced.value ? { duration: 0 } : spring))
</script>
<template>
<li
role="treeitem"
:data-tree-id="id"
:aria-level="props.level"
:aria-posinset="props.posinset"
:aria-setsize="props.setsize"
:aria-expanded="folder ? open : undefined"
:aria-selected="selected"
:aria-disabled="props.node.disabled || undefined"
:tabindex="ctx.tabbable.value === id ? 0 : -1"
class="outline-none [&:focus-visible>div]:ring-[3px] [&:focus-visible>div]:ring-inset [&:focus-visible>div]:ring-ring/50"
@focus.self="ctx.setFocused(id)"
>
<div
:class="cn(
'relative isolate flex cursor-pointer select-none items-center gap-1.5 rounded-md py-1 pr-2 text-sm transition-colors',
selected ? 'text-accent-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
props.node.disabled && 'cursor-default opacity-50',
)"
:style="{ paddingLeft: `${(props.level - 1) * ctx.indent + 6}px` }"
@click="ctx.activate(id, props.node)"
>
<motion.span
v-if="selected"
:layout-id="ctx.highlightId"
aria-hidden="true"
class="absolute inset-0 -z-10 rounded-md bg-accent ring-1 ring-border/60"
:transition="transition"
/>
<motion.svg
v-if="folder"
class="size-3.5 shrink-0 text-muted-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:initial="false"
:animate="{ rotate: open ? 90 : 0 }"
:transition="transition"
>
<path d="m9 18 6-6-6-6" />
</motion.svg>
<span v-else class="size-3.5 shrink-0" aria-hidden="true" />
<slot name="icon" :node="props.node" :folder="folder" :open="open">
<FileTreeIcon :name="props.node.name" :folder="folder" :open="open" />
</slot>
<span class="truncate" :class="selected && 'font-medium'">
<slot name="label" :node="props.node" :folder="folder">{{ props.node.name }}</slot>
</span>
</div>
<AnimatePresence :initial="false">
<motion.ul
v-if="open && props.node.children?.length"
key="group"
role="group"
class="relative m-0 list-none overflow-hidden p-0 before:pointer-events-none before:absolute before:inset-y-0.5 before:left-[var(--guide)] before:w-px before:bg-muted-foreground/25"
:style="{ '--guide': `${(props.level - 1) * ctx.indent + 6 + 7}px` }"
:initial="{ height: 0, opacity: 0 }"
:animate="{ height: 'auto', opacity: 1 }"
:exit="{ height: 0, opacity: 0 }"
:transition="transition"
>
<FileTreeItem
v-for="(child, i) in props.node.children"
:key="nodeId(child, id)"
:node="child"
:parent-id="id"
:level="props.level + 1"
:posinset="i + 1"
:setsize="props.node.children.length"
>
<template v-if="slots.icon" #icon="scope">
<slot name="icon" v-bind="scope" />
</template>
<template v-if="slots.label" #label="scope">
<slot name="label" v-bind="scope" />
</template>
</FileTreeItem>
</motion.ul>
</AnimatePresence>
</li>
</template>vue
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
name: string
folder?: boolean
open?: boolean
}>()
// Tints by file type, using chart tokens so they follow the theme.
const tints: Record<string, string> = {
ts: 'text-chart-2',
tsx: 'text-chart-2',
js: 'text-chart-4',
mjs: 'text-chart-4',
vue: 'text-chart-1',
json: 'text-chart-4',
css: 'text-chart-3',
html: 'text-chart-5',
md: 'text-muted-foreground',
png: 'text-chart-3',
jpg: 'text-chart-3',
svg: 'text-chart-3',
}
const ext = computed(() => props.name.split('.').pop()?.toLowerCase() ?? '')
const kind = computed(() => {
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext.value))
return 'image'
if (['md', 'mdx', 'txt'].includes(ext.value))
return 'text'
if (ext.value === 'json')
return 'data'
return 'code'
})
</script>
<template>
<svg
:class="cn('size-4 shrink-0', props.folder ? 'text-chart-4' : (tints[ext] ?? 'text-muted-foreground'))"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="props.folder && props.open">
<path d="M3 18V6.5A1.5 1.5 0 0 1 4.5 5h4l2 2.5h7A1.5 1.5 0 0 1 19 9v1.5" />
<path d="M3 18 5.4 11.6A1.5 1.5 0 0 1 6.8 10.5H20.5a1 1 0 0 1 .95 1.3L19.6 17a1.5 1.5 0 0 1-1.4 1H3Z" fill="currentColor" fill-opacity="0.15" />
</template>
<path
v-else-if="props.folder"
d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4l2 2.5h9A1.5 1.5 0 0 1 21 10v7.5A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5Z"
fill="currentColor"
fill-opacity="0.15"
/>
<template v-else>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z" />
<path d="M14 3v5h5" />
<template v-if="kind === 'code'">
<path d="m10 13-2 2 2 2" />
<path d="m14 13 2 2-2 2" />
</template>
<template v-else-if="kind === 'text'">
<path d="M9 13h6M9 17h4" />
</template>
<template v-else-if="kind === 'data'">
<path d="M10 12.5c-1 0-1 .8-1 1.5s-.5 1-1 1c.5 0 1 .3 1 1s0 1.5 1 1.5M14 12.5c1 0 1 .8 1 1.5s.5 1 1 1c-.5 0-1 .3-1 1s0 1.5-1 1.5" />
</template>
<template v-else>
<circle cx="10" cy="12.5" r="1.25" />
<path d="m19 17-3.5-3.5L9 20" />
</template>
</template>
</svg>
</template>ts
import type { InjectionKey, Ref } from 'vue'
export interface FileTreeNode {
/** File or folder name shown in the tree. */
name: string
/** Stable id. Defaults to the node's path, e.g. `src/components/Button.vue`. */
id?: string
/** Present (even if empty) for folders. */
children?: FileTreeNode[]
/** Disables selection; folders can still be expanded. */
disabled?: boolean
}
export interface FileTreeContext {
selected: Ref<string | undefined>
/** The one item that is in the tab order (roving tabindex). */
tabbable: Readonly<Ref<string | undefined>>
setFocused: (id: string) => void
isExpanded: (id: string) => boolean
activate: (id: string, node: FileTreeNode) => void
highlightId: string
reduced: Ref<boolean>
indent: number
}
export interface FileTreeSlots {
/** Replace the built-in file / folder icon. */
icon?: (props: { node: FileTreeNode, folder: boolean, open: boolean }) => any
/** Replace the node's label. */
label?: (props: { node: FileTreeNode, folder: boolean }) => any
}
export const FILE_TREE_KEY: InjectionKey<FileTreeContext> = Symbol('vx-file-tree')
export function nodeId(node: FileTreeNode, parentId?: string) {
return node.id ?? (parentId ? `${parentId}/${node.name}` : node.name)
}
export function isFolder(node: FileTreeNode) {
return Array.isArray(node.children)
}ts
export type { FileTreeNode } from './context'
export { default as FileTree } from './FileTree.vue'Usage
ts
import { FileTree, type FileTreeNode } from '@/components/ui/file-tree'vue
<FileTree
v-model:expanded="expanded"
v-model:selected="selected"
:items="[
{ name: 'src', children: [{ name: 'main.ts' }, { name: 'App.vue' }] },
{ name: 'package.json' },
]"
/>API
| Prop | Type | Default | Description |
|---|---|---|---|
items | FileTreeNode[] | — | { name, id?, children?, disabled? }. A node with children (even empty) is a folder. |
v-model:selected | string | — | Id of the selected node. Ids default to the path, e.g. 'src/main.ts'. |
v-model:expanded | string[] | [] | Ids of the open folders. |
label | string | 'Files' | Accessible name of the tree. |
indent | number | 16 | Indentation per level, in pixels. |
| Event | Payload | Description |
|---|---|---|
select | node, id | A node was activated with a click, Enter or Space. |
| Slot | Props | Description |
|---|---|---|
icon | { node, folder, open } | Replace the built-in icons, e.g. with lucide icons. |
label | { node, folder } | Replace the node's name. |
Implements the WAI-ARIA tree pattern: role="tree" / treeitem / group with aria-level, aria-expanded and aria-selected, and a roving tabindex. ↑ ↓ move, → opens a folder or steps into it, ← closes it or jumps to the parent, Home / End jump to the ends, Enter / Space select, * expands all siblings, and typing a letter jumps to the next matching name.