{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "view-toggle-rails",
  "title": "View Toggle Rails",
  "description": "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.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/view-toggle-rails/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// HumpYard — a list/grid/board view switcher for the same data, styled after\n// a rail classification yard: instead of a generic FLIP where every card\n// flies through every other card, each item gets its own drawn rail — a thin\n// curved SVG path from its old slot to its new one — and travels that exact\n// rail via CSS offset-path, so a user tracking \"the row I was reading\" can\n// trace its specific journey instead of guessing which blur is which.\n//\n// MECHANISM: on a view change the new layout commits INSTANTLY (React state,\n// synchronous — DOM order/semantics never sit in an intermediate frame, so\n// assistive tech never observes a half-finished shuffle). A layout effect\n// then measures each item's old rect (captured just before the commit) and\n// new rect (read fresh from the committed DOM), builds a slightly sagging\n// cubic path between the two centers for the visible rail and an equivalent\n// local-space path for the item itself, and lets each item ride its rail via\n// `offset-path` + animated `offset-distance` (ease-out-expo). Items are\n// released in original reading order at a 15ms stagger — the \"cars cut loose\n// over the hump\" — and each rail fades out 150ms after its own item docks,\n// rather than all clearing together. Where `offset-path` isn't supported the\n// whole rail apparatus is skipped and items instead run a plain FLIP\n// translate between the same two rects — still correct, just undrawn.\n//\n// Pure DOM + one small SVG overlay, no canvas. Every drawn stroke is\n// `var(--border)` at the token's own alpha via `stroke-opacity` — nothing\n// baked in as a literal. Board view groups items by status into three\n// columns purely through position (left/top), never by moving DOM nodes\n// between parents, so tab order and screen-reader reading order stay\n// identical to the original item list in every view.\n//\n// prefers-reduced-motion: the new layout still commits instantly; instead of\n// rails and travel, items get a plain 120ms opacity crossfade and nothing\n// else moves.\n// ---------------------------------------------------------------------------\n\nconst VIEWS = [\n  { value: \"list\", label: \"List\" },\n  { value: \"grid\", label: \"Grid\" },\n  { value: \"board\", label: \"Board\" },\n] as const;\n\ntype ViewMode = (typeof VIEWS)[number][\"value\"];\n\nexport interface HumpYardItem {\n  id: string;\n  title: string;\n  subtitle?: string;\n  status: \"todo\" | \"doing\" | \"done\";\n}\n\nconst STATUS_LABEL: Record<HumpYardItem[\"status\"], string> = {\n  todo: \"To do\",\n  doing: \"In progress\",\n  done: \"Done\",\n};\n\nconst STATUS_ORDER: HumpYardItem[\"status\"][] = [\"todo\", \"doing\", \"done\"];\n\nconst DEFAULT_ITEMS: HumpYardItem[] = [\n  { id: \"1\", title: \"Design review\", subtitle: \"Mara Chen\", status: \"todo\" },\n  { id: \"2\", title: \"Update pricing page\", subtitle: \"Jonas Weber\", status: \"todo\" },\n  { id: \"3\", title: \"Fix mobile nav overlap\", subtitle: \"Aiko Tanaka\", status: \"todo\" },\n  { id: \"4\", title: \"Draft Q3 roadmap\", subtitle: \"Sam Okafor\", status: \"todo\" },\n  { id: \"5\", title: \"Accessibility pass\", subtitle: \"Lena Fischer\", status: \"todo\" },\n  { id: \"6\", title: \"Migrate auth service\", subtitle: \"Ravi Patel\", status: \"doing\" },\n  { id: \"7\", title: \"Refactor billing module\", subtitle: \"Nora Lindqvist\", status: \"doing\" },\n  { id: \"8\", title: \"Customer interview notes\", subtitle: \"Mara Chen\", status: \"doing\" },\n  { id: \"9\", title: \"Investigate flaky test\", subtitle: \"Jonas Weber\", status: \"doing\" },\n  { id: \"10\", title: \"Prepare demo deck\", subtitle: \"Aiko Tanaka\", status: \"doing\" },\n  { id: \"11\", title: \"Write changelog\", subtitle: \"Sam Okafor\", status: \"done\" },\n  { id: \"12\", title: \"Set up staging env\", subtitle: \"Ravi Patel\", status: \"done\" },\n  { id: \"13\", title: \"Onboarding flow audit\", subtitle: \"Lena Fischer\", status: \"done\" },\n  { id: \"14\", title: \"Backfill analytics events\", subtitle: \"Nora Lindqvist\", status: \"done\" },\n];\n\nconst GAP = 12;\nconst ITEM_H = 60;\nconst BOARD_HEADER_H = 22;\nconst STAGGER_MS = 15;\nconst TRAVEL_MS = 560;\nconst RAIL_FADE_MS = 150;\nconst REDUCED_MS = 120;\nconst EASE_OUT_EXPO = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\ninterface Box {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\nfunction computeLayout(mode: ViewMode, items: HumpYardItem[], width: number) {\n  const boxes = new Map<string, Box>();\n  const headers: { label: string; x: number; y: number; w: number }[] = [];\n  const w = Math.max(width, 1);\n\n  if (mode === \"list\") {\n    items.forEach((it, i) => {\n      boxes.set(it.id, { x: 0, y: i * (ITEM_H + GAP), w, h: ITEM_H });\n    });\n  } else if (mode === \"grid\") {\n    const cols = Math.max(2, Math.min(4, Math.floor((w + GAP) / (208 + GAP)) || 2));\n    const cw = (w - GAP * (cols - 1)) / cols;\n    items.forEach((it, i) => {\n      const col = i % cols;\n      const row = Math.floor(i / cols);\n      boxes.set(it.id, { x: col * (cw + GAP), y: row * (ITEM_H + GAP), w: cw, h: ITEM_H });\n    });\n  } else {\n    const cols = 3;\n    const cw = (w - GAP * (cols - 1)) / cols;\n    STATUS_ORDER.forEach((status, colIdx) => {\n      const x = colIdx * (cw + GAP);\n      headers.push({ label: STATUS_LABEL[status], x, y: 0, w: cw });\n      const colItems = items.filter((it) => it.status === status);\n      colItems.forEach((it, row) => {\n        boxes.set(it.id, {\n          x,\n          y: BOARD_HEADER_H + GAP + row * (ITEM_H + GAP),\n          w: cw,\n          h: ITEM_H,\n        });\n      });\n    });\n  }\n\n  let maxBottom = 0;\n  boxes.forEach((b) => {\n    maxBottom = Math.max(maxBottom, b.y + b.h);\n  });\n  if (mode === \"board\") maxBottom = Math.max(maxBottom, BOARD_HEADER_H);\n\n  return { boxes, headers, height: maxBottom };\n}\n\n// Cubic bezier control points for a cable-like sag between two points: the\n// midsection bows toward the downward perpendicular (screen-space +y), the\n// same reading regardless of which way the item is actually travelling.\nfunction bow(sx: number, sy: number, ex: number, ey: number) {\n  const dx = ex - sx;\n  const dy = ey - sy;\n  const len = Math.hypot(dx, dy) || 1;\n  let px = -dy / len;\n  let py = dx / len;\n  if (py < 0) {\n    px = -px;\n    py = -py;\n  }\n  const sag = Math.min(30, Math.max(6, len * 0.14));\n  return {\n    c1x: sx + dx * 0.33 + px * sag,\n    c1y: sy + dy * 0.33 + py * sag,\n    c2x: sx + dx * 0.66 + px * sag,\n    c2y: sy + dy * 0.66 + py * sag,\n  };\n}\n\nlet offsetPathSupport: boolean | null = null;\nfunction supportsOffsetPath() {\n  if (offsetPathSupport !== null) return offsetPathSupport;\n  offsetPathSupport =\n    typeof CSS !== \"undefined\" &&\n    typeof CSS.supports === \"function\" &&\n    CSS.supports(\"offset-path\", \"path('M0 0L1 1')\");\n  return offsetPathSupport;\n}\n\nexport interface HumpYardProps {\n  /** The data set. Same items are reused across all three views. */\n  items?: HumpYardItem[];\n  /** Uncontrolled initial view. @default \"list\" */\n  defaultView?: ViewMode;\n  /** Controlled view. */\n  view?: ViewMode;\n  /** called with the new view when a different one is selected */\n  onViewChange?: (view: ViewMode) => void;\n  /** Accessible name for the item collection, e.g. \"Tasks\". */\n  \"aria-label\"?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function HumpYard({\n  items = DEFAULT_ITEMS,\n  defaultView = \"list\",\n  view: controlledView,\n  onViewChange,\n  \"aria-label\": ariaLabel = \"Tasks\",\n  className = \"\",\n}: HumpYardProps) {\n  const [uncontrolledView, setUncontrolledView] = useState<ViewMode>(defaultView);\n  const view = controlledView ?? uncontrolledView;\n  const viewRef = useRef(view);\n  viewRef.current = view;\n\n  const itemsRef = useRef(items);\n  itemsRef.current = items;\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const railSvgRef = useRef<SVGSVGElement>(null);\n  const itemElsRef = useRef(new Map<string, HTMLDivElement>());\n  const pendingOldRectsRef = useRef<Map<string, DOMRect> | null>(null);\n  const timeoutsRef = useRef<number[]>([]);\n  const railElsRef = useRef<SVGPathElement[]>([]);\n  const liveRegionRef = useRef<HTMLDivElement>(null);\n  const isFirstViewEffectRef = useRef(true);\n\n  const [width, setWidth] = useState(0);\n\n  useLayoutEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n    setWidth(el.clientWidth);\n    const ro = new ResizeObserver((entries) => {\n      const next = entries[0]?.contentRect.width ?? el.clientWidth;\n      setWidth(next);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const { boxes, headers, height } = useMemo(\n    () => computeLayout(view, items, width || 1),\n    [view, items, width]\n  );\n\n  const clearTimeouts = useCallback(() => {\n    timeoutsRef.current.forEach((t) => window.clearTimeout(t));\n    timeoutsRef.current = [];\n  }, []);\n\n  const clearRails = useCallback(() => {\n    railElsRef.current.forEach((p) => p.remove());\n    railElsRef.current = [];\n  }, []);\n\n  useEffect(\n    () => () => {\n      clearTimeouts();\n      clearRails();\n    },\n    [clearTimeouts, clearRails]\n  );\n\n  // Capture old rects synchronously, before the state update that commits\n  // the new layout — this is the \"F\" of FLIP.\n  const requestView = useCallback(\n    (next: ViewMode) => {\n      if (next === viewRef.current) return;\n      const oldRects = new Map<string, DOMRect>();\n      itemElsRef.current.forEach((el, id) => {\n        oldRects.set(id, el.getBoundingClientRect());\n      });\n      pendingOldRectsRef.current = oldRects;\n      onViewChange?.(next);\n      if (controlledView === undefined) setUncontrolledView(next);\n    },\n    [onViewChange, controlledView]\n  );\n\n  // Runs after the new layout has committed (still before paint): measures\n  // new rects, draws rails, and kicks off the staggered travel.\n  useLayoutEffect(() => {\n    const oldRects = pendingOldRectsRef.current;\n    pendingOldRectsRef.current = null;\n    if (!oldRects) return;\n\n    clearTimeouts();\n    clearRails();\n\n    const reduced =\n      typeof window !== \"undefined\" &&\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const ids = itemsRef.current.map((it) => it.id);\n    const svg = railSvgRef.current;\n    const containerBox = containerRef.current?.getBoundingClientRect();\n\n    if (reduced) {\n      ids.forEach((id) => {\n        const el = itemElsRef.current.get(id);\n        if (!el) return;\n        el.style.transition = \"none\";\n        el.style.opacity = \"0\";\n      });\n      const raf1 = requestAnimationFrame(() => {\n        const raf2 = requestAnimationFrame(() => {\n          ids.forEach((id) => {\n            const el = itemElsRef.current.get(id);\n            if (!el) return;\n            el.style.transition = `opacity ${REDUCED_MS}ms ease-out`;\n            el.style.opacity = \"1\";\n          });\n          const t = window.setTimeout(() => {\n            ids.forEach((id) => {\n              const el = itemElsRef.current.get(id);\n              if (!el) return;\n              el.style.transition = \"\";\n              el.style.opacity = \"\";\n            });\n          }, REDUCED_MS + 40);\n          timeoutsRef.current.push(t);\n        });\n        timeoutsRef.current.push(raf2 as unknown as number);\n      });\n      timeoutsRef.current.push(raf1 as unknown as number);\n      return;\n    }\n\n    const useOffset = supportsOffsetPath();\n\n    ids.forEach((id, i) => {\n      const el = itemElsRef.current.get(id);\n      const oldRect = oldRects.get(id);\n      if (!el || !oldRect) return;\n      const newRect = el.getBoundingClientRect();\n      const dx = oldRect.left - newRect.left;\n      const dy = oldRect.top - newRect.top;\n      if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;\n\n      const delay = i * STAGGER_MS;\n\n      if (useOffset) {\n        const local = bow(dx, dy, 0, 0);\n        el.style.transition = \"none\";\n        el.style.setProperty(\"offset-rotate\", \"0deg\");\n        el.style.setProperty(\"offset-anchor\", \"0px 0px\");\n        el.style.setProperty(\n          \"offset-path\",\n          `path('M ${dx} ${dy} C ${local.c1x} ${local.c1y} ${local.c2x} ${local.c2y} 0 0')`\n        );\n        el.style.setProperty(\"offset-distance\", \"0%\");\n        void el.offsetWidth;\n\n        if (svg && containerBox) {\n          const oldCx = oldRect.left + oldRect.width / 2 - containerBox.left;\n          const oldCy = oldRect.top + oldRect.height / 2 - containerBox.top;\n          const newCx = newRect.left + newRect.width / 2 - containerBox.left;\n          const newCy = newRect.top + newRect.height / 2 - containerBox.top;\n          const rail = bow(oldCx, oldCy, newCx, newCy);\n          const path = document.createElementNS(\"http://www.w3.org/2000/svg\", \"path\");\n          path.setAttribute(\n            \"d\",\n            `M ${oldCx} ${oldCy} C ${rail.c1x} ${rail.c1y} ${rail.c2x} ${rail.c2y} ${newCx} ${newCy}`\n          );\n          path.setAttribute(\"fill\", \"none\");\n          path.setAttribute(\"stroke\", \"var(--border)\");\n          path.setAttribute(\"stroke-width\", \"1\");\n          path.setAttribute(\"stroke-opacity\", \"0.3\");\n          path.style.transition = `opacity ${RAIL_FADE_MS}ms ease-out`;\n          svg.appendChild(path);\n          railElsRef.current.push(path);\n\n          const t1 = window.setTimeout(() => {\n            el.style.transition = `offset-distance ${TRAVEL_MS}ms ${EASE_OUT_EXPO}`;\n            el.style.setProperty(\"offset-distance\", \"100%\");\n          }, delay);\n          const t2 = window.setTimeout(() => {\n            el.style.transition = \"\";\n            el.style.removeProperty(\"offset-path\");\n            el.style.removeProperty(\"offset-distance\");\n            el.style.removeProperty(\"offset-anchor\");\n            el.style.removeProperty(\"offset-rotate\");\n            path.style.opacity = \"0\";\n            const t3 = window.setTimeout(() => {\n              path.remove();\n              railElsRef.current = railElsRef.current.filter((p) => p !== path);\n            }, RAIL_FADE_MS + 30);\n            timeoutsRef.current.push(t3);\n          }, delay + TRAVEL_MS);\n          timeoutsRef.current.push(t1, t2);\n        }\n      } else {\n        // Fallback: plain FLIP translate, no drawn rail.\n        el.style.transition = \"none\";\n        el.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;\n        void el.offsetWidth;\n        const t1 = window.setTimeout(() => {\n          el.style.transition = `transform ${TRAVEL_MS}ms ${EASE_OUT_EXPO}`;\n          el.style.transform = \"translate3d(0,0,0)\";\n        }, delay);\n        const t2 = window.setTimeout(() => {\n          el.style.transition = \"\";\n          el.style.transform = \"\";\n        }, delay + TRAVEL_MS);\n        timeoutsRef.current.push(t1, t2);\n      }\n    });\n    // boxes captures [view, items, width] already; re-running this effect\n    // only matters when a pending transition was actually queued.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [boxes]);\n\n  // Async announcement of the new view — skipped on mount.\n  useEffect(() => {\n    if (isFirstViewEffectRef.current) {\n      isFirstViewEffectRef.current = false;\n      return;\n    }\n    const label = VIEWS.find((v) => v.value === view)?.label ?? view;\n    if (liveRegionRef.current) {\n      liveRegionRef.current.textContent = `${label} view, ${items.length} items`;\n    }\n  }, [view, items.length]);\n\n  const onToggleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const idx = VIEWS.findIndex((v) => v.value === view);\n    let next = -1;\n    if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = (idx - 1 + VIEWS.length) % VIEWS.length;\n    else if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = (idx + 1) % VIEWS.length;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = VIEWS.length - 1;\n    else return;\n    e.preventDefault();\n    const target = VIEWS[next];\n    if (target) requestView(target.value);\n  };\n\n  return (\n    <div className={`w-full ${className}`}>\n      <div className=\"mb-3 flex items-center justify-between gap-3\">\n        <span className=\"font-mono text-[11px] uppercase tracking-wide text-ns-muted\">\n          {ariaLabel}\n        </span>\n        <div\n          role=\"radiogroup\"\n          aria-label=\"View\"\n          onKeyDown={onToggleKeyDown}\n          className=\"inline-flex shrink-0 items-center gap-0.5 rounded-sm border border-border bg-foreground/[0.04] p-0.5\"\n        >\n          {VIEWS.map((opt) => {\n            const selected = opt.value === view;\n            return (\n              <button\n                key={opt.value}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={selected}\n                tabIndex={selected ? 0 : -1}\n                onClick={() => requestView(opt.value)}\n                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-ns-accent ${\n                  selected\n                    ? \"bg-background text-foreground shadow-sm hover:ring-1 hover:ring-inset hover:ring-border\"\n                    : \"text-ns-muted hover:text-foreground\"\n                }`}\n              >\n                {opt.label} view\n              </button>\n            );\n          })}\n        </div>\n      </div>\n\n      <div ref={liveRegionRef} aria-live=\"polite\" className=\"sr-only\" />\n\n      <div\n        ref={containerRef}\n        role=\"list\"\n        aria-label={ariaLabel}\n        data-view={view}\n        className=\"relative w-full transition-[height] duration-300 ease-out motion-reduce:transition-none\"\n        style={{ height }}\n      >\n        <svg\n          ref={railSvgRef}\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 h-full w-full overflow-visible\"\n        />\n        {headers.map((h) => (\n          <div\n            key={h.label}\n            aria-hidden\n            className=\"absolute font-mono text-[10px] uppercase tracking-wide text-ns-muted\"\n            style={{ left: h.x, top: h.y, width: h.w }}\n          >\n            {h.label}\n          </div>\n        ))}\n        {items.map((it) => {\n          const box = boxes.get(it.id);\n          if (!box) return null;\n          return (\n            <div\n              key={it.id}\n              role=\"listitem\"\n              ref={(el) => {\n                if (el) itemElsRef.current.set(it.id, el);\n                else itemElsRef.current.delete(it.id);\n              }}\n              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\"\n              style={{ left: box.x, top: box.y, width: box.w, height: box.h }}\n            >\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"truncate text-[13px] font-medium text-foreground\">\n                  {it.title}\n                </div>\n                {it.subtitle && (\n                  <div className=\"truncate text-[11px] text-ns-muted\">{it.subtitle}</div>\n                )}\n              </div>\n              <span className=\"shrink-0 rounded-full border border-border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-ns-muted\">\n                {STATUS_LABEL[it.status]}\n              </span>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/view-toggle-rails.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "layout",
      "transition",
      "flip",
      "view-toggle",
      "kanban",
      "svg",
      "radio-group"
    ],
    "instruction": "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-ns-muted, text-foreground, focus ring in --ns-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."
  },
  "type": "registry:ui"
}