UIData Displaymotion/react

TreeView

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

Project files
  • src
    • components
      • button.tsx
      • dialog.tsx
      • tree-view.tsx
  • package.json
  • README.md
  • tsconfig.json

Customize

9 props
16

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add tree-view

Writes 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.

example.tsx
1import { TreeView } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <TreeView />6  );7}

Props

PropTypeDefault
items

Tree data. A node with a children array is a folder; ids default to the slash-joined label path (e.g. 'src/app/page.tsx').

TreeNode[] // { id?: string; label: string; children?: TreeNode[] }project file tree
label

Accessible name of the tree, shown as a header when showHeader is true.

string'Project files'
showHeader

Show the label as an uppercase header above the tree.

booleantrue
value

Controlled selected node id.

stringundefined
defaultValue

Initially selected node id when uncontrolled.

string'src/components/tree-view.tsx'
onValueChange

Called with the id of the newly selected node.

(id: string) => voidundefined
expanded

Controlled list of expanded folder ids.

string[]undefined
defaultExpanded

Initially expanded folder ids when uncontrolled.

string[]['src', 'src/components']
onExpandedChange

Called with the next list of expanded folder ids.

(ids: string[]) => voidundefined
expandAll

Start with every folder expanded (uncontrolled only; overrides defaultExpanded).

booleanfalse
accentColor

Selection highlight, selection bar and folder icon color.

string'#6366f1'
size

Row height and type size.

'sm' | 'md''md'
showIcons

Show folder and file icons (files get an extension-colored dot).

booleantrue
showGuides

Draw vertical indent guides for nested levels.

booleantrue
indent

Indentation per level in px.

number16
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

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