Skip to main content

ns-ui

Tree Hinge Fold

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.

Usage

registry/core/tree-hinge-fold/demo.tsx
"use client";

import { FoldingRule, type RuleNode } from "./component";

// Hoisted so the tree isn't handed a new node-array identity every render.
const NODES: RuleNode[] = [
  {
    id: "src",
    label: "src",
    children: [
      {
        id: "components",
        label: "components",
        children: [
          { id: "button", label: "button.tsx" },
          { id: "dialog", label: "dialog.tsx" },
          { id: "tabs", label: "tabs.tsx" },
        ],
      },
      {
        id: "lib",
        label: "lib",
        children: [
          { id: "utils", label: "utils.ts" },
          { id: "tokens", label: "tokens.ts" },
        ],
      },
      { id: "index", label: "index.ts" },
    ],
  },
  {
    id: "scripts",
    label: "scripts",
    children: [
      { id: "build", label: "build.ts" },
      { id: "verify", label: "verify.ts" },
    ],
  },
  { id: "package", label: "package.json" },
  { id: "readme", label: "README.md" },
];

export default function FoldingRuleDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-10 px-6">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / tree-hinge-fold — branches swing open on a hinge
      </p>
      <div className="w-full max-w-sm rounded-md border border-border bg-surface p-3">
        <FoldingRule nodes={NODES} defaultExpanded={["src", "components"]} />
      </div>
    </div>
  );
}

Component

registry/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 = "",
}: {
  /** root-level nodes of the tree */
  nodes: RuleNode[];
  /** ids expanded on first render */
  defaultExpanded?: string[];
  /** extra classes merged onto the rendered root element */
  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-ns-accent ${
          isSelected
            ? "bg-surface text-foreground"
            : "text-ns-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-ns-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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/tree-hinge-fold.tsx, so point the import there.

Use when expanding a branch should read as a physical unfolding, the child group swinging open around a visible pivot with rows unfolding in sequence and snapping shut faster than they open; pick tree-root-trace instead when the indent guides should be the animated element and the tree needs a continuous drawn connector, or tree-box-drawing when monospace connector glyphs are the medium itself.

Install

npx shadcn add https://design.helpmarq.com/r/tree-hinge-fold.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

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.

Props

PropTypeDefaultDescription
nodesRuleNode[]—root-level nodes of the tree
defaultExpanded?string[][]ids expanded on first render
className?string—extra classes merged onto the rendered root element