Skip to content

File Tree ​

A component used to showcase the folder and file structure of a directory.

Installation ​

Copy and paste the following code into your project:

vue
<script setup lang="ts">
import { computed, onMounted, provide, ref, toRef, useSlots } from "vue";
import { cn } from "@/lib/utils";
import {
  isFolderElement,
  mergeExpandedItems,
  sortTreeElements,
  TreeContextKey,
  type TreeSortMode,
  type TreeViewElement,
} from "./context";
import RenderNode from "./render-node.vue";

interface TreeProps {
  class?: string;
  initialSelectedId?: string;
  indicator?: boolean;
  elements?: TreeViewElement[];
  initialExpandedItems?: string[];
  sort?: TreeSortMode;
  dir?: "rtl" | "ltr";
}

const props = withDefaults(defineProps<TreeProps>(), {
  indicator: true,
  sort: "default",
});

const slots = useSlots();

const selectedId = ref<string | undefined>(props.initialSelectedId);
const expandedItems = ref<string[] | undefined>(props.initialExpandedItems);

const direction = computed<"rtl" | "ltr">(() =>
  props.dir === "rtl" ? "rtl" : "ltr",
);

const selectItem = (id: string) => {
  selectedId.value = id;
};

const handleExpand = (id: string) => {
  const prev = expandedItems.value;
  if (prev?.includes(id)) {
    expandedItems.value = prev.filter((item) => item !== id);
  } else {
    expandedItems.value = [...(prev ?? []), id];
  }
};

const setExpandedItems = (items: string[] | undefined) => {
  expandedItems.value = items;
};

const expandSpecificTargetedElements = (
  elements?: TreeViewElement[],
  selectId?: string,
) => {
  if (!elements || !selectId) return;

  const findParent = (
    currentElement: TreeViewElement,
    currentPath: string[] = [],
  ) => {
    const isSelectable = currentElement.isSelectable ?? true;
    const newPath = [...currentPath, currentElement.id];
    if (currentElement.id === selectId) {
      if (isSelectable) {
        expandedItems.value = mergeExpandedItems(expandedItems.value, newPath);
      } else {
        if (newPath.includes(currentElement.id)) {
          newPath.pop();
          expandedItems.value = mergeExpandedItems(
            expandedItems.value,
            newPath,
          );
        }
      }
      return;
    }
    if (
      Array.isArray(currentElement.children) &&
      currentElement.children.length > 0
    ) {
      currentElement.children.forEach((child) => {
        findParent(child, newPath);
      });
    }
  };

  elements.forEach((element) => {
    findParent(element);
  });
};

onMounted(() => {
  if (props.initialSelectedId) {
    expandSpecificTargetedElements(props.elements, props.initialSelectedId);
  }
});

provide(TreeContextKey, {
  selectedId,
  expandedItems,
  indicator: toRef(props, "indicator"),
  direction,
  handleExpand,
  selectItem,
  setExpandedItems,
  openIcon: slots.openIcon,
  closeIcon: slots.closeIcon,
});

const sortedElements = computed<TreeViewElement[]>(() =>
  props.elements ? sortTreeElements(props.elements, props.sort) : [],
);

const useElements = computed(() => !slots.default && props.elements != null);
</script>

<template>
  <div :class="cn('size-full', props.class)">
    <div
      role="tree"
      :dir="props.dir"
      class="relative h-full overflow-auto px-2"
    >
      <div class="flex flex-col gap-1" :dir="props.dir">
        <template v-if="useElements">
          <RenderNode
            v-for="element in sortedElements"
            :key="element.id"
            :element="element"
            :sort="props.sort"
            :is-folder="isFolderElement(element)"
          />
        </template>
        <slot v-else />
      </div>
    </div>
  </div>
</template>
vue
<script setup lang="ts">
import { computed, inject } from "vue";
import { cn } from "@/lib/utils";
import { RenderSlot, TreeContextKey } from "./context";

interface FolderProps {
  class?: string;
  element: string;
  value: string;
  isSelectable?: boolean;
  isSelect?: boolean;
}

const props = withDefaults(defineProps<FolderProps>(), {
  isSelectable: true,
});

const tree = inject(TreeContextKey);
if (!tree) {
  throw new Error("Folder must be used within a Tree");
}

const isExpanded = computed(
  () => tree.expandedItems.value?.includes(props.value) ?? false,
);
const isSelected = computed(() => props.isSelect ?? tree.selectedId.value === props.value);

const onTrigger = () => {
  if (!props.isSelectable) return;
  tree.selectItem(props.value);
  tree.handleExpand(props.value);
};

const onEnter = (el: Element) => {
  const node = el as HTMLElement;
  node.style.height = "0px";
  node.style.overflow = "hidden";
  requestAnimationFrame(() => {
    node.style.height = `${node.scrollHeight}px`;
  });
};

const onAfterEnter = (el: Element) => {
  const node = el as HTMLElement;
  node.style.height = "";
  node.style.overflow = "";
};

const onLeave = (el: Element) => {
  const node = el as HTMLElement;
  node.style.height = `${node.scrollHeight}px`;
  node.style.overflow = "hidden";
  requestAnimationFrame(() => {
    node.style.height = "0px";
  });
};
</script>

<template>
  <div
    role="treeitem"
    :aria-expanded="isExpanded"
    :aria-selected="isSelected"
    class="relative h-full overflow-hidden"
  >
    <button
      type="button"
      :disabled="!props.isSelectable"
      :aria-expanded="isExpanded"
      :class="
        cn(
          'flex items-center gap-1 rounded-md text-sm',
          props.class,
          {
            'bg-muted rounded-md': isSelected && props.isSelectable,
            'cursor-pointer': props.isSelectable,
            'cursor-not-allowed opacity-50': !props.isSelectable,
          },
        )
      "
      @click="onTrigger"
    >
      <template v-if="isExpanded">
        <RenderSlot v-if="tree.openIcon" :render="tree.openIcon" />
        <svg
          v-else
          xmlns="http://www.w3.org/2000/svg"
          width="24"
          height="24"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          class="size-4"
        >
          <path
            d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
          />
        </svg>
      </template>
      <template v-else>
        <RenderSlot v-if="tree.closeIcon" :render="tree.closeIcon" />
        <svg
          v-else
          xmlns="http://www.w3.org/2000/svg"
          width="24"
          height="24"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          class="size-4"
        >
          <path
            d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
          />
        </svg>
      </template>
      <span>{{ props.element }}</span>
    </button>
    <Transition
      name="spark-tree-collapse"
      @enter="onEnter"
      @after-enter="onAfterEnter"
      @leave="onLeave"
    >
      <div
        v-show="isExpanded"
        class="relative h-full overflow-hidden text-sm"
      >
        <div
          v-if="props.element && tree.indicator.value"
          aria-hidden="true"
          :dir="tree.direction.value"
          class="bg-muted absolute left-1.5 h-full w-px rounded-md py-3 duration-300 ease-in-out hover:bg-slate-300 rtl:right-1.5"
        />
        <div
          role="group"
          :dir="tree.direction.value"
          class="ml-5 flex flex-col gap-1 py-1 rtl:mr-5"
        >
          <slot />
        </div>
      </div>
    </Transition>
  </div>
</template>

<style scoped>
.spark-tree-collapse-enter-active,
.spark-tree-collapse-leave-active {
  transition: height 0.2s ease-out;
}
</style>
vue
<script setup lang="ts">
import { computed, inject } from "vue";
import { cn } from "@/lib/utils";
import { TreeContextKey } from "./context";

interface FileProps {
  class?: string;
  value: string;
  isSelectable?: boolean;
  isSelect?: boolean;
}

const props = withDefaults(defineProps<FileProps>(), {
  isSelectable: true,
});

const emit = defineEmits<{
  select: [id: string];
}>();

const tree = inject(TreeContextKey);
if (!tree) {
  throw new Error("File must be used within a Tree");
}

const isSelected = computed(() => props.isSelect ?? tree.selectedId.value === props.value);

const onClick = () => {
  if (!props.isSelectable) return;
  tree.selectItem(props.value);
  emit("select", props.value);
};
</script>

<template>
  <button
    type="button"
    role="treeitem"
    :aria-selected="isSelected"
    :disabled="!props.isSelectable"
    :dir="tree.direction.value"
    :class="
      cn(
        'flex w-fit items-center gap-1 rounded-md pr-1 text-sm duration-200 ease-in-out rtl:pr-0 rtl:pl-1',
        {
          'bg-muted': isSelected && props.isSelectable,
        },
        props.isSelectable
          ? 'cursor-pointer'
          : 'cursor-not-allowed opacity-50',
        tree.direction.value === 'rtl' ? 'rtl' : 'ltr',
        props.class,
      )
    "
    @click="onClick"
  >
    <slot name="fileIcon">
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="24"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        class="size-4"
      >
        <path
          d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"
        />
        <path d="M14 2v4a2 2 0 0 0 2 2h4" />
      </svg>
    </slot>
    <slot />
  </button>
</template>
vue
<script setup lang="ts">
import { computed } from "vue";
import {
  isFolderElement,
  type TreeSortMode,
  type TreeViewElement,
} from "./context";
import File from "./file.vue";
import Folder from "./folder.vue";

interface RenderNodeProps {
  element: TreeViewElement;
  sort: TreeSortMode;
}

const props = defineProps<RenderNodeProps>();

const isFolder = computed(() => isFolderElement(props.element));
</script>

<template>
  <Folder
    v-if="isFolder"
    :value="element.id"
    :element="element.name"
    :is-selectable="element.isSelectable"
  >
    <RenderNode
      v-for="child in element.children ?? []"
      :key="child.id"
      :element="child"
      :sort="sort"
    />
  </Folder>
  <File v-else :value="element.id" :is-selectable="element.isSelectable">
    <span>{{ element.name }}</span>
  </File>
</template>
vue
<script setup lang="ts">
import { computed, inject, onMounted } from "vue";
import { cn } from "@/lib/utils";
import { TreeContextKey, type TreeViewElement } from "./context";

interface CollapseButtonProps {
  class?: string;
  elements: TreeViewElement[];
  expandAll?: boolean;
}

const props = withDefaults(defineProps<CollapseButtonProps>(), {
  expandAll: false,
});

const tree = inject(TreeContextKey);
if (!tree) {
  throw new Error("CollapseButton must be used within a Tree");
}

const expandAllTree = (elements: TreeViewElement[]): string[] => {
  const expandedElementIds: string[] = [];

  const expandTree = (element: TreeViewElement) => {
    const isSelectable = element.isSelectable ?? true;
    if (isSelectable && element.children && element.children.length > 0) {
      expandedElementIds.push(element.id);
      for (const child of element.children) {
        expandTree(child);
      }
    }
  };

  for (const element of elements) {
    expandTree(element);
  }

  return [...new Set(expandedElementIds)];
};

const isOpen = computed(
  () => (tree.expandedItems.value?.length ?? 0) > 0,
);

const onClick = () => {
  if (isOpen.value) {
    tree.setExpandedItems([]);
  } else {
    tree.setExpandedItems(expandAllTree(props.elements));
  }
};

onMounted(() => {
  if (props.expandAll) {
    tree.setExpandedItems(expandAllTree(props.elements));
  }
});
</script>

<template>
  <button
    type="button"
    :class="
      cn(
        'ring-offset-background focus-visible:ring-ring inline-flex h-8 w-fit items-center justify-center gap-1 rounded-md p-1 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 absolute right-2 bottom-1',
        props.class,
      )
    "
    @click="onClick"
  >
    <slot />
    <span class="sr-only">Toggle</span>
  </button>
</template>
ts
import { defineComponent, type InjectionKey, type Ref } from "vue";

export interface TreeViewElement {
  id: string;
  name: string;
  type?: "file" | "folder";
  isSelectable?: boolean;
  children?: TreeViewElement[];
}

export type TreeSortMode =
  | "default"
  | "none"
  | ((a: TreeViewElement, b: TreeViewElement) => number);

export interface TreeContext {
  selectedId: Ref<string | undefined>;
  expandedItems: Ref<string[] | undefined>;
  indicator: Ref<boolean>;
  direction: Ref<"rtl" | "ltr">;
  handleExpand: (id: string) => void;
  selectItem: (id: string) => void;
  setExpandedItems: (items: string[] | undefined) => void;
  openIcon?: () => unknown;
  closeIcon?: () => unknown;
}

export const TreeContextKey: InjectionKey<TreeContext> = Symbol("SparkUITree");

/**
 * Renders a captured slot function, used to forward the Tree-level
 * `openIcon` / `closeIcon` slots down to deeply nested Folder nodes.
 */
export const RenderSlot = defineComponent({
  name: "RenderSlot",
  props: {
    render: {
      type: Function,
      required: true,
    },
  },
  setup(props) {
    return () => (props.render as () => unknown)();
  },
});

export const isFolderElement = (element: TreeViewElement): boolean => {
  if (element.type) {
    return element.type === "folder";
  }
  return Array.isArray(element.children);
};

export const mergeExpandedItems = (
  currentItems: string[] | undefined,
  nextItems: string[],
): string[] => [...new Set([...(currentItems ?? []), ...nextItems])];

const treeCollator = new Intl.Collator("en", {
  numeric: true,
  sensitivity: "base",
});

const defaultTreeComparator = (a: TreeViewElement, b: TreeViewElement) => {
  const aIsFolder = isFolderElement(a);
  const bIsFolder = isFolderElement(b);

  if (aIsFolder !== bIsFolder) {
    return aIsFolder ? -1 : 1;
  }

  return treeCollator.compare(a.name, b.name);
};

const getTreeComparator = (sort: TreeSortMode) => {
  if (sort === "none") {
    return undefined;
  }
  if (sort === "default") {
    return defaultTreeComparator;
  }
  return sort;
};

export const sortTreeElements = (
  elements: TreeViewElement[],
  sort: TreeSortMode,
): TreeViewElement[] => {
  const comparator = getTreeComparator(sort);

  const nextElements = elements.map((element) => {
    if (!Array.isArray(element.children)) {
      return element;
    }
    return {
      ...element,
      children: sortTreeElements(element.children, sort),
    };
  });

  if (!comparator) {
    return nextElements;
  }

  return [...nextElements].sort(comparator);
};

Add the following animations to your tailwind.config.js file, so folder expand/collapse works smoothly:

tailwind.config.js
js
module.exports = {
  theme: {
    extend: {
      keyframes: {
        "accordion-down": {
          from: { height: "0" },
          to: { height: "var(--radix-accordion-content-height)" },
        },
        "accordion-up": {
          from: { height: "var(--radix-accordion-content-height)" },
          to: { height: "0" },
        },
      },
      animation: {
        "accordion-down": "accordion-down 0.2s ease-out",
        "accordion-up": "accordion-up 0.2s ease-out",
      },
    },
  },
};

Usage ​

Pass a data-driven tree via the elements prop. When you pass elements and omit the default slot, the tree is rendered from data and siblings are sorted automatically using a folders-first alphabetical order. Use sort="none" to preserve the input order, or pass a custom comparator.

vue
<script setup lang="ts">
import Tree from "@/components/spark-ui/file-tree/tree.vue";
import type { TreeViewElement } from "@/components/spark-ui/file-tree/context";

const elements: TreeViewElement[] = [
  {
    id: "src",
    type: "folder",
    name: "src",
    children: [
      { id: "components", type: "folder", name: "components" },
      { id: "app", type: "folder", name: "app" },
      { id: "page", name: "page.tsx" },
      { id: "layout", name: "layout.tsx" },
    ],
  },
];
</script>

<template>
  <Tree :elements="elements" />
</template>

You can also compose the tree manually with the Folder, File and CollapseButton components.

Examples ​

Manual composition with a toggle button ​

Props ​

Tree ​

PropTypeDefaultDescription
classstring-The class to apply to the component.
initialSelectedIdstring-The ID of the initially selected item.
indicatorbooleantrueWhether to show the tree indicator line.
elementsTreeViewElement[]-An array of tree view elements to render when the default slot is omitted.
initialExpandedItemsstring[]-An array of IDs for items that should be initially expanded.
sort"default" | "none" | ((a, b) => number)"default"Sorting mode used for data-driven trees rendered from elements.
dir"rtl" | "ltr""ltr"The text direction of the tree.

Tree Slots ​

SlotDescription
defaultManually composed Folder / File nodes (overrides elements).
openIconCustom icon rendered for open folders.
closeIconCustom icon rendered for closed folders.

TreeViewElement ​

PropTypeDefaultDescription
idstring-A unique identifier for the node.
namestring-The label rendered for the node in data-driven mode.
type"file" | "folder"-Explicit node type. Use "folder" for empty folders.
isSelectablebooleantrueWhether the node can be selected.
childrenTreeViewElement[]-Child nodes for folders.

Folder ​

PropTypeDefaultDescription
classstring-The class to apply to the trigger.
elementstring-The name of the folder.
valuestring-The unique identifier for the folder.
isSelectablebooleantrueWhether the folder can be selected.
isSelectboolean-Whether the folder is currently selected.

File ​

PropTypeDefaultDescription
classstring-The class to apply to the file.
valuestring-The unique identifier for the file.
isSelectablebooleantrueWhether the file can be selected.
isSelectboolean-Whether the file is currently selected.

File Slots ​

SlotDescription
defaultThe file label content.
fileIconCustom icon rendered for the file.

File Events ​

EventPayloadDescription
selectstringEmitted with the file id when clicked.

CollapseButton ​

PropTypeDefaultDescription
classstring-The class to apply to the button.
elementsTreeViewElement[]-An array of tree view elements to control.
expandAllbooleanfalseWhether to expand all elements initially.

Released under the MIT License.