Build websites 10x faster with HextaUI Blocks — Learn more
UI/UI

Tree

A flexible tree view component with collapsible nodes, selection, and animations.

Project
<TreeProvider className="w-full max-w-md">
  <Tree>
    <TreeItem nodeId="root" label="Project" icon={<Folder />} hasChildren>
      <TreeItem nodeId="src" label="src" icon={<Folder />} level={1} hasChildren>
        <TreeItem nodeId="components" label="components" icon={<Folder />} level={2} hasChildren>
          <TreeItem nodeId="ui" label="ui" icon={<Folder />} level={3} hasChildren>
            <TreeItem nodeId="button" label="button.tsx" icon={<FileText />} level={4} />
            <TreeItem nodeId="tree" label="tree.tsx" icon={<FileText />} level={4} />
          </TreeItem>
        </TreeItem>
      </TreeItem>
    </TreeItem>
  </Tree>
</TreeProvider>

Installation

Install following dependencies:

npm install @radix-ui/react-slot class-variance-authority motion lucide-react

Copy and paste the following code into your project.

components/ui/tree.tsx
"use client";

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronRight, Folder, File, FolderOpen } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";

// Tree Context
interface TreeContextType {
  expandedIds: Set<string>;
  selectedIds: string[];
  toggleExpanded: (nodeId: string) => void;
  handleSelection: (nodeId: string, ctrlKey?: boolean) => void;
  showLines: boolean;
  showIcons: boolean;
  selectable: boolean;
  multiSelect: boolean;
  animateExpand: boolean;
  indent: number;
  onNodeClick?: (nodeId: string, data?: any) => void;
  onNodeExpand?: (nodeId: string, expanded: boolean) => void;
}

const TreeContext = React.createContext<TreeContextType | null>(null);

const useTree = () => {
  const context = React.useContext(TreeContext);
  if (!context) {
    throw new Error("Tree components must be used within a TreeProvider");
  }
  return context;
};

// Tree variants
const treeVariants = cva(
  "w-full bg-background border border-border rounded-ele shadow-sm/2",
  {
    variants: {
      variant: {
        default: "",
        outline: "border-2",
        ghost: "border-transparent bg-transparent",
      },
      size: {
        sm: "text-sm",
        default: "",
        lg: "text-lg",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

const treeItemVariants = cva(
  "flex items-center py-2 px-3 cursor-pointer transition-all duration-200 relative group rounded-[calc(var(--card-radius)-8px)]",
  {
    variants: {
      variant: {
        default: "hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
        ghost: "hover:bg-accent/50",
        subtle: "hover:bg-muted/50",
      },
      selected: {
        true: "bg-accent text-accent-foreground",
        false: "",
      },
    },
    defaultVariants: {
      variant: "default",
      selected: false,
    },
  }
);

// Provider Props
export interface TreeProviderProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof treeVariants> {
  defaultExpandedIds?: string[];
  selectedIds?: string[];
  onSelectionChange?: (selectedIds: string[]) => void;
  onNodeClick?: (nodeId: string, data?: any) => void;
  onNodeExpand?: (nodeId: string, expanded: boolean) => void;
  showLines?: boolean;
  showIcons?: boolean;
  selectable?: boolean;
  multiSelect?: boolean;
  animateExpand?: boolean;
  indent?: number;
}

// Tree Provider
const TreeProvider = React.forwardRef<HTMLDivElement, TreeProviderProps>(
  (
    {
      className,
      variant,
      size,
      children,
      defaultExpandedIds = [],
      selectedIds = [],
      onSelectionChange,
      onNodeClick,
      onNodeExpand,
      showLines = true,
      showIcons = true,
      selectable = true,
      multiSelect = false,
      animateExpand = true,
      indent = 20,
      ...props
    },
    ref
  ) => {
    const [expandedIds, setExpandedIds] = React.useState<Set<string>>(
      new Set(defaultExpandedIds)
    );
    const [internalSelectedIds, setInternalSelectedIds] =
      React.useState<string[]>(selectedIds);

    const isControlled = onSelectionChange !== undefined;
    const currentSelectedIds = isControlled ? selectedIds : internalSelectedIds;

    const toggleExpanded = React.useCallback(
      (nodeId: string) => {
        setExpandedIds((prev) => {
          const newSet = new Set(prev);
          const isExpanded = newSet.has(nodeId);
          isExpanded ? newSet.delete(nodeId) : newSet.add(nodeId);
          onNodeExpand?.(nodeId, !isExpanded);
          return newSet;
        });
      },
      [onNodeExpand]
    );

    const handleSelection = React.useCallback(
      (nodeId: string, ctrlKey = false) => {
        if (!selectable) return;

        let newSelection: string[];

        if (multiSelect && ctrlKey) {
          newSelection = currentSelectedIds.includes(nodeId)
            ? currentSelectedIds.filter((id) => id !== nodeId)
            : [...currentSelectedIds, nodeId];
        } else {
          newSelection = currentSelectedIds.includes(nodeId) ? [] : [nodeId];
        }

        isControlled
          ? onSelectionChange?.(newSelection)
          : setInternalSelectedIds(newSelection);
      },
      [selectable, multiSelect, currentSelectedIds, isControlled, onSelectionChange]
    );

    const contextValue: TreeContextType = {
      expandedIds,
      selectedIds: currentSelectedIds,
      toggleExpanded,
      handleSelection,
      showLines,
      showIcons,
      selectable,
      multiSelect,
      animateExpand,
      indent,
      onNodeClick,
      onNodeExpand,
    };

    return (
      <TreeContext.Provider value={contextValue}>
        <motion.div
          className={cn(treeVariants({ variant, size, className }))}
          ref={ref}
          initial={{ opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.3, ease: "easeOut" }}
        >
          <div className="p-2" {...props}>{children}</div>
        </motion.div>
      </TreeContext.Provider>
    );
  }
);

TreeProvider.displayName = "TreeProvider";

// Tree Props
export interface TreeProps extends React.HTMLAttributes<HTMLDivElement> {
  asChild?: boolean;
}

// Tree
const Tree = React.forwardRef<HTMLDivElement, TreeProps>(
  ({ className, asChild = false, children, ...props }, ref) => {
    const Comp = asChild ? Slot : "div";

    return (
      <Comp className={cn("space-y-1", className)} ref={ref} {...props}>
        {children}
      </Comp>
    );
  }
);

Tree.displayName = "Tree";

// Tree Item Props
export interface TreeItemProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof treeItemVariants> {
  nodeId: string;
  label: string;
  icon?: React.ReactNode;
  data?: any;
  level?: number;
  isLast?: boolean;
  parentPath?: boolean[];
  hasChildren?: boolean;
  asChild?: boolean;
}

// Tree Item
const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
  (
    {
      className,
      variant,
      nodeId,
      label,
      icon,
      data,
      level = 0,
      isLast = false,
      parentPath = [],
      hasChildren = false,
      asChild = false,
      children,
      onClick,
      ...props
    },
    ref
  ) => {
    const {
      expandedIds,
      selectedIds,
      toggleExpanded,
      handleSelection,
      showLines,
      showIcons,
      animateExpand,
      indent,
      onNodeClick,
    } = useTree();

    const isExpanded = expandedIds.has(nodeId);
    const isSelected = selectedIds.includes(nodeId);
    const currentPath = [...parentPath, isLast];

    const getDefaultIcon = () =>
      hasChildren ? (
        isExpanded ? (
          <FolderOpen className="h-4 w-4" />
        ) : (
          <Folder className="h-4 w-4" />
        )
      ) : (
        <File className="h-4 w-4" />
      );

    const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
      if (hasChildren) toggleExpanded(nodeId);
      handleSelection(nodeId, e.ctrlKey || e.metaKey);
      onNodeClick?.(nodeId, data);
      onClick?.(e);
    };

    const Comp = asChild ? Slot : "div";

    return (
      <div className="select-none">
        <motion.div
          className={cn(
            treeItemVariants({ variant, selected: isSelected, className })
          )}
          style={{ paddingLeft: level * indent + 8 }}
          onClick={handleClick}
          whileTap={{ scale: 0.98, transition: { duration: 0.1 } }}
        >
          {/* Tree Lines */}
          {showLines && level > 0 && (
            <div className="absolute left-0 top-0 bottom-0 pointer-events-none">
              {currentPath.map((isLastInPath, pathIndex) => (
                <div
                  key={pathIndex}
                  className="absolute top-0 bottom-0 border-l border-border/40"
                  style={{
                    left: pathIndex * indent + 12,
                    display:
                      pathIndex === currentPath.length - 1 && isLastInPath
                        ? "none"
                        : "block",
                  }}
                />
              ))}
              <div
                className="absolute top-1/2 border-t border-border/40"
                style={{
                  left: (level - 1) * indent + 12,
                  width: indent - 4,
                  transform: "translateY(-1px)",
                }}
              />
              {isLast && (
                <div
                  className="absolute top-0 border-l border-border/40"
                  style={{
                    left: (level - 1) * indent + 12,
                    height: "50%",
                  }}
                />
              )}
            </div>
          )}

          {/* Expand Icon */}
          <motion.div
            className="flex items-center justify-center w-4 h-4 mr-1"
            animate={{ rotate: hasChildren && isExpanded ? 90 : 0 }}
            transition={{ duration: 0.2, ease: "easeInOut" }}
          >
            {hasChildren && (
              <ChevronRight className="h-3 w-3 text-muted-foreground" />
            )}
          </motion.div>

          {/* Node Icon */}
          {showIcons && (
            <motion.div
              className="flex items-center justify-center w-4 h-4 mr-2 text-muted-foreground"
              whileHover={{ scale: 1.1 }}
              transition={{ duration: 0.15 }}
            >
              {icon || getDefaultIcon()}
            </motion.div>
          )}

          {/* Label */}
          <span className="text-sm  truncate flex-1 text-foreground">
            {label}
          </span>
        </motion.div>

        {/* Children */}
        <AnimatePresence>
          {hasChildren && isExpanded && children && (
            <motion.div
              initial={{ height: 0, opacity: 0 }}
              animate={{ height: "auto", opacity: 1 }}
              exit={{ height: 0, opacity: 0 }}
              transition={{
                duration: animateExpand ? 0.3 : 0,
                ease: "easeInOut",
              }}
              className="overflow-hidden"
            >
              <motion.div
                initial={{ y: -10 }}
                animate={{ y: 0 }}
                exit={{ y: -10 }}
                transition={{
                  duration: animateExpand ? 0.2 : 0,
                  delay: animateExpand ? 0.1 : 0,
                }}
              >
                {children}
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    );
  }
);

TreeItem.displayName = "TreeItem";

export { TreeProvider, Tree, TreeItem, treeVariants, treeItemVariants };
npx hextaui@latest add tree

Usage

import { TreeProvider, Tree, TreeItem } from "@/components/ui/tree";
<TreeProvider>
  <Tree>
    <TreeItem nodeId="1" label="Item 1" hasChildren>
      <TreeItem nodeId="2" label="Item 2" level={1} />
    </TreeItem>
  </Tree>
</TreeProvider>

Examples

Basic Tree

Documents
<TreeProvider className="w-full max-w-sm">
  <Tree>
    <TreeItem nodeId="documents" label="Documents" icon={<Folder />} hasChildren>
      <TreeItem nodeId="projects" label="Projects" icon={<Folder />} level={1} hasChildren>
        <TreeItem nodeId="project1" label="Project 1" icon={<Folder />} level={2} hasChildren>
          <TreeItem nodeId="readme" label="README.md" icon={<FileText />} level={3} />
          <TreeItem nodeId="index" label="index.tsx" icon={<FileText />} level={3} />
        </TreeItem>
      </TreeItem>
      <TreeItem nodeId="images" label="Images" icon={<Folder />} level={1} hasChildren>
        <TreeItem nodeId="logo" label="logo.png" icon={<Image />} level={2} />
        <TreeItem nodeId="banner" label="banner.jpg" icon={<Image />} level={2} />
      </TreeItem>
    </TreeItem>
  </Tree>
</TreeProvider>

Tree Variants

Default

Folder

Outline

Folder

Ghost

Folder
{/* Default */}
<TreeProvider variant="default">
  <Tree>
    <TreeItem nodeId="1" label="Folder" icon={<Folder />} hasChildren>
      <TreeItem nodeId="2" label="File.txt" icon={<File />} level={1} />
    </TreeItem>
  </Tree>
</TreeProvider>

{/* Outline */}
<TreeProvider variant="outline">
  <Tree>
    <TreeItem nodeId="3" label="Folder" icon={<Folder />} hasChildren>
      <TreeItem nodeId="4" label="File.txt" icon={<File />} level={1} />
    </TreeItem>
  </Tree>
</TreeProvider>

{/* Ghost */}
<TreeProvider variant="ghost">
  <Tree>
    <TreeItem nodeId="5" label="Folder" icon={<Folder />} hasChildren>
      <TreeItem nodeId="6" label="File.txt" icon={<File />} level={1} />
    </TreeItem>
  </Tree>
</TreeProvider>

Custom Icons

Settings
<TreeProvider className="w-full max-w-sm">
  <Tree>
    <TreeItem nodeId="settings" label="Settings" icon={<Settings />} hasChildren>
      <TreeItem nodeId="users" label="Users" icon={<Users />} level={1} hasChildren>
        <TreeItem nodeId="admin" label="Admin" icon={<Settings />} level={2} />
        <TreeItem nodeId="guest" label="Guest" icon={<Users />} level={2} />
      </TreeItem>
      <TreeItem nodeId="database" label="Database" icon={<Database />} level={1} hasChildren>
        <TreeItem nodeId="config" label="config.json" icon={<FileText />} level={2} />
      </TreeItem>
    </TreeItem>
  </Tree>
</TreeProvider>

Without Lines

Root
<TreeProvider showLines={false}>
  <Tree>
    <TreeItem nodeId="root" label="Root" icon={<Folder />} hasChildren>
      <TreeItem nodeId="child1" label="Child 1" icon={<Folder />} level={1} hasChildren>
        <TreeItem nodeId="grandchild1" label="Grandchild 1" icon={<File />} level={2} />
        <TreeItem nodeId="grandchild2" label="Grandchild 2" icon={<File />} level={2} />
      </TreeItem>
      <TreeItem nodeId="child2" label="Child 2" icon={<File />} level={1} />
    </TreeItem>
  </Tree>
</TreeProvider>

Data-driven Tree

You can build tree structures from data using a simple recursive approach.

Media
// Example data structure
const treeData = [
  {
    id: "media",
    label: "Media",
    icon: <Folder />,
    children: [
      {
        id: "videos",
        label: "Videos", 
        icon: <Video />,
        children: [
          { id: "video1", label: "intro.mp4", icon: <Video /> },
          { id: "video2", label: "demo.mp4", icon: <Video /> },
        ],
      },
      {
        id: "audio",
        label: "Audio",
        icon: <Music />,
        children: [
          { id: "audio1", label: "song.mp3", icon: <Music /> },
        ],
      },
    ],
  },
];

// Recursive function to build tree items
function buildTreeItems(nodes: any[], level = 0, parentPath: boolean[] = []) {
  return nodes.map((node, index) => {
    const isLast = index === nodes.length - 1;
    const hasChildren = (node.children?.length ?? 0) > 0;

    return (
      <TreeItem
        key={node.id}
        nodeId={node.id}
        label={node.label}
        icon={node.icon}
        level={level}
        isLast={isLast}
        parentPath={parentPath}
        hasChildren={hasChildren}
      >
        {hasChildren &&
          buildTreeItems(
            node.children,
            level + 1,
            [...parentPath, isLast]
          )}
      </TreeItem>
    );
  });
}

// Usage
<TreeProvider>
  <Tree>
    {buildTreeItems(treeData)}
  </Tree>
</TreeProvider>

Props

TreeProvider

PropTypeDefault
variant?
"default" | "outline" | "ghost"
"default"
size?
"sm" | "default" | "lg"
"default"
defaultExpandedIds?
string[]
[]
selectedIds?
string[]
[]
onSelectionChange?
(selectedIds: string[]) => void
undefined
onNodeClick?
(nodeId: string, data?: any) => void
undefined
onNodeExpand?
(nodeId: string, expanded: boolean) => void
undefined
showLines?
boolean
true
showIcons?
boolean
true
selectable?
boolean
true
multiSelect?
boolean
false
animateExpand?
boolean
true
indent?
number
20

TreeItem

PropTypeDefault
nodeId?
string
undefined
label?
string
undefined
icon?
React.ReactNode
undefined
data?
any
undefined
level?
number
0
hasChildren?
boolean
false
variant?
"default" | "ghost" | "subtle"
"default"
asChild?
boolean
false
Edit on GitHub

Last updated on