List/grid/board view switcher that draws a faint curved rail from every item's old slot to its new one and lets it ride that exact rail, releasing cards in reading order like cars cut loose over a classification-yard hump.
npx shadcn add https://design.helpmarq.com /r/view-toggle-rails.jsonregistry/core/view-toggle-rails/component.tsx"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
// ---------------------------------------------------------------------------
// HumpYard — a list/grid/board view switcher for the same data, styled after
// a rail classification yard: instead of a generic FLIP where every card
// flies through every other card, each item gets its own drawn rail — a thin
// curved SVG path from its old slot to its new one — and travels that exact
// rail via CSS offset-path, so a user tracking "the row I was reading" can
// trace its specific journey instead of guessing which blur is which.
//
// MECHANISM: on a view change the new layout commits INSTANTLY (React state,
// synchronous — DOM order/semantics never sit in an intermediate frame, so
// assistive tech never observes a half-finished shuffle). A layout effect
// then measures each item's old rect (captured just before the commit) and
// new rect (read fresh from the committed DOM), builds a slightly sagging
// cubic path between the two centers for the visible rail and an equivalent
// local-space path for the item itself, and lets each item ride its rail via
// `offset-path` + animated `offset-distance` (ease-out-expo). Items are
// released in original reading order at a 15ms stagger — the "cars cut loose
// over the hump" — and each rail fades out 150ms after its own item docks,
// rather than all clearing together. Where `offset-path` isn't supported the
// whole rail apparatus is skipped and items instead run a plain FLIP
// translate between the same two rects — still correct, just undrawn.
//
// Pure DOM + one small SVG overlay, no canvas. Every drawn stroke is
// `var(--border)` at the token's own alpha via `stroke-opacity` — nothing
// baked in as a literal. Board view groups items by status into three
// columns purely through position (left/top), never by moving DOM nodes
// between parents, so tab order and screen-reader reading order stay
// identical to the original item list in every view.
//
// prefers-reduced-motion: the new layout still commits instantly; instead of
// rails and travel, items get a plain 120ms opacity crossfade and nothing
// else moves.
// ---------------------------------------------------------------------------
const VIEWS = [
{ value: "list", label: "List" },
{ value: "grid", label: "Grid" },
{ value: "board", label: "Board" },
] as const;
type ViewMode = (typeof VIEWS)[number]["value"];
export interface HumpYardItem {
id: string;
title: string;
subtitle?: string;
status: "todo" | "doing" | "done";
}
const STATUS_LABEL: Record<HumpYardItem["status"], string> = {
todo: "To do",
doing: "In progress",
done: "Done",
};
const STATUS_ORDER: HumpYardItem["status"][] = ["todo", "doing", "done"];
const DEFAULT_ITEMS: HumpYardItem[] = [
{ id: "1", title: "Design review", subtitle: "Mara Chen", status: "todo" },
{ id: "2", title: "Update pricing page", subtitle: "Jonas Weber", status: "todo" },
{ id: "3", title: "Fix mobile nav overlap", subtitle: "Aiko Tanaka", status: "todo" },
{ id: "4", title: "Draft Q3 roadmap", subtitle: "Sam Okafor", status: "todo" },
{ id: "5", title: "Accessibility pass", subtitle: "Lena Fischer", status: "todo" },
{ id: "6", title: "Migrate auth service", subtitle: "Ravi Patel", status: "doing" },
{ id: "7", title: "Refactor billing module", subtitle: "Nora Lindqvist", status: "doing" },
{ id: "8", title: "Customer interview notes", subtitle: "Mara Chen", status: "doing" },
{ id: "9", title: "Investigate flaky test", subtitle: "Jonas Weber", status: "doing" },
{ id: "10", title: "Prepare demo deck", subtitle: "Aiko Tanaka", status: "doing" },
{ id: "11", title: "Write changelog", subtitle: "Sam Okafor", status: "done" },
{ id: "12", title: "Set up staging env", subtitle: "Ravi Patel", status: "done" },
{ id: "13", title: "Onboarding flow audit", subtitle: "Lena Fischer", status: "done" },
{ id: "14", title: "Backfill analytics events", subtitle: "Nora Lindqvist", status: "done" },
];
const GAP = 12;
const ITEM_H = 60;
const BOARD_HEADER_H = 22;
const STAGGER_MS = 15;
const TRAVEL_MS = 560;
const RAIL_FADE_MS = 150;
const REDUCED_MS = 120;
const EASE_OUT_EXPO = "cubic-bezier(0.16, 1, 0.3, 1)";
interface Box {
x: number;
y: number;
w: number;
h: number;
}
function computeLayout(mode: ViewMode, items: HumpYardItem[], width: number) {
const boxes = new Map<string, Box>();
const headers: { label: string; x: number; y: number; w: number }[] = [];
const w = Math.max(width, 1);
if (mode === "list") {
items.forEach((it, i) => {
boxes.set(it.id, { x: 0, y: i * (ITEM_H + GAP), w, h: ITEM_H });
});
} else if (mode === "grid") {
const cols = Math.max(2, Math.min(4, Math.floor((w + GAP) / (208 + GAP)) || 2));
const cw = (w - GAP * (cols - 1)) / cols;
items.forEach((it, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
boxes.set(it.id, { x: col * (cw + GAP), y: row * (ITEM_H + GAP), w: cw, h: ITEM_H });
});
} else {
const cols = 3;
const cw = (w - GAP * (cols - 1)) / cols;
STATUS_ORDER.forEach((status, colIdx) => {
const x = colIdx * (cw + GAP);
headers.push({ label: STATUS_LABEL[status], x, y: 0, w: cw });
const colItems = items.filter((it) => it.status === status);
colItems.forEach((it, row) => {
boxes.set(it.id, {
x,
y: BOARD_HEADER_H + GAP + row * (ITEM_H + GAP),
w: cw,
h: ITEM_H,
});
});
});
}
let maxBottom = 0;
boxes.forEach((b) => {
maxBottom = Math.max(maxBottom, b.y + b.h);
});
if (mode === "board") maxBottom = Math.max(maxBottom, BOARD_HEADER_H);
return { boxes, headers, height: maxBottom };
}
// Cubic bezier control points for a cable-like sag between two points: the
// midsection bows toward the downward perpendicular (screen-space +y), the
// same reading regardless of which way the item is actually travelling.
function bow(sx: number, sy: number, ex: number, ey: number) {
const dx = ex - sx;
const dy = ey - sy;
const len = Math.hypot(dx, dy) || 1;
let px = -dy / len;
let py = dx / len;
if (py < 0) {
px = -px;
py = -py;
}
const sag = Math.min(30, Math.max(6, len * 0.14));
return {
c1x: sx + dx * 0.33 + px * sag,
c1y: sy + dy * 0.33 + py * sag,
c2x: sx + dx * 0.66 + px * sag,
c2y: sy + dy * 0.66 + py * sag,
};
}
let offsetPathSupport: boolean | null = null;
function supportsOffsetPath() {
if (offsetPathSupport !== null) return offsetPathSupport;
offsetPathSupport =
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("offset-path", "path('M0 0L1 1')");
return offsetPathSupport;
}
export interface HumpYardProps {
/** The data set. Same items are reused across all three views. */
items?: HumpYardItem[];
/** Uncontrolled initial view. @default "list" */
defaultView?: ViewMode;
/** Controlled view. */
view?: ViewMode;
onViewChange?: (view: ViewMode) => void;
/** Accessible name for the item collection, e.g. "Tasks". */
"aria-label"?: string;
className?: string;
}
export function HumpYard({
items = DEFAULT_ITEMS,
defaultView = "list",
view: controlledView,
onViewChange,
"aria-label": ariaLabel = "Tasks",
className = "",
}: HumpYardProps) {
const [uncontrolledView, setUncontrolledView] = useState<ViewMode>(defaultView);
const view = controlledView ?? uncontrolledView;
const viewRef = useRef(view);
viewRef.current = view;
const itemsRef = useRef(items);
itemsRef.current = items;
const containerRef = useRef<HTMLDivElement>(null);
const railSvgRef = useRef<SVGSVGElement>(null);
const itemElsRef = useRef(new Map<string, HTMLDivElement>());
const pendingOldRectsRef = useRef<Map<string, DOMRect> | null>(null);
const timeoutsRef = useRef<number[]>([]);
const railElsRef = useRef<SVGPathElement[]>([]);
const liveRegionRef = useRef<HTMLDivElement>(null);
const isFirstViewEffectRef = useRef(true);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
setWidth(el.clientWidth);
const ro = new ResizeObserver((entries) => {
const next = entries[0]?.contentRect.width ?? el.clientWidth;
setWidth(next);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const { boxes, headers, height } = useMemo(
() => computeLayout(view, items, width || 1),
[view, items, width]
);
const clearTimeouts = useCallback(() => {
timeoutsRef.current.forEach((t) => window.clearTimeout(t));
timeoutsRef.current = [];
}, []);
const clearRails = useCallback(() => {
railElsRef.current.forEach((p) => p.remove());
railElsRef.current = [];
}, []);
useEffect(
() => () => {
clearTimeouts();
clearRails();
},
[clearTimeouts, clearRails]
);
// Capture old rects synchronously, before the state update that commits
// the new layout — this is the "F" of FLIP.
const requestView = useCallback(
(next: ViewMode) => {
if (next === viewRef.current) return;
const oldRects = new Map<string, DOMRect>();
itemElsRef.current.forEach((el, id) => {
oldRects.set(id, el.getBoundingClientRect());
});
pendingOldRectsRef.current = oldRects;
onViewChange?.(next);
if (controlledView === undefined) setUncontrolledView(next);
},
[onViewChange, controlledView]
);
// Runs after the new layout has committed (still before paint): measures
// new rects, draws rails, and kicks off the staggered travel.
useLayoutEffect(() => {
const oldRects = pendingOldRectsRef.current;
pendingOldRectsRef.current = null;
if (!oldRects) return;
clearTimeouts();
clearRails();
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ids = itemsRef.current.map((it) => it.id);
const svg = railSvgRef.current;
const containerBox = containerRef.current?.getBoundingClientRect();
if (reduced) {
ids.forEach((id) => {
const el = itemElsRef.current.get(id);
if (!el) return;
el.style.transition = "none";
el.style.opacity = "0";
});
const raf1 = requestAnimationFrame(() => {
const raf2 = requestAnimationFrame(() => {
ids.forEach((id) => {
const el = itemElsRef.current.get(id);
if (!el) return;
el.style.transition = `opacity ${REDUCED_MS}ms ease-out`;
el.style.opacity = "1";
});
const t = window.setTimeout(() => {
ids.forEach((id) => {
const el = itemElsRef.current.get(id);
if (!el) return;
el.style.transition = "";
el.style.opacity = "";
});
}, REDUCED_MS + 40);
timeoutsRef.current.push(t);
});
timeoutsRef.current.push(raf2 as unknown as number);
});
timeoutsRef.current.push(raf1 as unknown as number);
return;
}
const useOffset = supportsOffsetPath();
ids.forEach((id, i) => {
const el = itemElsRef.current.get(id);
const oldRect = oldRects.get(id);
if (!el || !oldRect) return;
const newRect = el.getBoundingClientRect();
const dx = oldRect.left - newRect.left;
const dy = oldRect.top - newRect.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
const delay = i * STAGGER_MS;
if (useOffset) {
const local = bow(dx, dy, 0, 0);
el.style.transition = "none";
el.style.setProperty("offset-rotate", "0deg");
el.style.setProperty("offset-anchor", "0px 0px");
el.style.setProperty(
"offset-path",
`path('M ${dx} ${dy} C ${local.c1x} ${local.c1y} ${local.c2x} ${local.c2y} 0 0')`
);
el.style.setProperty("offset-distance", "0%");
void el.offsetWidth;
if (svg && containerBox) {
const oldCx = oldRect.left + oldRect.width / 2 - containerBox.left;
const oldCy = oldRect.top + oldRect.height / 2 - containerBox.top;
const newCx = newRect.left + newRect.width / 2 - containerBox.left;
const newCy = newRect.top + newRect.height / 2 - containerBox.top;
const rail = bow(oldCx, oldCy, newCx, newCy);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute(
"d",
`M ${oldCx} ${oldCy} C ${rail.c1x} ${rail.c1y} ${rail.c2x} ${rail.c2y} ${newCx} ${newCy}`
);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "var(--border)");
path.setAttribute("stroke-width", "1");
path.setAttribute("stroke-opacity", "0.3");
path.style.transition = `opacity ${RAIL_FADE_MS}ms ease-out`;
svg.appendChild(path);
railElsRef.current.push(path);
const t1 = window.setTimeout(() => {
el.style.transition = `offset-distance ${TRAVEL_MS}ms ${EASE_OUT_EXPO}`;
el.style.setProperty("offset-distance", "100%");
}, delay);
const t2 = window.setTimeout(() => {
el.style.transition = "";
el.style.removeProperty("offset-path");
el.style.removeProperty("offset-distance");
el.style.removeProperty("offset-anchor");
el.style.removeProperty("offset-rotate");
path.style.opacity = "0";
const t3 = window.setTimeout(() => {
path.remove();
railElsRef.current = railElsRef.current.filter((p) => p !== path);
}, RAIL_FADE_MS + 30);
timeoutsRef.current.push(t3);
}, delay + TRAVEL_MS);
timeoutsRef.current.push(t1, t2);
}
} else {
// Fallback: plain FLIP translate, no drawn rail.
el.style.transition = "none";
el.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
void el.offsetWidth;
const t1 = window.setTimeout(() => {
el.style.transition = `transform ${TRAVEL_MS}ms ${EASE_OUT_EXPO}`;
el.style.transform = "translate3d(0,0,0)";
}, delay);
const t2 = window.setTimeout(() => {
el.style.transition = "";
el.style.transform = "";
}, delay + TRAVEL_MS);
timeoutsRef.current.push(t1, t2);
}
});
// boxes captures [view, items, width] already; re-running this effect
// only matters when a pending transition was actually queued.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [boxes]);
// Async announcement of the new view — skipped on mount.
useEffect(() => {
if (isFirstViewEffectRef.current) {
isFirstViewEffectRef.current = false;
return;
}
const label = VIEWS.find((v) => v.value === view)?.label ?? view;
if (liveRegionRef.current) {
liveRegionRef.current.textContent = `${label} view, ${items.length} items`;
}
}, [view, items.length]);
const onToggleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const idx = VIEWS.findIndex((v) => v.value === view);
let next = -1;
if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + VIEWS.length) % VIEWS.length;
else if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % VIEWS.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = VIEWS.length - 1;
else return;
e.preventDefault();
const target = VIEWS[next];
if (target) requestView(target.value);
};
return (
<div className={`w-full ${className}`}>
<div className="mb-3 flex items-center justify-between gap-3">
<span className="font-mono text-[11px] uppercase tracking-wide text-muted">
{ariaLabel}
</span>
<div
role="radiogroup"
aria-label="View"
onKeyDown={onToggleKeyDown}
className="inline-flex shrink-0 items-center gap-0.5 rounded-sm border border-border bg-foreground/[0.04] p-0.5"
>
{VIEWS.map((opt) => {
const selected = opt.value === view;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => requestView(opt.value)}
className={`rounded-[4px] px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide outline-none transition-colors motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-accent ${
selected
? "bg-background text-foreground shadow-sm hover:ring-1 hover:ring-inset hover:ring-border"
: "text-muted hover:text-foreground"
}`}
>
{opt.label} view
</button>
);
})}
</div>
</div>
<div ref={liveRegionRef} aria-live="polite" className="sr-only" />
<div
ref={containerRef}
role="list"
aria-label={ariaLabel}
data-view={view}
className="relative w-full transition-[height] duration-300 ease-out motion-reduce:transition-none"
style={{ height }}
>
<svg
ref={railSvgRef}
aria-hidden
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
/>
{headers.map((h) => (
<div
key={h.label}
aria-hidden
className="absolute font-mono text-[10px] uppercase tracking-wide text-muted"
style={{ left: h.x, top: h.y, width: h.w }}
>
{h.label}
</div>
))}
{items.map((it) => {
const box = boxes.get(it.id);
if (!box) return null;
return (
<div
key={it.id}
role="listitem"
ref={(el) => {
if (el) itemElsRef.current.set(it.id, el);
else itemElsRef.current.delete(it.id);
}}
className="absolute flex items-center gap-2 overflow-hidden rounded-md border border-border bg-foreground/[0.03] px-3 py-2 will-change-transform"
style={{ left: box.x, top: box.y, width: box.w, height: box.h }}
>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">
{it.title}
</div>
{it.subtitle && (
<div className="truncate text-[11px] text-muted">{it.subtitle}</div>
)}
</div>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted">
{STATUS_LABEL[it.status]}
</span>
</div>
);
})}
</div>
</div>
);
}
A list/grid/board view switcher for one shared data set, where the transition itself is a diagram rather than an ambiguous blur. All three views are one absolutely-positioned layer inside a single container: list stacks items in one full-width column, grid flows the same items into 2-4 responsive columns of fixed-height rows, and board groups them into three status columns (To do / In progress / Done) purely through left/top placement — no item ever changes DOM parent or source order, so tab order and screen-reader reading order are identical in every view. Selecting a view via a real ARIA radiogroup (roving tabindex, arrow keys move and commit, Home/End jump to the ends) captures each visible item's bounding rect, commits the new layout INSTANTLY via React state (so assistive tech never observes an in-between shuffle), then in a layout effect measures every item's new rect and, for each one that actually moved, builds a slightly sagging cubic bezier between the two centers — the sag is a perpendicular offset toward screen-space +y scaled to 14% of the path length, clamped 6-30px, so it reads as a cable drooping under its own weight rather than a straight ruled line. That curve is drawn once as a hairline path in a fixed SVG overlay (stroke var(--border), stroke-opacity 0.3, 1px) UNDER the cards, and an equivalent local-space version of the same curve is set as the item's own `offset-path` (anchor pinned to its top-left corner, offset-rotate 0deg so the card itself never rotates); `offset-distance` then animates 0% to 100% along that exact rail on a 560ms cubic-bezier(0.16,1,0.3,1) ease-out-expo transition. Items are released in the data's original order (not their visual row/column) at a 15ms stagger, the 'cars over the hump' cut-loose rhythm, and 150ms after an individual item's own transition ends its rail fades out and is removed — rails clear one at a time as their cargo arrives, not all together. Interrupting mid-transition (clicking again before a prior switch has finished) is safe: the next transition measures whatever rect is currently on screen, mid-flight or not, and rebuilds fresh rails from there. Where `CSS.supports('offset-path', ...)` is false the whole rail apparatus is skipped and items instead run a plain FLIP translate between the same two rects with the same stagger and easing, just without a visible path. `prefers-reduced-motion` skips rails and offset-path entirely: the new layout still commits instantly and items get a plain 120ms opacity crossfade, nothing else animates. A visually-hidden aria-live region announces '<View> view, N items' after each change (skipped on mount). Pure DOM + one small SVG overlay, zero canvas; every stroke and surface tint is token-relative (var(--border), bg-foreground/[0.03], text-muted, text-foreground, focus ring in --accent only). Distinct from avatar-stack-flock (a boids flock that mills and resolves into a row on hover — motion with no destination-tracing, one continuous simulation) and reveal-ripple-tiles (a radial wave-driven reveal, motion with no per-item path at all): view-toggle-rails's signature is that every item's journey between two arbitrary layouts is a specific, individually-traceable, transiently visible curve, not a choreographed field.