Skip to content

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
    • App.vue
    • main.ts
    • style.css
  • package.json
  • README.md
  • vite.config.ts

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/file-tree.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then 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 ​

PropTypeDefaultDescription
itemsFileTreeNode[]—{ name, id?, children?, disabled? }. A node with children (even empty) is a folder.
v-model:selectedstring—Id of the selected node. Ids default to the path, e.g. 'src/main.ts'.
v-model:expandedstring[][]Ids of the open folders.
labelstring'Files'Accessible name of the tree.
indentnumber16Indentation per level, in pixels.
EventPayloadDescription
selectnode, idA node was activated with a click, Enter or Space.
SlotPropsDescription
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.

Designed by Kuldeep Pisda · Released under the MIT License