{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gallery-gantry-track",
  "title": "Gallery Gantry Track",
  "description": "A scroll-scrubbed, infinitely-looping 3D card gallery: cards hang off a receding gantry track using real CSS perspective/preserve-3d, the whole tray tilts toward the pointer like a specimen table, and whichever card sits nearest the camera (by depth, not mouse distance) gets a procedural focus highlight and its title pushed to a live readout.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/gallery-gantry-track/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// GantryRun — a scroll-scrubbed, infinitely-looping 3D card gallery. Cards\n// hang off a receding gantry track: real CSS `perspective` + `preserve-3d`\n// does the projection (translate3d/rotateY per card, written directly to\n// each card's ref every frame — no React state on the hot path), the browser\n// computes the actual on-screen scale and foreshortening.\n//\n// The loop is a buffer trick: the item list is rendered twice back to back\n// along the track, and scroll progress drives a camera distance that runs\n// exactly the length of ONE set before the pass completes — so by the time\n// the last real card has receded out of view, its duplicate is already\n// approaching from the far end, and the whole thing reads as an unbroken\n// belt. There is no distance-to-mouse magnetism per card (that always reads\n// as N springs firing independently as the pointer crosses the grid) —\n// instead the track itself tilts toward the pointer like a specimen tray,\n// and whichever card currently sits nearest the camera (by depth, not by\n// pointer distance) gets a procedural focus highlight and its title is\n// pushed into a live readout — the \"camera\" itself is what has attention,\n// not the mouse.\n// ---------------------------------------------------------------------------\n\nexport interface GantryItem {\n  id: string;\n  title: string;\n  caption?: string;\n}\n\nexport interface GantryRunProps {\n  /** the gallery images, in order */\n  items?: GantryItem[];\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst DEFAULT_ITEMS: GantryItem[] = [\n  { id: \"01\", title: \"Signal Plate\", caption: \"Rev. A\" },\n  { id: \"02\", title: \"Trace Coil\", caption: \"Rev. C\" },\n  { id: \"03\", title: \"Bench Jig\", caption: \"Rev. B\" },\n  { id: \"04\", title: \"Vernier Set\", caption: \"Rev. A\" },\n  { id: \"05\", title: \"Ratchet Frame\", caption: \"Rev. D\" },\n  { id: \"06\", title: \"Sounding Rail\", caption: \"Rev. B\" },\n  { id: \"07\", title: \"Gantry Head\", caption: \"Rev. A\" },\n  { id: \"08\", title: \"Solder Bridge\", caption: \"Rev. C\" },\n];\n\nconst STEP_X = 34; // px lateral drift per card\nconst STEP_Y = -16; // px vertical rise per card\nconst STEP_Z = 260; // px receding depth per card\nconst FOCUS_WINDOW = 130; // depth window (px) counted as \"in focus\"\n\nfunction useReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onChange = () => setReduced(mq.matches);\n    onChange();\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduced;\n}\n\nexport function GantryRun({ items = DEFAULT_ITEMS, className = \"\" }: GantryRunProps) {\n  const sectionRef = useRef<HTMLElement>(null);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const labelRef = useRef<HTMLSpanElement>(null);\n  const reduced = useReducedMotion();\n  const doubled = [...items, ...items];\n  const loopDistance = items.length * STEP_Z;\n\n  useEffect(() => {\n    const section = sectionRef.current;\n    const track = trackRef.current;\n    if (!section || !track) return;\n\n    let raf = 0;\n    let p = 0; // scroll progress 0..1\n    let tiltX = 0;\n    let tiltY = 0;\n    let tiltTargetX = 0;\n    let tiltTargetY = 0;\n    let focusIndex = -1;\n    let dirty = true;\n\n    const frame = () => {\n      dirty = false;\n      const camZ = p * loopDistance;\n\n      const k = 0.1;\n      tiltX += (tiltTargetX - tiltX) * k;\n      tiltY += (tiltTargetY - tiltY) * k;\n      track.style.transform = `rotateX(${tiltY.toFixed(3)}deg) rotateY(${tiltX.toFixed(3)}deg)`;\n\n      let bestDz = Infinity;\n      let bestIdx = -1;\n      for (let i = 0; i < doubled.length; i++) {\n        const el = cardRefs.current[i];\n        if (!el) continue;\n        const worldZ = i * STEP_Z;\n        const dz = worldZ - camZ;\n        const adz = Math.abs(dz);\n        if (adz < bestDz) {\n          bestDz = adz;\n          bestIdx = i;\n        }\n        // cull cards far behind the camera or far down the track\n        if (dz < -STEP_Z * 1.5 || dz > loopDistance + STEP_Z * 1.5) {\n          el.style.opacity = \"0\";\n          continue;\n        }\n        const worldX = i * STEP_X;\n        const worldY = i * STEP_Y;\n        const inFocus = adz < FOCUS_WINDOW;\n        const scale = inFocus ? 1.05 : 1;\n        el.style.transform = `translate3d(${worldX}px, ${worldY}px, ${(-dz).toFixed(1)}px) rotateY(-6deg) scale(${scale})`;\n        const fade = Math.max(0, 1 - Math.max(0, dz + STEP_Z) / (loopDistance * 0.55));\n        el.style.opacity = String(Math.min(1, Math.max(0, fade)));\n        el.dataset.focused = inFocus ? \"true\" : \"false\";\n      }\n\n      if (bestIdx !== focusIndex && bestIdx >= 0) {\n        focusIndex = bestIdx;\n        const item = items[bestIdx % items.length];\n        if (labelRef.current && item) {\n          labelRef.current.textContent = `${item.id} — ${item.title}`;\n        }\n      }\n\n      const tiltSettled = Math.abs(tiltTargetX - tiltX) < 0.02 && Math.abs(tiltTargetY - tiltY) < 0.02;\n      if (!dirty && tiltSettled) {\n        raf = 0;\n        return;\n      }\n      raf = requestAnimationFrame(frame);\n    };\n\n    const wake = () => {\n      if (!raf) raf = requestAnimationFrame(frame);\n    };\n\n    const readScroll = () => {\n      const rect = section.getBoundingClientRect();\n      const trackLen = rect.height - window.innerHeight;\n      p = trackLen > 0 ? Math.min(1, Math.max(0, -rect.top / trackLen)) : 0;\n      dirty = true;\n      wake();\n    };\n\n    const onPointer = (e: PointerEvent) => {\n      const rect = section.getBoundingClientRect();\n      const vw = window.innerWidth;\n      const vh = Math.min(window.innerHeight, rect.height);\n      tiltTargetX = ((e.clientX - vw / 2) / vw) * 14;\n      tiltTargetY = -((e.clientY - vh / 2) / vh) * 8;\n      dirty = true;\n      wake();\n    };\n    const onLeave = () => {\n      tiltTargetX = 0;\n      tiltTargetY = 0;\n      dirty = true;\n      wake();\n    };\n\n    readScroll();\n    wake();\n    window.addEventListener(\"scroll\", readScroll, { passive: true });\n    window.addEventListener(\"resize\", readScroll);\n    window.addEventListener(\"pointermove\", onPointer, { passive: true });\n    window.addEventListener(\"pointerleave\", onLeave);\n    return () => {\n      cancelAnimationFrame(raf);\n      window.removeEventListener(\"scroll\", readScroll);\n      window.removeEventListener(\"resize\", readScroll);\n      window.removeEventListener(\"pointermove\", onPointer);\n      window.removeEventListener(\"pointerleave\", onLeave);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [items.length, loopDistance, reduced]);\n\n  // prefers-reduced-motion: no scroll scrub, no 3D tilt, no receding track —\n  // a plain static grid of the same cards, same tokens, same focus styling\n  // rules minus the depth-driven trigger.\n  if (reduced) {\n    return (\n      <section data-gallery-gantry-track data-gantry-viewport className={`bg-background px-6 py-16 ${className}`}>\n        <div className=\"mx-auto flex max-w-4xl items-center justify-between pb-6\">\n          <span className=\"font-mono text-xs uppercase tracking-widest text-ns-muted\">Gantry catalog</span>\n          <span className=\"font-mono text-xs tabular-nums text-foreground\">{items.length} items</span>\n        </div>\n        <div className=\"mx-auto grid max-w-4xl grid-cols-2 gap-4 sm:grid-cols-4\">\n          {items.map((item) => (\n            <div\n              key={item.id}\n              data-gantry-card\n              className=\"flex h-[220px] flex-col justify-between rounded-[12px] border border-border bg-surface p-4 shadow-sm\"\n            >\n              <span className=\"font-mono text-[10px] uppercase tracking-widest text-ns-muted\">{item.id}</span>\n              <div\n                aria-hidden\n                className=\"h-16 w-full rounded-sm border border-border\"\n                style={{\n                  backgroundImage:\n                    \"repeating-linear-gradient(135deg, var(--border) 0px, var(--border) 1px, transparent 1px, transparent 9px)\",\n                }}\n              />\n              <div>\n                <p className=\"truncate text-sm font-medium text-foreground\">{item.title}</p>\n                {item.caption ? <p className=\"truncate text-xs text-ns-muted\">{item.caption}</p> : null}\n              </div>\n            </div>\n          ))}\n        </div>\n      </section>\n    );\n  }\n\n  return (\n    <section ref={sectionRef} data-gallery-gantry-track className={`relative h-[400vh] bg-background ${className}`}>\n      <div data-gantry-viewport className=\"sticky top-0 flex h-screen w-full flex-col overflow-hidden\">\n\n        <div\n          data-gantry-stage\n          className=\"flex flex-1 items-center justify-center\"\n          style={{ perspective: \"1400px\", perspectiveOrigin: \"50% 45%\" }}\n        >\n          <div ref={trackRef} className=\"relative h-0 w-0\" style={{ transformStyle: \"preserve-3d\" }}>\n            {doubled.map((item, i) => (\n              <div\n                key={`${item.id}-${i}`}\n                ref={(el) => {\n                  cardRefs.current[i] = el;\n                }}\n                data-gantry-card\n                className=\"absolute flex h-[220px] w-[168px] -translate-x-1/2 -translate-y-1/2 flex-col justify-between rounded-[12px] border bg-surface p-4 shadow-sm transition-[border-color,box-shadow] duration-200 ease-out data-[focused=true]:border-ns-accent data-[focused=true]:shadow-md\"\n                style={{ borderColor: \"var(--border)\", transformStyle: \"preserve-3d\" }}\n              >\n                <span className=\"font-mono text-[10px] uppercase tracking-widest text-ns-muted\">{item.id}</span>\n                <div\n                  aria-hidden\n                  className=\"h-16 w-full rounded-sm border border-border\"\n                  style={{\n                    backgroundImage:\n                      \"repeating-linear-gradient(135deg, var(--border) 0px, var(--border) 1px, transparent 1px, transparent 9px)\",\n                  }}\n                />\n                <div>\n                  <p className=\"truncate text-sm font-medium text-foreground\">{item.title}</p>\n                  {item.caption ? <p className=\"truncate text-xs text-ns-muted\">{item.caption}</p> : null}\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n\n        <div className=\"pointer-events-none absolute inset-x-0 bottom-6 flex flex-col items-center gap-1.5\">\n          <span ref={labelRef} className=\"font-mono text-xs tabular-nums text-foreground\">\n            {items[0] ? `${items[0].id} — ${items[0].title}` : \"\"}\n          </span>\n          <p className=\"text-center font-mono text-[11px] tracking-widest text-ns-muted\">SCROLL TO RUN THE GANTRY</p>\n        </div>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/gallery-gantry-track.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "scroll",
      "3d",
      "gallery",
      "gantry",
      "perspective",
      "parallax",
      "cursor"
    ],
    "instruction": "Build a 400vh section with a sticky full-viewport inner div. Cards are laid out along a 'gantry track': item i sits at world position (i*34px lateral, i*-16px vertical rise, i*260px depth), and the item list is rendered TWICE back to back (a loop buffer) so a duplicate of the first card is already approaching as the last real card recedes out of frame. A real CSS perspective (1400px, origin 50% 45%) on the viewport wrapper plus transform-style: preserve-3d on the track and every card lets the browser do the actual projection — each card's transform (translate3d + a fixed rotateY(-6deg) + a small in-focus scale bump) is written directly to its ref every frame, not through React state. Scroll progress is read from the section's getBoundingClientRect in a passive scroll listener (0 at the section's top edge reaching the viewport, 1 once its bottom edge does), mapped to camera depth camZ = progress * (item count * 260px) — since that span equals exactly the depth of ONE set of the duplicated pair, the pass reads as a single continuous, closed loop: at progress 1 the camera is back at the seam. Independently of scroll, the pointer tilts the whole track (not any individual card) toward itself: pointer offset from viewport center maps to a target rotateX/rotateY on the track wrapper, eased in at a fixed 0.1 lerp factor per frame, so the tray reads as a tilting specimen table rather than N cards each springing at the mouse. Every frame the card whose depth (|world Z - camZ|) is smallest is the 'focus' card: it gets `data-focused=\"true\"\" (styled via a plain Tailwind data-attribute variant into an --ns-accent border and a raised shadow, both transitioning over 200ms) and its title is written directly into a small mono readout above the scroll hint via textContent, so attention follows camera depth, not cursor proximity. Cards more than 1.5 track-steps behind the camera or past the loop's far edge are set to opacity 0 to bound the DOM work; visible cards fade out gradually as they approach either edge of the visible window. Default items render as token-styled specimen cards (id, a diagonal-hatch swatch built from a `repeating-linear-gradient` of `var(--border)`, title, caption) rather than photographic images, so the component has no network dependency and stays theme-reactive by construction. prefers-reduced-motion replaces the entire scroll-scrubbed 3D experience with a plain static grid of the same cards and tokens — no scroll listener, no tilt, no receding track — rather than merely slowing the scrub down. Props: items (id/title/caption), className. Zero dependencies."
  },
  "type": "registry:ui"
}