A nested, file-explorer style tree whose folders expand and collapse with an animated height reveal and a rotating chevron. Supports single selection, indent guides and controlled or uncontrolled selection and expansion.
Preview
- src
- app
- components
- button.tsx
- dialog.tsx
- tree-view.tsx
- hooks
- lib
- public
- package.json
- README.md
- tsconfig.json
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add tree-viewWrites components/ui/tree-view.tsx and components/styles/tree-view.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { TreeView } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <TreeView />6 );7}Props
Accessibility
Implements the WAI-ARIA tree pattern: role="tree" (named by its header or aria-label) containing role="treeitem" elements with aria-level, aria-setsize, aria-posinset, aria-selected and aria-expanded on folders; nested items live in role="group" lists. Roving tabindex gives one Tab stop. Up/Down move between visible items, Right expands a folder or moves to its first child, Left collapses or moves to the parent, Home/End jump to the first/last visible item, Enter/Space select (and toggle folders). Focus is shown with a 2px outline. Under prefers-reduced-motion folders open instantly.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/tree-view.module.css";6 7export interface TreeNode {8 /** Unique id (defaults to the slash-joined label path) */9 id?: string;10 /** Visible label */11 label: string;12 /** Child nodes; a node with a children array is a folder */13 children?: TreeNode[];14}15 16export interface TreeViewProps {17 /** Tree data (defaults to a small project file explorer) */18 items?: TreeNode[];19 /** Accessible name of the tree (also the visible header when showHeader is true) */20 label?: string;21 /** Show the label as a header above the tree */22 showHeader?: boolean;23 /** Controlled selected node id */24 value?: string;25 /** Initially selected node id when uncontrolled */26 defaultValue?: string;27 /** Called with the id of the newly selected node */28 onValueChange?: (id: string) => void;29 /** Controlled list of expanded folder ids */30 expanded?: string[];31 /** Initially expanded folder ids when uncontrolled */32 defaultExpanded?: string[];33 /** Called with the next list of expanded folder ids */34 onExpandedChange?: (ids: string[]) => void;35 /** Start with every folder expanded (uncontrolled only) */36 expandAll?: boolean;37 /** Color of the selection highlight and focus tint */38 accentColor?: string;39 /** Row density */40 size?: "sm" | "md";41 /** Show folder/file icons */42 showIcons?: boolean;43 /** Draw vertical indent guides */44 showGuides?: boolean;45 /** Indentation per level in px */46 indent?: number;47 /** Optional className merged onto the root */48 className?: string;49 /** Optional inline styles merged onto the root */50 style?: React.CSSProperties;51}52 53const f = (label: string): TreeNode => ({ label });54const DEFAULT_ITEMS: TreeNode[] = [55 {56 label: "src",57 children: [58 { label: "app", children: [f("layout.tsx"), f("page.tsx"), f("globals.css")] },59 { label: "components", children: [f("button.tsx"), f("dialog.tsx"), f("tree-view.tsx")] },60 { label: "hooks", children: [f("use-media-query.ts"), f("use-theme.ts")] },61 { label: "lib", children: [f("utils.ts")] },62 ],63 },64 { label: "public", children: [f("favicon.ico"), f("og-image.png")] },65 f("package.json"),66 f("README.md"),67 f("tsconfig.json"),68];69 70interface Flat {71 id: string;72 label: string;73 parent: string | null;74 folder: boolean;75}76 77const idOf = (n: TreeNode, prefix: string) => n.id ?? (prefix ? `${prefix}/${n.label}` : n.label);78 79function flatten(nodes: TreeNode[], open: Set<string> | null, prefix = "", parent: string | null = null, out: Flat[] = []) {80 for (const n of nodes) {81 const id = idOf(n, prefix);82 out.push({ id, label: n.label, parent, folder: Array.isArray(n.children) });83 if (n.children && (!open || open.has(id))) flatten(n.children, open, id, id, out);84 }85 return out;86}87 88const EXT_COLORS: Record<string, string> = { tsx: "#3b82f6", ts: "#3b82f6", css: "#ec4899", json: "#eab308", md: "#a1a1aa", png: "#22c55e", ico: "#f97316" };89 90function Icon({ folder, open, label }: { folder: boolean; open: boolean; label: string }) {91 if (folder)92 return (93 <svg aria-hidden="true" className={styles.icon} data-folder="" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round">94 {open ? <path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v1H8.5a2 2 0 0 0-1.9 1.4L4 19H5a2 2 0 0 1-2-2zM6.6 11.4A2 2 0 0 1 8.5 10H21l-2.6 7.6a2 2 0 0 1-1.9 1.4H4z" /> : <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />}95 </svg>96 );97 const ext = label.split(".").pop() ?? "";98 return (99 <svg aria-hidden="true" className={styles.icon} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round">100 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />101 <path d="M14 3v5h5" />102 <circle cx="12" cy="15" r="2.2" fill={EXT_COLORS[ext] ?? "currentColor"} stroke="none" />103 </svg>104 );105}106 107export function TreeView({108 items = DEFAULT_ITEMS,109 label = "Project files",110 showHeader = true,111 value: valueProp,112 defaultValue = "src/components/tree-view.tsx",113 onValueChange,114 expanded: expandedProp,115 defaultExpanded = ["src", "src/components"],116 onExpandedChange,117 expandAll = false,118 accentColor = "#6366f1",119 size = "md",120 showIcons = true,121 showGuides = true,122 indent = 16,123 className = "",124 style,125}: TreeViewProps) {126 const reduce = useReducedMotion();127 const all = React.useMemo(() => flatten(items, null), [items]);128 const [innerOpen, setInnerOpen] = React.useState<string[]>(() => (expandAll ? all.filter((n) => n.folder).map((n) => n.id) : defaultExpanded));129 const [innerSel, setInnerSel] = React.useState<string | undefined>(defaultValue);130 const openList = expandedProp ?? innerOpen;131 const selected = valueProp !== undefined ? valueProp : innerSel;132 const open = React.useMemo(() => new Set(openList), [openList]);133 const visible = React.useMemo(() => flatten(items, open), [items, open]);134 const byId = React.useMemo(() => new Map(all.map((n) => [n.id, n])), [all]);135 const [focusId, setFocusId] = React.useState<string | null>(null);136 const refs = React.useRef(new Map<string, HTMLLIElement>());137 const headId = React.useId();138 139 const tabId = visible.some((n) => n.id === focusId) ? focusId : visible.some((n) => n.id === selected) ? selected : visible[0]?.id;140 141 const setOpen = (id: string, next: boolean) => {142 if (open.has(id) === next) return;143 const list = next ? [...openList, id] : openList.filter((x) => x !== id);144 if (expandedProp === undefined) setInnerOpen(list);145 onExpandedChange?.(list);146 };147 const select = (id: string) => {148 if (valueProp === undefined) setInnerSel(id);149 if (id !== selected) onValueChange?.(id);150 };151 const focus = (id: string | undefined) => {152 if (!id) return;153 setFocusId(id);154 refs.current.get(id)?.focus();155 };156 const activate = (n: Flat) => {157 select(n.id);158 if (n.folder) setOpen(n.id, !open.has(n.id));159 };160 161 const onKeyDown = (e: React.KeyboardEvent<HTMLUListElement>) => {162 const i = visible.findIndex((n) => n.id === tabId);163 const cur = visible[i];164 if (!cur) return;165 const k = e.key;166 if (k === "ArrowDown") focus(visible[i + 1]?.id);167 else if (k === "ArrowUp") focus(visible[i - 1]?.id);168 else if (k === "Home") focus(visible[0]?.id);169 else if (k === "End") focus(visible[visible.length - 1]?.id);170 else if (k === "ArrowRight") {171 if (cur.folder && !open.has(cur.id)) setOpen(cur.id, true);172 else if (cur.folder && visible[i + 1]?.parent === cur.id) focus(visible[i + 1].id);173 } else if (k === "ArrowLeft") {174 if (cur.folder && open.has(cur.id)) setOpen(cur.id, false);175 else if (cur.parent) focus(cur.parent);176 } else if (k === "Enter" || k === " ") activate(cur);177 else return;178 e.preventDefault();179 };180 181 const transition = reduce ? { duration: 0 } : { duration: 0.22, ease: [0.16, 1, 0.3, 1] as const };182 183 const renderLevel = (nodes: TreeNode[], level: number, prefix: string): React.ReactNode =>184 nodes.map((node, idx) => {185 const id = idOf(node, prefix);186 const folder = Array.isArray(node.children);187 const isOpen = folder && open.has(id);188 const isSel = id === selected;189 const flat = byId.get(id) ?? { id, label: node.label, parent: prefix || null, folder };190 return (191 <li192 key={id}193 ref={(el) => {194 if (el) refs.current.set(id, el);195 else refs.current.delete(id);196 }}197 role="treeitem"198 aria-level={level}199 aria-setsize={nodes.length}200 aria-posinset={idx + 1}201 aria-expanded={folder ? isOpen : undefined}202 aria-selected={isSel}203 tabIndex={id === tabId ? 0 : -1}204 className={styles.item}205 onFocus={(e) => {206 e.stopPropagation();207 setFocusId(id);208 }}209 >210 <div211 className={styles.row}212 data-selected={isSel ? "" : undefined}213 style={{ paddingLeft: `calc(${(level - 1) * indent}px + 0.375rem)` }}214 onClick={() => {215 activate(flat);216 focus(id);217 }}218 >219 {showGuides &&220 Array.from({ length: level - 1 }, (_, g) => (221 <span key={g} aria-hidden="true" className={styles.guide} style={{ left: `calc(${g * indent + indent / 2}px + 0.375rem)` }} />222 ))}223 <span aria-hidden="true" className={styles.chevron} data-open={isOpen ? "" : undefined} data-hidden={folder ? undefined : ""}>224 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">225 <path d="m9 6 6 6-6 6" />226 </svg>227 </span>228 {showIcons && <Icon folder={folder} open={isOpen} label={node.label} />}229 <span className={styles.text}>{node.label}</span>230 </div>231 {folder && (232 <AnimatePresence initial={false}>233 {isOpen && (234 <motion.ul235 role="group"236 className={styles.group}237 initial={{ height: 0, opacity: 0 }}238 animate={{ height: "auto", opacity: 1 }}239 exit={{ height: 0, opacity: 0 }}240 transition={transition}241 >242 {renderLevel(node.children ?? [], level + 1, id)}243 </motion.ul>244 )}245 </AnimatePresence>246 )}247 </li>248 );249 });250 251 return (252 <div253 className={`${styles.root} ${className}`.trim()}254 data-size={size}255 style={{ "--mf-tv-accent": accentColor, ...style } as React.CSSProperties}256 >257 {showHeader && label && (258 <div id={headId} className={styles.header}>259 {label}260 </div>261 )}262 <ul263 role="tree"264 aria-labelledby={showHeader && label ? headId : undefined}265 aria-label={showHeader && label ? undefined : label || "Tree"}266 className={styles.tree}267 onKeyDown={onKeyDown}268 >269 {renderLevel(items, 1, "")}270 </ul>271 </div>272 );273}