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
pnpm add @radix-ui/react-slot class-variance-authority motion lucide-react
yarn add @radix-ui/react-slot class-variance-authority motion lucide-react
bun add @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
pnpm dlx hextaui@latest add tree
yarn dlx hextaui@latest add tree
bun x 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
indent?
number
20
animateExpand?
boolean
true
multiSelect?
boolean
false
selectable?
boolean
true
showIcons?
boolean
true
showLines?
boolean
true
onNodeExpand?
(nodeId: string, expanded: boolean) => void
undefined
onNodeClick?
(nodeId: string, data?: any) => void
undefined
onSelectionChange?
(selectedIds: string[]) => void
undefined
selectedIds?
string[]
[]
defaultExpandedIds?
string[]
[]
size?
"sm" | "default" | "lg"
"default"
variant?
"default" | "outline" | "ghost"
"default"

TreeItem

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

Last updated on