Skip to content

A file tree that opens by height, lights its branch and types ahead.

01Preview

Explorerbilling-api
src
routes
invoices.tsM
webhooks.tsM
customers.ts
server.ts
.env.example
package.json
README.md
src/routes/webhooks.ts

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { TreeView } from "@/components/ui/tree-view";

<TreeView
  aria-label="Files"
  items={[
    { id: "src", label: "src", children: [{ id: "src/server.ts", label: "server.ts" }] },
    { id: "vendor", label: "vendor", hasChildren: true },
    { id: "README.md", label: "README.md", meta: "4 KB" },
  ]}
  defaultExpanded={["src"]}
  loadChildren={(node) => fetch(`/api/tree/${node.id}`).then((r) => r.json())}
  onAction={(node) => openFile(node.id)}
/>

04Source

"use client";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ChevronRight } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

export type TreeNode = {
  id: string;
  label: string;
  /** Replaces the default folder or file glyph. */
  icon?: React.ReactNode;
  /** Child nodes. An empty array is an empty folder. */
  children?: TreeNode[];
  /** A folder whose children come from loadChildren the first time it opens. */
  hasChildren?: boolean;
  disabled?: boolean;
  /** Quiet right-aligned detail: a size, a count, a status. */
  meta?: React.ReactNode;
};

export type TreeViewProps = Omit<React.ComponentProps<"div">, "children" | "onSelect"> & {
  items: TreeNode[];
  expanded?: string[];
  defaultExpanded?: string[];
  onExpandedChange?: (ids: string[]) => void;
  selected?: string[];
  defaultSelected?: string[];
  onSelectedChange?: (ids: string[]) => void;
  selectionMode?: "single" | "multiple" | "none";
  /** Arrow keys move the selection along with focus, the way file explorers do. Single mode only. */
  selectionFollowsFocus?: boolean;
  /** Clicking a folder's row also opens or closes it. The chevron always does. */
  expandOnClick?: boolean;
  /** Enter or double-click on a leaf. */
  onAction?: (node: TreeNode) => void;
  /** Fetches the children of a node marked hasChildren, the first time it opens. */
  loadChildren?: (node: TreeNode) => Promise<TreeNode[]>;
  /** Vertical lines under each open folder. The branch holding the selection draws brighter. */
  indentGuides?: boolean;
};

type Row =
  | { kind: "node"; node: TreeNode; level: number; parent: string | null; ancestors: string[]; pos: number; size: number; branch: boolean; open: boolean }
  | { kind: "status"; status: "loading" | "error" | "empty"; parent: TreeNode; level: number; ancestors: string[] };

const ROW = 28;
const INDENT = 16;

export function TreeView({
  items,
  expanded: expandedProp,
  defaultExpanded = [],
  onExpandedChange,
  selected: selectedProp,
  defaultSelected = [],
  onSelectedChange,
  selectionMode = "single",
  selectionFollowsFocus = false,
  expandOnClick = true,
  onAction,
  loadChildren,
  indentGuides = true,
  className,
  onKeyDown,
  ...rest
}: TreeViewProps) {
  const reduce = !!useReducedMotion();
  const [expanded, setExpanded] = useControllableState({ value: expandedProp, defaultValue: defaultExpanded, onChange: onExpandedChange });
  const [selected, setSelected] = useControllableState({ value: selectedProp, defaultValue: defaultSelected, onChange: onSelectedChange });
  const [loaded, setLoaded] = useState<Record<string, TreeNode[] | "loading" | "error">>({});
  const [focusId, setFocusId] = useState<string | null>(null);
  const [anchor, setAnchor] = useState<string | null>(null);
  // Changes made from the keyboard render on the same frame; pointer changes animate.
  const [instant, setInstant] = useState(false);
  const [announce, setAnnounce] = useState("");
  const rowEls = useRef(new Map<string, HTMLDivElement>());
  const moveFocus = useRef(false);
  const typed = useRef({ text: "", at: 0 });
  const treeRef = useRef<HTMLDivElement>(null);
  const id = useId();

  const openSet = useMemo(() => new Set(expanded), [expanded]);
  const selectedSet = useMemo(() => new Set(selected), [selected]);

  const childrenOf = useCallback(
    (node: TreeNode): TreeNode[] | "loading" | "error" | undefined => {
      if (node.children) return node.children;
      if (!node.hasChildren) return undefined;
      return loaded[node.id] ?? "loading";
    },
    [loaded],
  );

  // The tree, flattened to the rows you can see. One flat list keeps the keyboard model,
  // the hover highlight and the height animation simple, and is how large trees virtualise.
  const rows = useMemo(() => {
    const out: Row[] = [];
    const walk = (nodes: TreeNode[], level: number, parent: string | null, ancestors: string[]) => {
      nodes.forEach((node, i) => {
        const kids = childrenOf(node);
        const branch = kids !== undefined;
        const open = branch && openSet.has(node.id);
        out.push({ kind: "node", node, level, parent, ancestors, pos: i + 1, size: nodes.length, branch, open });
        if (!open) return;
        const next = [...ancestors, node.id];
        if (kids === "loading" || kids === "error") out.push({ kind: "status", status: kids, parent: node, level: level + 1, ancestors: next });
        else if (kids!.length === 0) out.push({ kind: "status", status: "empty", parent: node, level: level + 1, ancestors: next });
        else walk(kids!, level + 1, node.id, next);
      });
    };
    walk(items, 1, null, []);
    return out;
  }, [items, openSet, childrenOf]);

  const nodeRows = rows.filter((r): r is Extract<Row, { kind: "node" }> => r.kind === "node");
  const byId = new Map(nodeRows.map((r) => [r.node.id, r]));

  // If the focused row was folded away, focus falls back to its nearest visible ancestor.
  let current = focusId && byId.has(focusId) ? focusId : null;
  if (focusId && !current) {
    const lost = findPath(items, focusId, childrenOf);
    current = lost?.reverse().find((a) => byId.has(a)) ?? null;
  }
  const tabStop = current ?? selected.find((s) => byId.has(s)) ?? nodeRows[0]?.node.id ?? null;

  // The branch holding the selection (or focus) lights its guides.
  const litTarget = byId.get(selected[0] ?? "") ?? byId.get(current ?? "");
  const lit = new Set(litTarget?.ancestors ?? []);

  useEffect(() => {
    if (!moveFocus.current || !tabStop) return;
    moveFocus.current = false;
    rowEls.current.get(tabStop)?.focus();
  }, [tabStop]);

  /* ---------------------------------------------------------------- actions */

  const load = useCallback(
    (node: TreeNode) => {
      if (!loadChildren || node.children || !node.hasChildren) return;
      const state = loaded[node.id];
      if (state && state !== "error") return;
      setLoaded((l) => ({ ...l, [node.id]: "loading" }));
      setAnnounce(`Loading ${node.label}`);
      loadChildren(node).then(
        (kids) => {
          setLoaded((l) => ({ ...l, [node.id]: kids }));
          setAnnounce(kids.length ? `${node.label}, ${kids.length} ${kids.length === 1 ? "item" : "items"}` : `${node.label} is empty`);
        },
        () => {
          setLoaded((l) => ({ ...l, [node.id]: "error" }));
          setAnnounce(`Couldn't load ${node.label}. Press Right arrow to try again.`);
        },
      );
    },
    [loadChildren, loaded],
  );

  const setOpen = (node: TreeNode, open: boolean) => {
    if (open) load(node);
    if (open === openSet.has(node.id)) return;
    setExpanded(open ? [...expanded, node.id] : expanded.filter((e) => e !== node.id));
  };

  const select = (nodeId: string, mode: "replace" | "toggle" | "range") => {
    if (selectionMode === "none") return;
    const node = byId.get(nodeId)?.node;
    if (!node || node.disabled) return;
    if (selectionMode === "single" || mode === "replace") {
      setSelected([nodeId]);
      setAnchor(nodeId);
    } else if (mode === "toggle") {
      setSelected(selectedSet.has(nodeId) ? selected.filter((s) => s !== nodeId) : [...selected, nodeId]);
      setAnchor(nodeId);
    } else {
      const from = nodeRows.findIndex((r) => r.node.id === (anchor ?? nodeId));
      const to = nodeRows.findIndex((r) => r.node.id === nodeId);
      const [a, b] = from < to ? [from, to] : [to, from];
      setSelected(nodeRows.slice(a, b + 1).filter((r) => !r.node.disabled).map((r) => r.node.id));
    }
  };

  const focusRow = (nodeId: string | undefined, fromKeyboard: boolean) => {
    if (!nodeId) return;
    moveFocus.current = true;
    setFocusId(nodeId);
    if (fromKeyboard && nodeId === tabStop) rowEls.current.get(nodeId)?.focus();
  };

  /* ---------------------------------------------------------------- keyboard (APG tree view) */

  const handleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(e);
    if (e.defaultPrevented || !tabStop) return;
    const row = byId.get(tabStop);
    if (!row) return;
    const index = nodeRows.indexOf(row);
    const { node } = row;
    const multi = selectionMode === "multiple";
    setInstant(true);

    const go = (target: (typeof nodeRows)[number] | undefined) => {
      if (!target) return;
      focusRow(target.node.id, true);
      if (multi && e.shiftKey) select(target.node.id, "range");
      else if (selectionMode === "single" && selectionFollowsFocus) select(target.node.id, "replace");
    };

    switch (e.key) {
      case "ArrowDown":
        go(nodeRows[index + 1]);
        break;
      case "ArrowUp":
        go(nodeRows[index - 1]);
        break;
      case "Home":
        go(nodeRows[0]);
        break;
      case "End":
        go(nodeRows[nodeRows.length - 1]);
        break;
      case "ArrowRight":
        if (!row.branch) return;
        if (!row.open || loaded[node.id] === "error") setOpen(node, true);
        else go(nodeRows[index + 1]?.parent === node.id ? nodeRows[index + 1] : undefined);
        break;
      case "ArrowLeft":
        if (row.branch && row.open) setOpen(node, false);
        else go(row.parent ? byId.get(row.parent) : undefined);
        break;
      case "Enter":
        if (row.branch) setOpen(node, !row.open);
        else if (!node.disabled) {
          select(node.id, "replace");
          onAction?.(node);
        }
        break;
      case " ":
        select(node.id, multi ? "toggle" : "replace");
        break;
      case "*": {
        // Opens every sibling folder at this level, per the tree pattern.
        const siblings = nodeRows.filter((r) => r.parent === row.parent && r.branch && !r.open);
        siblings.forEach((s) => load(s.node));
        setExpanded([...expanded, ...siblings.map((s) => s.node.id)]);
        break;
      }
      case "a":
        if (!(multi && (e.metaKey || e.ctrlKey))) return typeahead(e);
        setSelected(nodeRows.filter((r) => !r.node.disabled).map((r) => r.node.id));
        break;
      default:
        return typeahead(e);
    }
    e.preventDefault();
  };

  // Typing jumps to the next visible row whose label starts with what you typed.
  const typeahead = (e: React.KeyboardEvent) => {
    if (e.key.length !== 1 || e.metaKey || e.ctrlKey || e.altKey) return;
    const now = e.timeStamp;
    const t = typed.current;
    t.text = now - t.at > 500 ? e.key.toLowerCase() : t.text + e.key.toLowerCase();
    t.at = now;
    const start = nodeRows.findIndex((r) => r.node.id === tabStop);
    // A repeated single letter cycles through matches; a longer string refines the current one.
    const repeated = t.text.length > 1 && [...t.text].every((c) => c === t.text[0]);
    const needle = repeated ? t.text[0] : t.text;
    const from = needle.length === 1 ? start + 1 : start;
    for (let i = 0; i < nodeRows.length; i++) {
      const r = nodeRows[(from + i) % nodeRows.length];
      if (r.node.label.toLowerCase().startsWith(needle)) {
        focusRow(r.node.id, true);
        if (selectionMode === "single" && selectionFollowsFocus) select(r.node.id, "replace");
        break;
      }
    }
    e.preventDefault();
  };

  /* ---------------------------------------------------------------- render */

  const { onMove, onLeave, glide } = useGlide(treeRef, reduce);
  const enter = { duration: instant ? 0 : reduce ? 0.12 : 0.2, ease: ease.out };

  return (
    <div
      ref={treeRef}
      role="tree"
      aria-multiselectable={selectionMode === "multiple" || undefined}
      onKeyDown={handleKey}
      onPointerDown={() => setInstant(false)}
      onPointerMove={onMove}
      onPointerLeave={onLeave}
      className={cn("relative isolate overflow-y-auto overscroll-contain p-1 text-[13px] outline-none select-none", className)}
      {...rest}
    >
      <motion.div aria-hidden style={glide} className="pointer-events-none absolute inset-x-1 top-0 -z-10 rounded-md bg-fg/[0.04]" />
      <AnimatePresence initial={false}>
        {rows.map((row) => {
          const key = row.kind === "node" ? row.node.id : `${row.parent.id}:${row.status}`;
          return (
            <motion.div
              key={key}
              // Rows open by height, so everything below slides down instead of jumping.
              initial={{ height: 0, opacity: 0 }}
              animate={{ height: ROW, opacity: 1 }}
              exit={{ height: 0, opacity: 0, transition: { ...enter, duration: instant ? 0 : reduce ? 0.1 : 0.15 } }}
              transition={reduce ? { height: { duration: 0 }, opacity: enter } : enter}
              className="overflow-hidden"
              role="none"
            >
              {row.kind === "node" ? (
                <TreeRow
                  row={row}
                  selected={selectedSet.has(row.node.id)}
                  tabbable={row.node.id === tabStop}
                  lit={lit}
                  guides={indentGuides}
                  instant={instant}
                  failed={loaded[row.node.id] === "error"}
                  busy={row.open && childrenOf(row.node) === "loading"}
                  ref={(el) => {
                    if (el) rowEls.current.set(row.node.id, el);
                    else rowEls.current.delete(row.node.id);
                  }}
                  onFocus={() => setFocusId(row.node.id)}
                  onToggle={() => setOpen(row.node, !row.open)}
                  onPress={(e) => {
                    setFocusId(row.node.id);
                    if (row.node.disabled) return;
                    if (selectionMode === "multiple" && (e.metaKey || e.ctrlKey)) select(row.node.id, "toggle");
                    else if (selectionMode === "multiple" && e.shiftKey) select(row.node.id, "range");
                    else select(row.node.id, "replace");
                    if (row.branch && expandOnClick && !e.shiftKey && !e.metaKey && !e.ctrlKey) setOpen(row.node, !row.open);
                  }}
                  onDoubleClick={() => !row.branch && !row.node.disabled && onAction?.(row.node)}
                />
              ) : (
                <StatusRow row={row} lit={lit} guides={indentGuides} onRetry={() => load(row.parent)} />
              )}
            </motion.div>
          );
        })}
      </AnimatePresence>
      {rows.length === 0 && <p className="px-2 py-1.5 text-[12.5px] text-fg-3">No files</p>}
      <span id={`${id}-status`} role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Rows
 * -----------------------------------------------------------------------------------------------*/

type NodeRow = Extract<Row, { kind: "node" }>;

function TreeRow({
  row,
  selected,
  tabbable,
  lit,
  guides,
  instant,
  failed,
  busy,
  ref,
  onFocus,
  onToggle,
  onPress,
  onDoubleClick,
}: {
  row: NodeRow;
  selected: boolean;
  tabbable: boolean;
  lit: Set<string>;
  guides: boolean;
  instant: boolean;
  failed: boolean;
  busy: boolean;
  ref: React.Ref<HTMLDivElement>;
  onFocus: () => void;
  onToggle: () => void;
  onPress: (e: React.MouseEvent) => void;
  onDoubleClick: () => void;
}) {
  const { node, level, branch, open } = row;
  return (
    <div
      ref={ref}
      role="treeitem"
      aria-level={level}
      aria-posinset={row.pos}
      aria-setsize={row.size}
      aria-expanded={branch ? open : undefined}
      aria-selected={selected}
      aria-disabled={node.disabled || undefined}
      aria-busy={busy || undefined}
      tabIndex={tabbable ? 0 : -1}
      data-row
      data-selected={selected || undefined}
      onFocus={onFocus}
      onClick={onPress}
      onDoubleClick={onDoubleClick}
      style={{ paddingLeft: 6 + (level - 1) * INDENT }}
      className={cn(
        "group/row relative flex h-7 cursor-default items-center gap-1.5 rounded-md pr-2 text-fg-2 outline-none",
        "transition-[background-color,color] duration-100",
        "focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3",
        selected && "bg-fg/[0.08] text-fg",
        node.disabled && "text-fg-4",
      )}
    >
      {guides && <Guides row={row} lit={lit} />}
      <span
        aria-hidden
        onClick={(e) => {
          if (!branch) return;
          e.stopPropagation();
          onToggle();
        }}
        className={cn("relative grid size-4 shrink-0 place-items-center text-fg-4", branch && "hover:text-fg-2 pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5")}
      >
        {branch &&
          (busy ? (
            <Spinner />
          ) : (
            <ChevronRight
              size={14}
              className={cn("transition-transform duration-180 ease-out-expo motion-reduce:transition-none", open && "rotate-90", instant && "duration-0")}
            />
          ))}
      </span>
      <span aria-hidden className={cn("flex shrink-0 [&_svg]:size-4", selected ? "text-fg-2" : "text-fg-3", failed && "text-danger")}>
        {node.icon ?? (branch ? <FolderGlyph open={open} /> : <FileGlyph />)}
      </span>
      <span className="min-w-0 flex-1 truncate">{node.label}</span>
      {failed ? (
        <span className="shrink-0 text-[11.5px] text-danger">Couldn’t load</span>
      ) : (
        node.meta != null && <span className="shrink-0 font-mono text-2xs text-fg-4 tabular">{node.meta}</span>
      )}
    </div>
  );
}

function StatusRow({ row, lit, guides, onRetry }: { row: Extract<Row, { kind: "status" }>; lit: Set<string>; guides: boolean; onRetry: () => void }) {
  return (
    <div
      aria-hidden
      onClick={row.status === "error" ? onRetry : undefined}
      style={{ paddingLeft: 6 + (row.level - 1) * INDENT + 22 }}
      className={cn("relative flex h-7 items-center gap-1.5 pr-2 text-[12.5px] text-fg-4", row.status === "error" && "cursor-pointer text-fg-3 hover:text-fg")}
    >
      {guides && <Guides row={row} lit={lit} />}
      {row.status === "loading" && <span className="h-2 w-24 animate-pulse-soft rounded-full bg-hover motion-reduce:animate-none" />}
      {row.status === "empty" && "Empty folder"}
      {row.status === "error" && (
        <>
          Couldn’t load <span className="text-fg underline decoration-fg-4 underline-offset-2">Try again</span>
        </>
      )}
    </div>
  );
}

// One hairline per ancestor level, centered under that ancestor's chevron.
function Guides({ row, lit }: { row: { level: number; ancestors: string[] }; lit: Set<string> }) {
  return (
    <>
      {row.ancestors.map((a, i) => (
        <span
          key={a}
          aria-hidden
          style={{ left: 6 + i * INDENT + 7.5 }}
          className={cn("pointer-events-none absolute inset-y-0 w-px transition-colors duration-200", lit.has(a) ? "bg-fg-4" : "bg-line-2")}
        />
      ))}
    </>
  );
}

/* -------------------------------------------------------------------------------------------------
 * The hover highlight that glides between rows
 * -----------------------------------------------------------------------------------------------*/

function useGlide(treeRef: React.RefObject<HTMLDivElement | null>, reduce: boolean) {
  const y = useMotionValue(0);
  const opacity = useMotionValue(0);
  const shown = useRef(false);

  const onMove = (e: React.PointerEvent) => {
    if (e.pointerType !== "mouse") return;
    const row = (e.target as HTMLElement).closest<HTMLElement>("[data-row]");
    if (!row || !treeRef.current?.contains(row) || row.hasAttribute("data-selected")) {
      if (shown.current) animate(opacity, 0, { duration: 0.12 });
      shown.current = false;
      return;
    }
    const top = row.offsetTop;
    if (!shown.current || reduce) {
      y.jump(top);
      animate(opacity, 1, { duration: 0.08 });
    } else animate(y, top, spring.follow);
    shown.current = true;
  };
  const onLeave = () => {
    shown.current = false;
    animate(opacity, 0, { duration: 0.15 });
  };
  useEffect(() => () => opacity.stop(), [opacity]);

  return { onMove, onLeave, glide: { y, opacity, height: ROW } };
}

/* -------------------------------------------------------------------------------------------------
 * Helpers and glyphs
 * -----------------------------------------------------------------------------------------------*/

function findPath(nodes: TreeNode[], target: string, childrenOf: (n: TreeNode) => TreeNode[] | "loading" | "error" | undefined, trail: string[] = []): string[] | null {
  for (const n of nodes) {
    if (n.id === target) return trail;
    const kids = childrenOf(n);
    if (Array.isArray(kids)) {
      const found = findPath(kids, target, childrenOf, [...trail, n.id]);
      if (found) return found;
    }
  }
  return null;
}

function FolderGlyph({ open }: { open: boolean }) {
  return (
    <svg width={16} height={16} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinejoin="round" aria-hidden>
      {open ? (
        <path d="M2.5 11.75v-7.5c0-.4.35-.75.75-.75h3l1.5 1.5h4.5c.4 0 .75.35.75.75V7M2.5 11.75 4.2 7.6a1 1 0 0 1 .93-.6h8.37a.5.5 0 0 1 .46.7l-1.6 3.9a1 1 0 0 1-.93.65H3.25a.75.75 0 0 1-.75-.5z" />
      ) : (
        <path d="M2.5 4.25c0-.4.35-.75.75-.75h3l1.5 1.5h5c.4 0 .75.35.75.75v6.5c0 .4-.35.75-.75.75h-9.5a.75.75 0 0 1-.75-.75z" />
      )}
    </svg>
  );
}

function FileGlyph() {
  return (
    <svg width={16} height={16} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinejoin="round" aria-hidden>
      <path d="M4 2.5h5l3.5 3.5v7.5H4zM9 2.5V6h3.5" />
    </svg>
  );
}

function Spinner() {
  return (
    <svg width={12} height={12} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" className="animate-spin-slow motion-reduce:animate-none" aria-hidden>
      <path d="M8 2.25a5.75 5.75 0 1 0 5.75 5.75" />
    </svg>
  );
}

05Props

PropTypeDefaultDescription
items*TreeNode[]Nodes with id, label, and optional icon, children, hasChildren, disabled and meta. An empty children array is an empty folder.
expandedstring[]Open folder ids, controlled with onExpandedChange.
defaultExpandedstring[][]Folders open at first render.
onExpandedChange(ids: string[]) => voidCalled when folders open or close.
selectedstring[]Selected ids, controlled with onSelectedChange.
defaultSelectedstring[][]Selection at first render.
onSelectedChange(ids: string[]) => voidCalled when the selection changes.
selectionMode"single" | "multiple" | "none""single"Multiple adds ⌘/Ctrl-click, Shift-click ranges, Space to toggle, Shift+arrows and ⌘A.
selectionFollowsFocusbooleanfalseIn single mode, arrow keys move the selection with focus.
expandOnClickbooleantrueClicking a folder's row also toggles it. The chevron always does.
onAction(node: TreeNode) => voidEnter or double-click on a leaf: open the file.
loadChildren(node: TreeNode) => Promise<TreeNode[]>Loads a hasChildren node's children the first time it opens, with loading, empty and retryable error rows.
indentGuidesbooleantrueHairlines under open folders; the branch holding the selection draws brighter.

06Notes

Behavior

  • The tree is flattened to its visible rows, so keyboard order, the hover highlight and the open/close animation all work on one list, and the structure is ready to virtualise.
  • Folders that load lazily show a skeleton row, then their children; a failure marks the folder “Couldn’t load” with a Try again row, and Right arrow retries.
  • Folding away the row that had focus hands focus to its nearest visible ancestor instead of losing it.
  • Long names truncate; the right-hand meta (a size, a git status) never wraps under them.

Motion

  • Rows open and close by height, 200ms on the expo ease-out (150ms closing), so everything below slides rather than jumps; the chevron turns 90° in 180ms.
  • One hover wash glides between rows on the follow spring (650/45) and fades out when the pointer leaves.
  • Anything done from the keyboard (arrows, Right to open, typeahead) renders on the same frame. Reduced motion opens rows instantly with a 120ms fade.

Accessibility

  • role="tree" with treeitems carrying aria-level, aria-setsize, aria-posinset, aria-expanded and aria-selected; one roving tab stop.
  • The full APG keyboard: ↑↓ move, → opens or enters, ← closes or climbs, Home/End, Enter opens files and toggles folders, * opens every sibling, and typing jumps by name.
  • Loading, results and failures are announced in a polite live region; status rows are hidden from assistive tech because the folder already carries aria-busy.