Overview
A nested tree for file browsers and structured navigation. Supports controlled selection, expand/collapse, and Arrow, Home, and End navigation. Reduced motion removes positional movement.
Installation
$ pnpm add motion lucide-reactCopy the source below and adjust the cn utility import to your project.
tsx
"use client"; import { useEffect, useId, useMemo, useRef, useState } from "react";import type { KeyboardEvent } from "react";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import { ChevronRight, FileText, Folder, FolderOpen } from "lucide-react";import { cn } from "@/lib/utils"; export type TreeNode = { id: string; label: string; children?: TreeNode[];}; export type AnimatedTreeProps = { nodes: TreeNode[]; value?: string; defaultValue?: string; onValueChange?: (id: string) => void; defaultExpanded?: string[]; className?: string;}; export function AnimatedTree({ nodes, value, defaultValue, onValueChange, defaultExpanded = [], className,}: AnimatedTreeProps) { const reduce = useReducedMotion(); const id = useId(); const controlled = value !== undefined; const [internalValue, setInternalValue] = useState(defaultValue); const selected = controlled ? value : internalValue; const [expanded, setExpanded] = useState(() => new Set(defaultExpanded)); const [focusedId, setFocusedId] = useState<string | undefined>(selected); const refs = useRef(new Map<string, HTMLDivElement>()); const frame = useRef<number | null>(null); useEffect(() => { return () => { if (frame.current != null) cancelAnimationFrame(frame.current); }; }, []); const visibleNodes = useMemo(() => { const result: TreeNode[] = []; const visit = (items: TreeNode[]) => { items.forEach((node) => { result.push(node); if (node.children?.length && expanded.has(node.id)) visit(node.children); }); }; visit(nodes); return result; }, [expanded, nodes]); const tabStopId = visibleNodes.some((node) => node.id === focusedId) ? focusedId : visibleNodes.some((node) => node.id === selected) ? selected : visibleNodes[0]?.id; const select = (nodeId: string) => { if (!controlled) setInternalValue(nodeId); onValueChange?.(nodeId); }; const toggle = (nodeId: string) => setExpanded((current) => { const next = new Set(current); if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId); return next; }); const focusAt = (index: number) => { const target = visibleNodes[Math.max(0, Math.min(visibleNodes.length - 1, index))]; if (target) refs.current.get(target.id)?.focus(); }; const renderNodes = (items: TreeNode[], depth = 0) => ( <ul role={depth === 0 ? "tree" : "group"} aria-label={depth === 0 ? "Files" : undefined} className={cn(depth > 0 && "ml-4 border-l border-(--color-rule) pl-2")} > <AnimatePresence initial={false}> {items.map((node) => { const folder = Boolean(node.children?.length); const isOpen = expanded.has(node.id); const isSelected = selected === node.id; return ( <motion.li key={node.id} role="none" initial={ reduce ? false : { opacity: 0, transform: "translateY(-4px)" } } animate={{ opacity: 1, transform: "translateY(0px)" }} exit={ reduce ? { opacity: 0 } : { opacity: 0, transform: "translateY(-4px)" } } transition={{ duration: reduce ? 0.01 : 0.18, ease: [0.23, 1, 0.32, 1], }} > <div ref={(element) => { if (element) refs.current.set(node.id, element); else refs.current.delete(node.id); }} id={`${id}-${node.id}`} role="treeitem" aria-level={depth + 1} aria-selected={isSelected} aria-labelledby={`${id}-${node.id}-label`} aria-expanded={folder ? isOpen : undefined} tabIndex={tabStopId === node.id ? 0 : -1} className="w-full rounded-md outline-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--color-primary)" onFocus={() => setFocusedId(node.id)} onClick={(event) => { event.stopPropagation(); select(node.id); if (folder) toggle(node.id); }} onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => { event.stopPropagation(); const index = visibleNodes.findIndex( (item) => item.id === node.id, ); if (event.key === "ArrowDown") { event.preventDefault(); focusAt(index + 1); } else if (event.key === "ArrowUp") { event.preventDefault(); focusAt(index - 1); } else if (event.key === "Home") { event.preventDefault(); focusAt(0); } else if (event.key === "End") { event.preventDefault(); focusAt(visibleNodes.length - 1); } else if (event.key === "ArrowRight" && folder) { event.preventDefault(); if (!isOpen) { toggle(node.id); frame.current = requestAnimationFrame(() => refs.current.get(node.children![0].id)?.focus(), ); } else focusAt(index + 1); } else if (event.key === "ArrowLeft") { event.preventDefault(); if (folder && isOpen) toggle(node.id); else { const parent = visibleNodes .slice(0, index) .reverse() .find((item) => item.children?.some((child) => child.id === node.id), ); if (parent) refs.current.get(parent.id)?.focus(); } } else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(node.id); if (folder) toggle(node.id); } }} > <div className={cn( "flex min-h-9 cursor-default items-center gap-2 rounded-md px-2 text-left text-sm", isSelected ? "bg-secondary text-(--color-ink)" : "text-(--color-ink-2)", )} > {folder ? ( <motion.span aria-hidden="true" animate={{ transform: `rotate(${isOpen ? 90 : 0}deg)` }} transition={{ duration: reduce ? 0.01 : 0.16, ease: [0.23, 1, 0.32, 1], }} > <ChevronRight className="size-3.5" /> </motion.span> ) : ( <span className="w-3.5" /> )} {folder ? ( isOpen ? ( <FolderOpen className="size-4 text-(--color-ink-muted)" /> ) : ( <Folder className="size-4 text-(--color-ink-muted)" /> ) ) : ( <FileText className="size-4 text-(--color-ink-muted)" /> )} <span id={`${id}-${node.id}-label`}>{node.label}</span> </div> <AnimatePresence initial={false}> {folder && isOpen && ( <motion.div role="presentation" initial={ reduce ? false : { opacity: 0, transform: "translateY(-3px)" } } animate={{ opacity: 1, transform: "translateY(0px)" }} exit={{ opacity: 0, transform: reduce ? "translateY(0px)" : "translateY(-3px)", }} transition={{ duration: reduce ? 0.01 : 0.16, ease: [0.23, 1, 0.32, 1], }} > {renderNodes(node.children!, depth + 1)} </motion.div> )} </AnimatePresence> </div> </motion.li> ); })} </AnimatePresence> </ul> ); return <div className={cn("w-full", className)}>{renderNodes(nodes)}</div>;}
Usage
tsx
import { AnimatedTree, type TreeNode } from "@/components/ui/animated-tree"; const nodes: TreeNode[] = [ { id: "src", label: "src", children: [{ id: "app", label: "app.tsx" }] },]; <AnimatedTree nodes={nodes} defaultExpanded={["src"]} />;
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| nodes | TreeNode[] | Yes | — | Nested nodes; every node needs a stable, unique id. |
| value | string | No | — | Controlled selected node id. |
| defaultValue | string | No | — | Initial selected node id for uncontrolled use. |
| onValueChange | (id: string) => void | No | — | Called when a node is selected. |
| defaultExpanded | string[] | No | — | Node ids expanded on first render. |
| className | string | No | — | Optional root class names. |
Motion notes
Branch entry and chevron rotation use short ease-out transitions; arrow-key navigation itself is immediate.