Branch tree
A file-branch tree that expands in place on continuous 1px rails, with spring panels that retarget mid-flight and children that close only after their parent.
Loading preview…
Show code
"use client";
import {
animate,
AnimatePresence,
motion,
PresenceContext,
usePresence,
useReducedMotion,
type Transition,
} from "framer-motion";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
export type BranchNode = {
/** Stable id. Open state is keyed on it, so derive it from a slug, never a label. */
id: string;
label: string;
/** Plain lines, rendered as a rail-and-tick list one level deeper. */
lines?: string[];
children?: BranchNode[];
};
export type BranchContentContext = {
depth: number;
/** The node's siblings, in order, and the tree's open ids, so content can
* respond to what else is open (e.g. sibling-scale). */
siblings: readonly BranchNode[];
openIds: ReadonlySet<string>;
};
interface BranchTreeProps {
nodes: BranchNode[];
/** Open on first render. Children of an open node are not opened for you. */
defaultOpenIds?: string[];
/** Custom content for a node, rendered above its children inside the panel. */
renderContent?: (node: BranchNode, context: BranchContentContext) => ReactNode;
/** Shows the Expand / Collapse control in the top-right corner. */
expandAll?: boolean;
className?: string;
}
/**
* A spring rather than a tween because opening a second branch mid-animation is
* the common gesture here: a spring retargets from the current height with its
* velocity intact, where a tween restarts a fresh curve and reads as a stutter.
* bounce 0 because overshooting a height would clip the content.
*/
const panelSpring = { type: "spring", duration: 0.4, bounce: 0 } as const;
function collectIds(nodes: BranchNode[]): string[] {
return nodes.flatMap((node) => [node.id, ...collectIds(node.children ?? [])]);
}
/** Every descendant id under each node, so collapsing a parent can clear them. */
function buildDescendantsMap(nodes: BranchNode[]): Map<string, string[]> {
const map = new Map<string, string[]>();
function walk(node: BranchNode): string[] {
const descendants = (node.children ?? []).flatMap((child) => [child.id, ...walk(child)]);
map.set(node.id, descendants);
return descendants;
}
for (const node of nodes) walk(node);
return map;
}
/** One <li>: a continuous 1px rail (a single box, so it cannot seam) and a 16px tick. */
function Item({ children }: { children: ReactNode }) {
return (
<li
className={cn(
"relative",
"before:absolute before:top-0 before:bottom-0 before:z-[1] before:w-px before:bg-foreground",
"last:before:bottom-auto last:before:h-[9px]",
// Rail and tick share one expression, so they cannot drift apart.
"before:left-[calc(var(--rail-inset)-var(--rail))]",
)}
>
{children}
</li>
);
}
function Row({ children }: { children: ReactNode }) {
return (
<div
className={cn(
"relative z-[1] flex min-h-[18px] items-center",
"before:absolute before:top-[9px] before:h-px before:w-4 before:bg-foreground",
"before:left-[calc(var(--rail-inset)-var(--rail))]",
)}
>
{children}
</div>
);
}
function List({ root, children }: { root?: boolean; children: ReactNode }) {
return (
<ul
data-root={root ? "" : undefined}
// minmax(0, 1fr): a grid track otherwise grows to its widest child, so a
// wide media band would stretch the tree instead of scrolling inside it.
className="relative m-0 grid list-none grid-cols-[minmax(0,1fr)] gap-0 p-0 pl-[var(--rail)]"
>
{children}
</ul>
);
}
/**
* Text is never a block floating free of the rails: each line gets its own
* tick. Lines are usually strings; any inline node (a loading bar) works too.
*/
export function BranchLines({ lines }: { lines: ReactNode[] }) {
return (
<List>
{lines.map((line, index) => (
<Item key={typeof line === "string" ? `${index}-${line}` : index}>
<Row>
<span className="min-w-0 leading-[18px] [overflow-wrap:anywhere]">{line}</span>
</Row>
</Item>
))}
</List>
);
}
type BranchProps = {
nodes: BranchNode[];
depth: number;
openIds: Set<string>;
onToggle: (id: string) => void;
onExited: (id: string) => void;
instant: boolean;
renderContent?: BranchTreeProps["renderContent"];
};
function Branch(props: BranchProps) {
const { nodes, depth } = props;
return (
<List root={depth === 0}>
{nodes.map((node) => (
<Node
key={node.id}
{...props}
node={node}
siblings={nodes}
/>
))}
</List>
);
}
/**
* The animated panel. Height is measured with offsetHeight, which is layout
* size, and driven through framer's animate(). Animating to `height: "auto"`
* makes framer measure with getBoundingClientRect, which includes ancestor
* transforms: under any scale() the spring ran to the scaled height and then
* snapped to the real one when it switched back to auto, on every open and
* close. Calling animate() on the element again mid-flight retargets from the
* current height with its velocity, so a reversed panel never restarts.
*/
function Panel({
id,
transition,
instant,
children,
}: {
id: string;
transition: Transition;
instant: boolean;
children: ReactNode;
}) {
const [isPresent, safeToRemove] = usePresence();
// AnimatePresence initial={false}: panels open on first render don't animate in.
const presence = useContext(PresenceContext);
const [appear] = useState(() => presence?.initial !== false && !instant);
const ref = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const run = useRef(0);
useLayoutEffect(() => {
const el = ref.current;
const body = bodyRef.current;
if (!el || !body) return;
const token = ++run.current;
const settled = el.style.height === "" || el.style.height === "auto";
if (isPresent) {
if (settled) return; // first paint at auto, nothing to animate
animate(el, { height: body.offsetHeight }, transition).then(() => {
// Back to auto so nested panels opening later can grow it.
if (run.current === token) el.style.height = "auto";
});
return;
}
// Closing: from a settled auto height start at the real layout height;
// mid-flight, retarget the running spring.
const target = settled ? [el.offsetHeight, 0] : 0;
animate(el, { height: target }, transition).then(() => {
if (run.current === token) safeToRemove?.();
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- runs per presence change only
}, [isPresent]);
useEffect(() => () => void run.current++, []);
return (
<div
ref={ref}
id={id}
// Clip the block axis only, so the height animates while wide content
// (a media band) can still escape sideways. Scroll anchoring off: it
// keeps correcting the offset while one panel closes and another opens.
className="-mx-1 overflow-x-visible overflow-y-clip px-1 [overflow-anchor:none]"
style={appear ? { height: 0 } : undefined}
>
<div ref={bodyRef}>{children}</div>
</div>
);
}
function Node({
node,
depth,
openIds,
onToggle,
onExited,
instant,
renderContent,
siblings,
}: BranchProps & { node: BranchNode; siblings: BranchNode[] }) {
const reduceMotion = useReducedMotion();
const content = renderContent?.(node, { depth, siblings, openIds }) ?? null;
const hasLines = Boolean(node.lines?.length);
const isOpen = openIds.has(node.id);
const panelId = `${node.id}-panel`;
const transition = reduceMotion || instant ? { duration: 0 } : panelSpring;
const skipMotion = Boolean(reduceMotion || instant);
if (!content && !hasLines && !node.children?.length) {
return (
<Item>
<Row>
<span className="min-w-0 leading-[18px] [overflow-wrap:anywhere]">{node.label}</span>
</Row>
</Item>
);
}
return (
<Item>
<Row>
<button
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => onToggle(node.id)}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-[2px] border-0 bg-transparent p-0",
"font-medium leading-[18px] text-foreground [-webkit-tap-highlight-color:transparent]",
"transition-colors hover:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
)}
>
<span>{node.label}</span>
<motion.span
aria-hidden="true"
className="inline-flex size-1.5 flex-none items-center justify-center text-foreground"
initial={false}
animate={{ rotate: isOpen ? 90 : 0 }}
transition={transition}
>
<svg width="6" height="6" viewBox="0 0 8 8" fill="none" className="block">
<path
d="M2.5 1 L6 4 L2.5 7"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
</button>
</Row>
<AnimatePresence initial={false} onExitComplete={() => onExited(node.id)}>
{isOpen ? (
<Panel id={panelId} transition={transition} instant={skipMotion}>
{/* Flex column so child margins cannot collapse, and every open-state
gap is padding on this inner box: the measured height is the
border box, and the panel itself can still reach zero. */}
<div
className={cn(
"flex flex-col",
depth === 0 && "pb-[var(--gap)]",
depth === 0 && !content && "pt-[var(--gap)]",
)}
>
{content ? <div className="min-w-0 py-[var(--gap)]">{content}</div> : null}
{hasLines ? <BranchLines lines={node.lines!} /> : null}
{node.children?.length ? (
<Branch
nodes={node.children}
depth={depth + 1}
openIds={openIds}
onToggle={onToggle}
onExited={onExited}
instant={instant}
renderContent={renderContent}
/>
) : null}
</div>
</Panel>
) : null}
</AnimatePresence>
</Item>
);
}
/**
* A file-branch tree that expands in place. One component owns open state (a
* single Set of ids), so Expand / Collapse, descendant pruning and sibling
* scaling all agree. It is not an accordion: any number of branches stay open.
*/
export function BranchTree({
nodes,
defaultOpenIds = [],
renderContent,
expandAll = true,
className,
}: BranchTreeProps) {
const [openIds, setOpenIds] = useState<Set<string>>(() => new Set(defaultOpenIds));
// Bulk Expand / Collapse changes every nested level at once; nested
// height:auto animations would fight and snap, so that one change is instant.
const [instant, setInstant] = useState(false);
const allIds = useMemo(() => collectIds(nodes), [nodes]);
const descendantsMap = useMemo(() => buildDescendantsMap(nodes), [nodes]);
const allExpanded = allIds.length > 0 && allIds.every((id) => openIds.has(id));
const onToggle = useCallback((id: string) => {
setOpenIds((prev) => {
const next = new Set(prev);
// Closing removes only the node itself. Its descendants stay open until
// the panel has finished exiting (onExited), so nested panels never snap.
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const onExited = useCallback(
(id: string) => {
setOpenIds((prev) => {
if (prev.has(id)) return prev;
const descendants = descendantsMap.get(id) ?? [];
if (!descendants.some((d) => prev.has(d))) return prev;
const next = new Set(prev);
for (const d of descendants) next.delete(d);
return next;
});
},
[descendantsMap],
);
const onExpandCollapse = useCallback(() => {
setInstant(true);
setOpenIds(allExpanded ? new Set() : new Set(allIds));
}, [allExpanded, allIds]);
useEffect(() => {
if (!instant) return;
const frame = requestAnimationFrame(() => setInstant(false));
return () => cancelAnimationFrame(frame);
}, [instant, openIds]);
return (
<div
className={cn("relative text-xs leading-4 text-foreground", className)}
style={{ "--rail": "24px", "--rail-inset": "4px", "--gap": "4px" } as CSSProperties}
>
<Branch
nodes={nodes}
depth={0}
openIds={openIds}
onToggle={onToggle}
onExited={onExited}
instant={instant}
renderContent={renderContent}
/>
{expandAll ? (
<button
type="button"
onClick={onExpandCollapse}
className={cn(
"absolute top-0 right-0 z-20 cursor-pointer rounded-[2px] border-0 bg-transparent p-0",
"font-medium leading-[18px] text-foreground transition-colors hover:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
)}
>
{allExpanded ? "Collapse" : "Expand"}
</button>
) : null}
</div>
);
}