{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gyro-gallery",
  "type": "registry:block",
  "title": "Gyro Gallery",
  "description": "Gallery tiles riding three concentric, counter-rotating orbits on one tilted plane, like the rings of a gyroscope. The rig turns to face the pointer and calms as it nears; pointing near a piece brings it forward on a lime spoke from the core with its name beside it, and a click opens it. Page scroll winds the rings and swivels the plane.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/gyro-gallery.tsx",
      "type": "registry:component",
      "target": "components/tweenui/gyro-gallery.tsx",
      "content": "'use client';\n\nimport { useId, useRef, useState, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport interface GyroItem {\n  title: string;\n  image: string;\n  /** A short tag shown under the title, e.g. \"Block\". */\n  kind?: string;\n  href?: string;\n}\n\nexport interface GyroGalleryProps extends ComponentPropsWithoutRef<'section'> {\n  items?: GyroItem[];\n  title?: string;\n  description?: string;\n}\n\n/**\n * Three concentric orbits, counter-rotating like the rings of a gyroscope. `radius` is a\n * share of the stage's shorter side and `spin` is radians per second (negative runs\n * backwards). The rings share one plane on purpose: concentric ellipses on a shared\n * plane never cross, so tiles on different rings never pile onto each other, and the\n * gaps between rings are wider than a tile even at the steepest the pointer can tip them.\n */\nconst ORBITS = [\n  { radius: 0.19, spin: 0.16, count: 5 },\n  { radius: 0.31, spin: -0.11, count: 8 },\n  { radius: 0.43, spin: 0.07, count: 11 },\n] as const;\n/** The plane the rings share: leaned back by TILT, that lean turned by TURN on screen. */\nconst TILT = 34;\nconst TURN = -10;\n\n/** Focal length of the projection, in px. Smaller is a stronger perspective. */\nconst FOCAL = 1000;\n/** How far the rig turns to face the pointer, in degrees. Kept gentle so targets stay put. */\nconst YAW = 22;\nconst PITCH = 12;\n/**\n * Scroll, across one pass of the section through the viewport: the plane swivels on\n * screen by SCROLL_TURN degrees and tips by ±SCROLL_TILT, and each ring winds through\n * SCROLL_WIND radians per unit of its own spin. Swivelling in the screen plane cannot\n * make rings cross, and the small tip keeps the gaps wider than a tile.\n */\nconst SCROLL_TURN = 50;\nconst SCROLL_TILT = 6;\nconst SCROLL_WIND = 28;\n/**\n * Focus is by proximity: the tile nearest the pointer, within this many px of its\n * centre, is the one you are pointing at. A rival has to be this much closer to steal it.\n */\nconst FOCUS_RADIUS = 130;\nconst FOCUS_STICK = 24;\n/** The focused tile drifts this share of the way to the pointer, up to LIFT_MAX px. */\nconst LIFT = 0.35;\nconst LIFT_MAX = 46;\nconst FOCUS_GROW = 2.1;\n/** Spin while the pointer is over the gallery but nothing is focused, as a share of cruise. */\nconst CALM = 0.22;\n/**\n * The rings are framed to their real on-screen extent every frame: centred in the stage,\n * and scaled down to keep this many px clear at the edges if they would not otherwise fit.\n * A leaned-back ring is lopsided (its near half is larger and lower), so the plain centre\n * of the stage is not where it looks centred.\n */\nconst FRAME_MARGIN = 36;\n\nconst deg = Math.PI / 180;\n\nconst SITE = 'https://tween-ui.vercel.app';\nconst poster = (kind: 'block' | 'component', name: string, title: string): GyroItem => ({\n  title,\n  kind: kind === 'block' ? 'Block' : 'Component',\n  image: `${SITE}/media/${kind}s/${name}-poster.webp`,\n  href: `${SITE}/${kind}/${name}`,\n});\n\nconst DEFAULT_ITEMS: GyroItem[] = [\n  poster('block', 'shutter-slider', 'Shutter Slider'),\n  poster('component', 'sliding-tabs', 'Sliding Tabs'),\n  poster('block', 'kinetic-type-ring', 'Kinetic Type Ring'),\n  poster('block', 'cta-starfall', 'CTA Starfall'),\n  poster('component', 'flip-card', 'Flip Card'),\n  poster('block', 'tab-wipe', 'Tab Wipe'),\n  poster('block', 'integration-hub', 'Integration Hub'),\n  poster('component', 'logo-orbit', 'Logo Orbit'),\n  poster('block', 'pricing-plan-switch', 'Pricing Plan Switch'),\n  poster('block', 'cube-roll-item', 'Cube Roll Item'),\n  poster('component', 'image-fan-slider', 'Image Fan Slider'),\n  poster('block', 'grid-cascade', 'Grid Cascade'),\n  poster('block', 'testimonial-reel-focus', 'Testimonial Reel Focus'),\n  poster('component', 'wordmark-reveal', 'Wordmark Reveal'),\n  poster('block', 'footer-wordmark-rise', 'Footer Wordmark Rise'),\n  poster('block', 'loader-iris-ring', 'Loader Iris Ring'),\n  poster('component', 'morphing-text', 'Morphing Text'),\n  poster('block', 'testimonial-ring', 'Testimonial Ring'),\n  poster('block', 'card-spotlight-grid', 'Card Spotlight Grid'),\n  poster('block', 'column-drift', 'Column Drift'),\n];\n\n/** Which orbit each tile rides, and where on it it starts. */\nconst SLOTS = ORBITS.flatMap((orbit, ring) =>\n  Array.from({ length: orbit.count }, (_, i) => ({\n    ring,\n    angle: (i / orbit.count) * Math.PI * 2 + ring * 0.7,\n  }))\n);\n\nconst clamp01 = (value: number) => Math.min(1, Math.max(0, value));\n\nconst GyroGallery = ({\n  items = DEFAULT_ITEMS,\n  title = 'The archive, in orbit.',\n  description = 'Point near a piece to bring it forward, click to open it, drag to spin the rings.',\n  className,\n  ...props\n}: GyroGalleryProps) => {\n  const count = items.length;\n  const tiles = SLOTS.map((slot, i) => ({ ...slot, item: items[i % Math.max(1, count)] }));\n\n  const [active, setActive] = useState<number | null>(null);\n  const rootRef = useRef<HTMLElement>(null);\n  const stageRef = useRef<HTMLDivElement>(null);\n  const coreRef = useRef<HTMLDivElement>(null);\n  const labelRef = useRef<HTMLDivElement>(null);\n  const tetherRef = useRef<SVGLineElement>(null);\n  const spokeRef = useRef<SVGSVGElement>(null);\n  const titleId = useId();\n\n  useGSAP(\n    () => {\n      const section = rootRef.current;\n      const stage = stageRef.current;\n      const core = coreRef.current;\n      const label = labelRef.current;\n      const tether = tetherRef.current;\n      const spoke = spokeRef.current;\n      if (!section || !stage || !core || !label || !tether || !spoke || !count) return;\n\n      const tileEls = Array.from(stage.querySelectorAll<HTMLElement>('[data-gyro-tile]'));\n      const reduce = prefersReducedMotion();\n\n      let width = stage.clientWidth;\n      let height = stage.clientHeight;\n      const measure = () => {\n        width = stage.clientWidth;\n        height = stage.clientHeight;\n      };\n\n      // Everything the frame loop reads; the intro and the pointer only ever write here\n      const rig = {\n        spread: reduce ? 1 : 0, // 0 = every tile at the centre, 1 = out on its orbit\n        lean: reduce ? 1 : 0, // 0 = orbits face-on, 1 = at their full gyroscope tilt\n        yaw: 0,\n        pitch: 0,\n        phase: ORBITS.map(() => 0),\n        scroll: 0, // -0.5 as the section enters below, 0 centred, 0.5 as it leaves above\n        wind: 0, // how far scrolling has wound the rings: page scroll plus wheel turns\n        nudge: 0, // a swivel from the wheel that springs back once it stops\n      };\n      let scrollTarget = 0;\n      let wheelWind = 0;\n      let nudgeTarget = 0;\n      const pointer = { x: 0, y: 0, inside: false };\n      const spots = tileEls.map(() => ({ x: 0, y: 0, scale: 1, z: 0 }));\n      const lifts = tileEls.map(() => ({ x: 0, y: 0 }));\n      const grow = tileEls.map(() => 1);\n      let focus: number | null = null;\n      const view = { dx: 0, dy: 0, fit: 1, framed: false };\n      let dragging = false;\n      let moved = false;\n\n      /** Swap the focused tile: the label, the tether, the core and the cursor all follow. */\n      const setFocus = (next: number | null) => {\n        if (next === focus) return;\n        focus = next;\n        setActive(next);\n        stage.style.cursor = next === null ? '' : tiles[next].item.href ? 'pointer' : 'default';\n        gsap.to(core, { opacity: next === null ? 1 : 0.18, duration: reduce ? 0 : 0.35 });\n        gsap.to(label, { autoAlpha: next === null ? 0 : 1, duration: reduce ? 0 : 0.25 });\n        // The spoke and its hub only show while a piece is in focus\n        gsap.to(spoke, { opacity: next === null ? 0 : 1, duration: reduce ? 0 : 0.2 });\n        if (next !== null) {\n          // The spoke draws itself out from the core to the piece\n          gsap.fromTo(\n            tether,\n            { attr: { 'stroke-dashoffset': reduce ? 0 : 1 } },\n            { attr: { 'stroke-dashoffset': 0 }, duration: reduce ? 0 : 0.45, ease: 'power3.out' }\n          );\n        }\n      };\n\n      const pickFocus = () => {\n        if (!pointer.inside || moved || rig.spread < 0.9) return setFocus(null);\n        let best: number | null = null;\n        let bestDistance = Infinity;\n        spots.forEach((spot, i) => {\n          const distance = Math.hypot(pointer.x - spot.x, pointer.y - spot.y);\n          if (distance < bestDistance) {\n            best = i;\n            bestDistance = distance;\n          }\n        });\n        // Keep the current piece unless a rival is clearly closer, so focus never flickers\n        if (focus !== null) {\n          const held = Math.hypot(pointer.x - spots[focus].x, pointer.y - spots[focus].y);\n          if (held < FOCUS_RADIUS + FOCUS_STICK && held < bestDistance + FOCUS_STICK) return;\n        }\n        setFocus(bestDistance < FOCUS_RADIUS ? best : null);\n      };\n\n      const place = () => {\n        const size = Math.min(width, height);\n        const yaw = rig.yaw * deg;\n        const pitch = rig.pitch * deg;\n        const [cy, sy, cp, sp] = [Math.cos(yaw), Math.sin(yaw), Math.cos(pitch), Math.sin(pitch)];\n\n        // Where every tile sits on screen this frame, before any pointer effects\n        tileEls.forEach((_, i) => {\n          const { ring, angle } = tiles[i];\n          const orbit = ORBITS[ring];\n          const r = orbit.radius * size * rig.spread;\n          // Scrolling winds every ring along its own direction, the middle one against the rest\n          const theta = angle + rig.phase[ring] + rig.wind * orbit.spin * SCROLL_WIND;\n\n          // On the orbit's own plane, then lean it back and turn the lean around the screen\n          // ...and swivels the plane on screen, tipping it a little further as it passes\n          const swivel = gsap.utils.clamp(-0.6, 0.6, rig.scroll + rig.nudge);\n          const tilt = (TILT + swivel * 2 * SCROLL_TILT) * rig.lean * deg;\n          const turn = (TURN + swivel * SCROLL_TURN) * rig.lean * deg;\n          const ox = Math.cos(theta) * r;\n          const oy = Math.sin(theta) * r * Math.cos(tilt);\n          const oz = Math.sin(theta) * r * Math.sin(tilt);\n          let x = ox * Math.cos(turn) - oy * Math.sin(turn);\n          let y = ox * Math.sin(turn) + oy * Math.cos(turn);\n          let z = oz;\n\n          // The whole rig turns to face the pointer\n          [x, z] = [x * cy + z * sy, -x * sy + z * cy];\n          [y, z] = [y * cp - z * sp, y * sp + z * cp];\n\n          const scale = FOCAL / (FOCAL - z);\n          Object.assign(spots[i], { x: x * scale, y: y * scale, scale, z });\n        });\n\n        // Frame the rings to their real extent: centre them, and fit them if they are too big\n        const tileW = tileEls[0]?.offsetWidth ?? 0;\n        const tileH = tileEls[0]?.offsetHeight ?? 0;\n        let [minX, maxX, minY, maxY] = [Infinity, -Infinity, Infinity, -Infinity];\n        spots.forEach((spot) => {\n          minX = Math.min(minX, spot.x - (tileW * spot.scale) / 2);\n          maxX = Math.max(maxX, spot.x + (tileW * spot.scale) / 2);\n          minY = Math.min(minY, spot.y - (tileH * spot.scale) / 2);\n          maxY = Math.max(maxY, spot.y + (tileH * spot.scale) / 2);\n        });\n        const roomX = (width - FRAME_MARGIN * 2) / Math.max(1, maxX - minX);\n        const roomY = (height - FRAME_MARGIN * 2) / Math.max(1, maxY - minY);\n        const settle = reduce || !view.framed ? 1 : 0.08;\n        view.framed = width > 0;\n        view.dx += (-(minX + maxX) / 2 - view.dx) * settle;\n        view.dy += (-(minY + maxY) / 2 - view.dy) * settle;\n        view.fit += (Math.min(1, roomX, roomY) - view.fit) * settle;\n        spots.forEach((spot) => {\n          spot.x = (spot.x + view.dx) * view.fit;\n          spot.y = (spot.y + view.dy) * view.fit;\n          spot.scale *= view.fit;\n        });\n        const shift = `translate(${view.dx * view.fit}px, ${view.dy * view.fit}px)`;\n        core.style.transform = shift;\n        spoke.style.transform = shift;\n\n        pickFocus();\n\n        tileEls.forEach((tile, i) => {\n          const spot = spots[i];\n          const isFocus = focus === i;\n\n          // The focused piece drifts out to meet the pointer\n          const lift = lifts[i];\n          let tx = 0;\n          let ty = 0;\n          if (isFocus) {\n            const dx = pointer.x - spot.x;\n            const dy = pointer.y - spot.y;\n            const reach = Math.min(Math.hypot(dx, dy) * LIFT, LIFT_MAX);\n            const length = Math.hypot(dx, dy) || 1;\n            tx = (dx / length) * reach;\n            ty = (dy / length) * reach;\n          }\n          const follow = reduce ? 1 : 0.16;\n          lift.x += (tx - lift.x) * follow;\n          lift.y += (ty - lift.y) * follow;\n          grow[i] += ((isFocus ? FOCUS_GROW : 1) - grow[i]) * follow;\n\n          const x = spot.x + lift.x;\n          const y = spot.y + lift.y;\n          const depth = clamp01((spot.z + Math.min(width, height) * 0.5) / Math.min(width, height));\n          const soften = focus !== null && !isFocus ? 0.45 : 1;\n\n          tile.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%) scale(${spot.scale * grow[i]})`;\n          tile.style.opacity = String(\n            isFocus ? 1 : (0.35 + 0.65 * depth) * soften * clamp01(rig.spread * 4)\n          );\n          tile.style.zIndex = String(isFocus ? 5000 : 1000 + Math.round(spot.z));\n          tile.toggleAttribute('data-focused', isFocus);\n\n          if (isFocus) {\n            // The label rides beside the piece, on the side facing the middle of the stage\n            const half = (tile.offsetWidth * spot.scale * grow[i]) / 2;\n            const toLeft = x > 0;\n            label.style.transform = `translate(${x + (toLeft ? -half - 16 : half + 16)}px, ${y}px) translate(${toLeft ? '-100%' : '0'}, -50%)`;\n            // The spoke's svg moves with the core, so its end is measured from there\n            tether.setAttribute('x2', String(x - view.dx * view.fit));\n            tether.setAttribute('y2', String(y - view.dy * view.fit));\n          }\n        });\n      };\n\n      const observer = new ResizeObserver(() => {\n        measure();\n        place();\n      });\n      observer.observe(stage);\n      measure();\n      place();\n\n      // Pointer: where it is, how far a drag has thrown the spin, and whether a tap is a first tap\n      let throwSpin = 0;\n      let dragX = 0;\n      let speed = 1;\n      let tapFocused = false;\n\n      const track = (event: PointerEvent) => {\n        const box = stage.getBoundingClientRect();\n        pointer.x = event.clientX - box.left - box.width / 2;\n        pointer.y = event.clientY - box.top - box.height / 2;\n        pointer.inside = true;\n      };\n      const onMove = (event: PointerEvent) => {\n        track(event);\n        if (dragging) {\n          const dx = event.clientX - dragX;\n          dragX = event.clientX;\n          if (Math.abs(dx) > 1) moved = true;\n          throwSpin = gsap.utils.clamp(-14, 14, throwSpin + dx * 0.06);\n        }\n        if (reduce) place();\n      };\n      const onLeave = (event: PointerEvent) => {\n        // A finger leaves the moment it lifts; keep its focus for the second tap\n        if (event.pointerType !== 'mouse') return;\n        pointer.inside = false;\n        if (reduce) place();\n      };\n      const onDown = (event: PointerEvent) => {\n        track(event);\n        const before = focus;\n        place();\n        tapFocused = event.pointerType !== 'mouse' && focus !== before;\n        dragging = !reduce;\n        moved = false;\n        dragX = event.clientX;\n        if (dragging) stage.setPointerCapture?.(event.pointerId);\n      };\n      const onUp = (event: PointerEvent) => {\n        dragging = false;\n        stage.releasePointerCapture?.(event.pointerId);\n      };\n      // A click opens the focused piece wherever it lands, except at the end of a drag or\n      // on the first tap of a finger, which only brings the piece forward\n      const onClick = (event: MouseEvent) => {\n        const href = focus === null ? undefined : tiles[focus].item.href;\n        if (moved || tapFocused || !href) {\n          if (moved || tapFocused) event.preventDefault();\n          moved = false;\n          tapFocused = false;\n          return;\n        }\n        if ((event.target as Element).closest('[data-focused]')) return;\n        event.preventDefault();\n        if (event.metaKey || event.ctrlKey) window.open(href, '_blank', 'noopener');\n        else window.location.assign(href);\n      };\n\n      stage.addEventListener('pointermove', onMove);\n      stage.addEventListener('pointerleave', onLeave);\n      stage.addEventListener('pointerdown', onDown);\n      stage.addEventListener('pointerup', onUp);\n      stage.addEventListener('pointercancel', onUp);\n      stage.addEventListener('click', onClick, true);\n      const cleanupPointer = () => {\n        stage.removeEventListener('pointermove', onMove);\n        stage.removeEventListener('pointerleave', onLeave);\n        stage.removeEventListener('pointerdown', onDown);\n        stage.removeEventListener('pointerup', onUp);\n        stage.removeEventListener('pointercancel', onUp);\n        stage.removeEventListener('click', onClick, true);\n        stage.style.cursor = '';\n      };\n\n      if (reduce) {\n        return () => {\n          observer.disconnect();\n          cleanupPointer();\n        };\n      }\n\n      const tick = (_time: number, deltaMs: number) => {\n        const dt = Math.min(deltaMs, 64) / 1000;\n\n        // The rig turns to face the pointer, but holds still while a piece is in focus,\n        // so the piece you are reaching for does not slide away under the cursor\n        if (focus === null) {\n          const nx = pointer.inside ? gsap.utils.clamp(-1, 1, pointer.x / (width / 2)) : 0;\n          const ny = pointer.inside ? gsap.utils.clamp(-1, 1, pointer.y / (height / 2)) : 0;\n          const ease = Math.min(1, dt * 2.4);\n          rig.yaw += (nx * YAW - rig.yaw) * ease;\n          rig.pitch += (-ny * PITCH - rig.pitch) * ease;\n        }\n\n        // Spin: calm while the pointer is over the gallery, still while a piece is in focus,\n        // thrown by a drag and wound back down afterwards\n        const base = dragging ? 1 : focus !== null ? 0 : pointer.inside ? CALM : 1;\n        const target = base + throwSpin;\n        speed += (target - speed) * Math.min(1, dt * (focus !== null ? 6 : 2.4));\n        if (!dragging) throwSpin *= Math.pow(0.18, dt);\n        ORBITS.forEach((orbit, ring) => {\n          rig.phase[ring] += orbit.spin * speed * dt;\n        });\n\n        // Scroll is followed with a little lag, so a flick of the wheel still reads as a turn;\n        // the wheel's own swivel springs back to rest once the wheel goes quiet\n        const follow = Math.min(1, dt * 4);\n        rig.scroll += (scrollTarget - rig.scroll) * follow;\n        rig.wind += (scrollTarget + wheelWind - rig.wind) * follow;\n        nudgeTarget *= Math.pow(0.06, dt);\n        rig.nudge += (nudgeTarget - rig.nudge) * Math.min(1, dt * 6);\n\n        place();\n      };\n\n      let ticking = false;\n      const setTicking = (on: boolean) => {\n        if (on === ticking) return;\n        ticking = on;\n        if (on) gsap.ticker.add(tick);\n        else gsap.ticker.remove(tick);\n      };\n\n      // The intro: tiles spiral out of the centre, then the orbits tip back into a gyroscope\n      const intro = gsap\n        .timeline({ paused: true })\n        .to(rig, { spread: 1, duration: 1.8, ease: 'expo.out' }, 0)\n        .to(rig.phase, { 0: '+=1.4', 1: '-=1.1', 2: '+=0.8', duration: 1.8, ease: 'expo.out' }, 0)\n        .to(rig, { lean: 1, duration: 1.6, ease: 'power3.inOut' }, 0.5);\n\n      const trigger = ScrollTrigger.create({\n        trigger: section,\n        start: 'top bottom',\n        end: 'bottom top',\n        onEnter: () => intro.play(),\n        onEnterBack: () => intro.play(),\n        onToggle: (self) => setTicking(self.isActive),\n        // Scroll position scrubs the rig; scroll speed flings the rings like a drag would\n        onUpdate: (self) => {\n          scrollTarget = self.progress - 0.5;\n          throwSpin = gsap.utils.clamp(-6, 6, throwSpin + self.getVelocity() * 0.0012);\n        },\n      });\n      rig.scroll = rig.wind = scrollTarget = trigger.progress - 0.5;\n      if (trigger.isActive) {\n        intro.play();\n        setTicking(true);\n      }\n\n      // The wheel (or a vertical swipe) over the gallery also drives it, for pages where\n      // the gallery fills the window and there is nothing to scroll. It is only counted\n      // when the page did not move, so a page that does scroll is never driven twice.\n      const scrollIntent = (delta: number) => {\n        const before = window.scrollY;\n        requestAnimationFrame(() => {\n          if (Math.abs(window.scrollY - before) > 0.5) return;\n          wheelWind += delta * 0.0012;\n          nudgeTarget = gsap.utils.clamp(-0.45, 0.45, nudgeTarget + delta * 0.0016);\n          throwSpin = gsap.utils.clamp(-6, 6, throwSpin + delta * 0.012);\n        });\n      };\n      const onWheel = (event: WheelEvent) => {\n        const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? height : 1;\n        scrollIntent(event.deltaY * scale);\n      };\n      let touchY: number | null = null;\n      const onTouchStart = (event: TouchEvent) => {\n        touchY = event.touches[0]?.clientY ?? null;\n      };\n      const onTouchMove = (event: TouchEvent) => {\n        const y = event.touches[0]?.clientY;\n        if (touchY === null || y === undefined) return;\n        scrollIntent(touchY - y);\n        touchY = y;\n      };\n      section.addEventListener('wheel', onWheel, { passive: true });\n      section.addEventListener('touchstart', onTouchStart, { passive: true });\n      section.addEventListener('touchmove', onTouchMove, { passive: true });\n\n      return () => {\n        setTicking(false);\n        intro.kill();\n        trigger.kill();\n        observer.disconnect();\n        cleanupPointer();\n        section.removeEventListener('wheel', onWheel);\n        section.removeEventListener('touchstart', onTouchStart);\n        section.removeEventListener('touchmove', onTouchMove);\n      };\n    },\n    { scope: rootRef, dependencies: [items] }\n  );\n\n  const shown = active === null ? null : tiles[active]?.item;\n\n  return (\n    <section\n      ref={rootRef}\n      aria-labelledby={titleId}\n      className={cn(\n        'relative isolate h-full min-h-[640px] w-full overflow-hidden bg-[#03110f] text-[#e8f3f2] max-sm:min-h-[560px]',\n        '@container [--tile:clamp(3.25rem,6.5cqw,5.5rem)]',\n        className\n      )}\n      {...props}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-1/2 left-1/2 -z-10 size-[min(80cqw,48rem)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(closest-side,rgb(4_95_100/0.4),rgb(4_95_100/0.1)_55%,rgb(4_95_100/0)_100%)]\"\n      />\n\n      {/* The core: the size of the archive, stepping back while a piece is in focus */}\n      <div\n        ref={coreRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-1/2 left-1/2 grid -translate-x-1/2 -translate-y-1/2 justify-items-center gap-1 text-center\"\n      >\n        <span className=\"text-[clamp(2rem,5cqw,3.5rem)] leading-none font-semibold tracking-[-0.04em] text-[#e8f3f2] tabular-nums\">\n          {count}\n        </span>\n        <span className=\"text-xs tracking-[0.14em] text-[#9fd4d6]/70 uppercase\">\n          pieces in orbit\n        </span>\n      </div>\n\n      {/* The spoke from the core to the piece in focus */}\n      <svg\n        ref={spokeRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-1/2 left-1/2 z-[1] size-px overflow-visible opacity-0\"\n      >\n        <line\n          ref={tetherRef}\n          x1=\"0\"\n          y1=\"0\"\n          x2=\"0\"\n          y2=\"0\"\n          pathLength={1}\n          strokeDasharray=\"1\"\n          strokeDashoffset=\"1\"\n          stroke=\"#c6f56f\"\n          strokeWidth=\"1.25\"\n          strokeLinecap=\"round\"\n        />\n        <circle r=\"3\" fill=\"#c6f56f\" />\n      </svg>\n\n      <div\n        ref={stageRef}\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 z-[2] cursor-grab touch-pan-y select-none active:cursor-grabbing\"\n      >\n        {tiles.map(({ item }, i) => {\n          const Tile = item.href ? 'a' : 'div';\n          return (\n            <Tile\n              key={`${item.title}-${i}`}\n              data-gyro-tile=\"\"\n              {...(item.href ? { href: item.href, tabIndex: -1 } : {})}\n              draggable={false}\n              className=\"group/tile absolute top-1/2 left-1/2 block w-(--tile) opacity-0 will-change-transform\"\n            >\n              <span className=\"relative block aspect-[16/10] overflow-hidden rounded-[5px] border border-[#9fd4d6]/16 bg-[#071d1b] shadow-[0_18px_40px_-16px_rgb(0_0_0/0.75)] transition-[border-color,box-shadow] duration-300 group-data-focused/tile:border-[#c6f56f] group-data-focused/tile:shadow-[0_0_0_1px_#c6f56f,0_24px_60px_-18px_rgb(198_245_111/0.35)] motion-reduce:transition-none\">\n                <img\n                  src={item.image}\n                  alt=\"\"\n                  draggable={false}\n                  loading=\"lazy\"\n                  className=\"absolute inset-0 size-full object-cover\"\n                />\n              </span>\n            </Tile>\n          );\n        })}\n\n        {/* The label rides beside the piece in focus */}\n        <div\n          ref={labelRef}\n          className=\"pointer-events-none invisible absolute top-1/2 left-1/2 z-[6000] w-max max-w-[16rem] rounded-lg border border-[#c6f56f]/30 bg-[#071d1b]/95 px-3.5 py-2.5 opacity-0 shadow-[0_18px_40px_-16px_rgb(0_0_0/0.8)] backdrop-blur-sm\"\n        >\n          {shown && (\n            <>\n              <span className=\"block text-[15px] leading-tight font-semibold tracking-tight text-[#e8f3f2]\">\n                {shown.title}\n              </span>\n              <span className=\"mt-1 flex items-center gap-2 text-[11px] tracking-[0.12em] text-[#9fd4d6]/70 uppercase\">\n                {shown.kind}\n                {shown.href && <span className=\"text-[#c6f56f]\">Click to open</span>}\n              </span>\n            </>\n          )}\n        </div>\n      </div>\n\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {shown ? `${shown.title}${shown.kind ? `, ${shown.kind}` : ''}` : ''}\n      </p>\n\n      <div className=\"pointer-events-none absolute bottom-0 left-0 z-[3] grid max-w-[26rem] gap-2 px-5 pb-6 sm:px-8 sm:pb-8\">\n        <h2\n          id={titleId}\n          className=\"text-[clamp(1.5rem,3cqw,2.25rem)] leading-[1.05] font-semibold tracking-[-0.035em] text-[#e8f3f2]\"\n        >\n          {title}\n        </h2>\n        <p className=\"text-sm leading-relaxed text-balance text-[#9fd4d6]/75\">{description}</p>\n      </div>\n\n      {/* The rig is decoration; the archive itself, for keyboards and screen readers */}\n      <ul className=\"sr-only\">\n        {items.map((item) => (\n          <li key={item.title}>{item.href ? <a href={item.href}>{item.title}</a> : item.title}</li>\n        ))}\n      </ul>\n    </section>\n  );\n};\n\nexport default GyroGallery;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/gyro-gallery\nGSAP plugins (ScrollTrigger) are registered inside the component, so there is nothing to set up.\nPass your own `items`; the three orbits hold 24 tiles and repeat your items around them, so even a handful fills the rig."
}