Tree view whose branches unfold like a carpenter's folding rule — each child group is a hinged segment that swings open around a visible pivot, rows unfolding in sequence, and snaps shut faster than it opens.
npx shadcn add https://design.helpmarq.com /r/tree-hinge-fold.jsonregistry/core/tree-hinge-fold/component.tsx"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent,
} from "react";
export type RuleNode = {
id: string;
label: string;
children?: RuleNode[];
};
// Tree whose branches unfold like a carpenter's folding rule: each child
// group is a hinged segment that swings open around a visible pivot, rows
// unfolding in sequence down the segment. Collapse snaps shut fast, no
// stagger — a rule folds quicker than it opens.
export function FoldingRule({
nodes,
defaultExpanded = [],
className = "",
}: {
nodes: RuleNode[];
/** ids expanded on first render */
defaultExpanded?: string[];
className?: string;
}) {
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(defaultExpanded)
);
const [selected, setSelected] = useState<string | null>(null);
const [focused, setFocused] = useState<string>(() => nodes[0]?.id ?? "");
const treeRef = useRef<HTMLDivElement>(null);
const reduced = useRef(false);
useEffect(() => {
reduced.current = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
}, []);
const toggle = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
// the visible rows in document order, with parent links for Left-arrow
const visible: { node: RuleNode; parent: RuleNode | null }[] = [];
const walk = (list: RuleNode[], parent: RuleNode | null) => {
for (const node of list) {
visible.push({ node, parent });
if (node.children && expanded.has(node.id)) walk(node.children, node);
}
};
walk(nodes, null);
const focusItem = (id: string) => {
setFocused(id);
// roving tabindex — focus the row element directly
treeRef.current
?.querySelector<HTMLElement>(`[data-rule-id="${CSS.escape(id)}"]`)
?.focus();
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const i = visible.findIndex((v) => v.node.id === focused);
if (i === -1) return;
const { node, parent } = visible[i];
let handled = true;
switch (e.key) {
case "ArrowDown":
if (i < visible.length - 1) focusItem(visible[i + 1].node.id);
break;
case "ArrowUp":
if (i > 0) focusItem(visible[i - 1].node.id);
break;
case "ArrowRight":
if (node.children?.length) {
if (!expanded.has(node.id)) toggle(node.id);
else focusItem(node.children[0].id);
}
break;
case "ArrowLeft":
if (node.children?.length && expanded.has(node.id)) toggle(node.id);
else if (parent) focusItem(parent.id);
break;
case "Home":
if (visible.length) focusItem(visible[0].node.id);
break;
case "End":
if (visible.length) focusItem(visible[visible.length - 1].node.id);
break;
case "Enter":
case " ":
if (node.children?.length) toggle(node.id);
setSelected(node.id);
break;
default:
handled = false;
}
if (handled) e.preventDefault();
};
return (
<div
ref={treeRef}
role="tree"
aria-label="File tree"
className={`font-mono text-[13px] ${className}`}
onKeyDown={onKeyDown}
>
{nodes.map((node) => (
<Branch
key={node.id}
node={node}
depth={0}
expanded={expanded}
selected={selected}
focused={focused}
reduced={reduced}
onToggle={toggle}
onSelect={(id) => {
setSelected(id);
setFocused(id);
}}
/>
))}
</div>
);
}
function Branch({
node,
depth,
expanded,
selected,
focused,
reduced,
onToggle,
onSelect,
}: {
node: RuleNode;
depth: number;
expanded: Set<string>;
selected: string | null;
focused: string;
reduced: { current: boolean };
onToggle: (id: string) => void;
onSelect: (id: string) => void;
}) {
const hasChildren = !!node.children?.length;
const isOpen = hasChildren && expanded.has(node.id);
const isSelected = selected === node.id;
return (
<div
role="treeitem"
aria-expanded={hasChildren ? isOpen : undefined}
aria-selected={isSelected}
data-rule-id={node.id}
tabIndex={focused === node.id ? 0 : -1}
onClick={(e) => {
e.stopPropagation();
if (hasChildren) onToggle(node.id);
onSelect(node.id);
}}
className="group/item cursor-pointer outline-none"
>
<div
className={`flex items-center gap-1.5 rounded-sm px-2 py-1.5 transition-colors duration-150 group-focus-visible/item:outline-2 group-focus-visible/item:-outline-offset-2 group-focus-visible/item:outline-accent ${
isSelected
? "bg-surface text-foreground"
: "text-muted hover:bg-surface hover:text-foreground"
}`}
style={{ paddingLeft: `${8 + depth * 20}px` }}
>
{hasChildren ? (
<svg
viewBox="0 0 12 12"
aria-hidden="true"
className="h-3 w-3 shrink-0 transition-transform duration-200 ease-out"
style={{ transform: isOpen ? "rotate(90deg)" : "rotate(0deg)" }}
>
<path
d="M4.5 2.5 8 6l-3.5 3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<span
aria-hidden="true"
className="block h-1 w-1 shrink-0 rounded-full bg-border"
style={{ marginLeft: 4, marginRight: 4 }}
/>
)}
<span className="truncate">{node.label}</span>
</div>
{hasChildren && (
<div
className="grid"
style={{
gridTemplateRows: isOpen ? "1fr" : "0fr",
transition: reduced.current
? "none"
: `grid-template-rows ${isOpen ? 360 : 200}ms cubic-bezier(0.22, 1, 0.36, 1)`,
}}
>
<div
className="min-h-0 overflow-hidden"
style={{ perspective: "700px" }}
>
{/* the hinged segment: swings open around the joint at its top */}
<div
role="group"
className="relative"
style={{
transformOrigin: "top center",
transform: reduced.current
? "none"
: isOpen
? "rotateX(0deg)"
: "rotateX(-58deg)",
opacity: isOpen ? 1 : 0,
transition: reduced.current
? "none"
: isOpen
? "transform 360ms cubic-bezier(0.34, 1.4, 0.64, 1), opacity 200ms ease-out"
: "transform 200ms cubic-bezier(0.55, 0, 0.85, 0.4), opacity 160ms ease-in",
}}
>
{/* hinge pivot + segment edge rule — z-10 + pointer-events-none:
each child row below sits in its own transform stacking
context (for the row-stagger animation), which the transform
spec promotes to an implicit z-index:0. Left at z-index:auto
these decorative spans tied for that same paint layer and
lost the DOM-order tiebreak to the (later) row wrappers, so
a hovered row's opaque bg-surface painted over the line and
visually severed it. Explicit z-index lifts them into the
next paint layer, above every row's hover/selected fill. */}
<span
aria-hidden="true"
className="absolute bottom-1 top-0 z-10 w-px bg-border pointer-events-none"
style={{ left: `${13 + depth * 20 + 8}px` }}
/>
<span
aria-hidden="true"
className="absolute top-0.5 z-10 h-[5px] w-[5px] rounded-full border border-muted bg-surface pointer-events-none"
style={{ left: `${13 + depth * 20 + 6}px` }}
/>
{node.children!.map((child, i) => (
<div
key={child.id}
style={
reduced.current
? undefined
: {
transformOrigin: "top center",
transform: isOpen
? "rotateX(0deg)"
: "rotateX(-28deg)",
opacity: isOpen ? 1 : 0,
transition: isOpen
? `transform 300ms cubic-bezier(0.34, 1.4, 0.64, 1) ${60 + i * 45}ms, opacity 180ms ease-out ${60 + i * 45}ms`
: "transform 150ms ease-in, opacity 120ms ease-in",
}
}
>
<Branch
node={child}
depth={depth + 1}
expanded={expanded}
selected={selected}
focused={focused}
reduced={reduced}
onToggle={onToggle}
onSelect={onSelect}
/>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
Build a tree view where expanding a node reads as a carpenter's folding rule swinging open a hinged segment. Each child group sits inside a grid-template-rows 0fr/1fr wrapper (transitioned 360 ms open / 200 ms close with ease-out-quint) so layout height animates for free, and the group itself lives under a 700px perspective and rotates from rotateX(-58deg) at transform-origin top to flat, with an overshooting back-out cubic-bezier(0.34, 1.4, 0.64, 1) so the segment lands with a slight bounce; individual child rows carry their own smaller rotateX(-28deg) staggered ~45 ms apart down the segment, so the branch unfolds row by row rather than as one slab. Collapse is deliberately asymmetric: faster (200 ms), ease-in, no stagger — a rule folds shut quicker than it opens. Each open segment draws its hinge as a small bordered pivot dot at the joint plus a 1px vertical rule down the segment's spine, positioned per depth so nesting reads as jointed segments, and leaf rows carry a 1px dot bullet instead of a chevron; the chevron rotates 90 degrees on expand. Full tree semantics: role=tree with an aria-label, role=treeitem rows carrying aria-expanded (only when the node has children) and aria-selected, children inside role=group, roving tabindex with ArrowUp/ArrowDown over the visible flattened order, ArrowRight expands then dives to first child, ArrowLeft collapses then climbs to the parent, Home/End jump the ends, Enter/Space toggles and selects. Selection is a token bg-surface fill with foreground text; hover matches; focus is a token accent outline. All colors from theme tokens only. Under prefers-reduced-motion every rotation and height transition is disabled — groups simply appear and disappear.