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.
"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
Prop | Type | Default |
---|---|---|
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
Prop | Type | Default |
---|---|---|
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