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-react

Copy the source below and adjust the cn utility import to your project.

components/ui/animated-tree.tsx
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

PropTypeRequiredDefaultDescription
nodesTreeNode[]Yes—Nested nodes; every node needs a stable, unique id.
valuestringNo—Controlled selected node id.
defaultValuestringNo—Initial selected node id for uncontrolled use.
onValueChange(id: string) => voidNo—Called when a node is selected.
defaultExpandedstring[]No—Node ids expanded on first render.
classNamestringNo—Optional root class names.

Motion notes

Branch entry and chevron rotation use short ease-out transitions; arrow-key navigation itself is immediate.