{
  "version": 1,
  "effects": [
    {
      "slug": "staggered-reveal",
      "category": "Reveals",
      "title": "Staggered reveal",
      "tagline": "Children rise in sequence when the section enters the viewport.",
      "page": "https://scrollvars.dev/fx/staggered-reveal.html",
      "requires": {
        "styles": [
          "core"
        ],
        "min": "1.9.0"
      },
      "file": "StaggeredReveal.tsx",
      "content": "// ScrollVars fx · staggered-reveal\n// Requires: npm i scrollvars · import 'scrollvars/styles/core.css' (layout)\n'use client'\nimport * as React from 'react'\nimport { Reveal } from 'scrollvars/react'\n\nexport function StaggeredReveal({ children, ...rest }: React.ComponentProps<typeof Reveal>) {\n  return (\n    <Reveal auto {...rest}>\n      {children}\n    </Reveal>\n  )\n}\n"
    },
    {
      "slug": "deck-spread",
      "category": "Reveals",
      "title": "Deck spread",
      "tagline": "A centered card deck deals itself into the grid. On arrival or scrubbed.",
      "page": "https://scrollvars.dev/fx/deck-spread.html",
      "requires": {
        "styles": [
          "core",
          "pin"
        ],
        "min": "1.9.0"
      },
      "file": "DeckSpread.tsx",
      "content": "// ScrollVars fx · deck-spread\n// Requires: npm i scrollvars · import 'scrollvars/styles/core.css' (layout)\n// Knobs: gap, --sv-mid = (children-1)/2 is set for you.\n'use client'\nimport * as React from 'react'\nimport { Track } from 'scrollvars/react'\n\n// toArray keys each level from \".0\", so a fragment's children collide with\n// their uncles without the parent's key in front (React.Children.toArray\n// does NOT flatten a fragment: it comes back as ONE element). Recurse into\n// one, keyed like the coverflow rail's own normalization. Anything that is\n// not an element (a portal renders elsewhere) passes through untouched: it\n// holds no place in the fan.\nfunction deckSlots(children: React.ReactNode, prefix = ''): React.ReactNode[] {\n  const list: React.ReactNode[] = []\n  React.Children.toArray(children).forEach((child) => {\n    if (!React.isValidElement(child)) { list.push(child); return }\n    const key = prefix ? `${prefix}:${child.key ?? ''}` : (child.key ?? '')\n    if (child.type === React.Fragment) {\n      list.push(...deckSlots((child.props as { children?: React.ReactNode }).children, key))\n    } else {\n      list.push(React.cloneElement(child, { key }))\n    }\n  })\n  return list\n}\n\nexport function DeckSpread({\n  children,\n  gap = 16,\n  className,\n}: {\n  children: React.ReactNode\n  gap?: number\n  className?: string\n}) {\n  // a conditional card ({show && <Card/>}) is false, and Children.map still\n  // calls back for it, so the deck got an empty cell and --sv-mid centred\n  // the fan on a card that is not there.\n  const items = deckSlots(children)\n  const count = items.filter(React.isValidElement).length\n  let order = -1\n  return (\n    <Track className={className}>\n      <div\n        className=\"sv-spread sv-spread-in\"\n        style={{ '--sv-gap': gap + 'px', '--sv-mid': (count - 1) / 2 } as React.CSSProperties}\n      >\n        {items.map((child) =>\n          React.isValidElement(child) ? (\n            <div key={child.key} style={{ '--sv-order': ++order } as React.CSSProperties}>\n              {child}\n            </div>\n          ) : (\n            child\n          )\n        )}\n      </div>\n    </Track>\n  )\n}\n"
    },
    {
      "slug": "curtain",
      "category": "Pinned scenes",
      "title": "Curtain",
      "tagline": "Two panels slide apart as you scroll through a pinned stretch.",
      "page": "https://scrollvars.dev/fx/curtain.html",
      "requires": {
        "styles": [
          "pin"
        ],
        "min": "1.14.0",
        "tailwind": true
      },
      "file": "Curtain.tsx",
      "content": "// ScrollVars fx · curtain\n// Requires: npm i scrollvars · import 'scrollvars/styles/pin.css' (layout)\n'use client'\nimport * as React from 'react'\nimport { Track } from 'scrollvars/react'\n\nexport function Curtain({\n  children,\n  panelClassName = 'bg-zinc-900',\n  height = '250vh',\n}: {\n  children: React.ReactNode // the revealed content\n  panelClassName?: string\n  height?: string\n}) {\n  return (\n    <Track pin={height}>\n      <div className=\"sv-stage\">\n        <div className=\"grid h-full place-items-center\">{children}</div>\n        <div className={`sv-curtain-l absolute inset-y-0 left-0 w-1/2 ${panelClassName}`} />\n        <div className={`sv-curtain-r absolute inset-y-0 right-0 w-1/2 ${panelClassName}`} />\n      </div>\n    </Track>\n  )\n}\n"
    },
    {
      "slug": "horizontal-rail",
      "category": "Pinned scenes",
      "title": "Horizontal rail",
      "tagline": "Vertical scroll travels a horizontal track through a pinned stage.",
      "page": "https://scrollvars.dev/fx/horizontal-rail.html",
      "requires": {
        "styles": [
          "pin"
        ],
        "min": "1.14.1",
        "tailwind": true
      },
      "file": "HorizontalRail.tsx",
      "content": "// ScrollVars fx · horizontal-rail\n// Requires: npm i scrollvars · import 'scrollvars/styles/pin.css' (layout)\n'use client'\nimport * as React from 'react'\nimport { Track } from 'scrollvars/react'\n\nexport function HorizontalRail({\n  children,\n  height = '300vh',\n}: {\n  children: React.ReactNode // the cards (give each a fixed width + shrink-0)\n  height?: string\n}) {\n  return (\n    <Track pin={height}>\n      <div className=\"sv-stage flex items-center\">\n        <div className=\"sv-rail flex gap-4 px-[10vw]\">{children}</div>\n      </div>\n    </Track>\n  )\n}\n"
    },
    {
      "slug": "sequenced-scrub",
      "category": "Pinned scenes",
      "title": "Sequenced scrub",
      "tagline": "Each child animates over its own slice of the pin. Choreography without a timeline.",
      "page": "https://scrollvars.dev/fx/sequenced-scrub.html",
      "requires": {
        "styles": [
          "pin"
        ],
        "min": "1.14.0"
      },
      "file": "SequencedScrub.tsx",
      "content": "// ScrollVars fx · sequenced-scrub\n// Requires: npm i scrollvars · import 'scrollvars/styles/pin.css' (layout)\n// Each child animates over its own slice of the pin: pass ranges as\n// [from, to] pairs (0..1); overlapping ranges are fine: that's the point.\n'use client'\nimport * as React from 'react'\nimport { Track } from 'scrollvars/react'\n\n// toArray keys each level from \".0\", so a fragment's children collide with\n// their uncles without the parent's key in front (React.Children.toArray\n// does NOT flatten a fragment: it comes back as ONE element). Recurse into\n// one, keyed like the coverflow rail's own normalization. Anything that is\n// not an element (a portal renders elsewhere) passes through untouched: it\n// owns no slice of the pin.\nfunction scrubSlots(children: React.ReactNode, prefix = ''): React.ReactNode[] {\n  const list: React.ReactNode[] = []\n  React.Children.toArray(children).forEach((child) => {\n    if (!React.isValidElement(child)) { list.push(child); return }\n    const key = prefix ? `${prefix}:${child.key ?? ''}` : (child.key ?? '')\n    if (child.type === React.Fragment) {\n      list.push(...scrubSlots((child.props as { children?: React.ReactNode }).children, key))\n    } else {\n      list.push(React.cloneElement(child, { key }))\n    }\n  })\n  return list\n}\n\nexport function SequencedScrub({\n  children,\n  ranges,\n  height = '250vh',\n  className,\n}: {\n  children: React.ReactNode\n  /** One [from, to] per child; defaults to evenly staggered overlapping slices. */\n  ranges?: [number, number][]\n  height?: string\n  className?: string\n}) {\n  // a conditional step ({show && <Card/>}) is false, and Children.map still\n  // calls back for it, so the stack got an empty slice and every step after\n  // it scrubbed on the wrong window.\n  const items = scrubSlots(children)\n  const count = items.filter(React.isValidElement).length\n  let step = -1\n  return (\n    <Track pin={height} className={className}>\n      <div className=\"sv-stage\" style={{ display: 'grid', placeItems: 'center' }}>\n        <div className=\"sv-range sv-range-rise\" style={{ display: 'grid', gap: 12 }}>\n          {items.map((child) => {\n            if (!React.isValidElement(child)) return child\n            const i = ++step\n            const [from, to] = ranges?.[i] ?? [i / count, Math.min((i + 1.6) / count, 1)]\n            return (\n              <div key={child.key} style={{ '--sv-from': from, '--sv-to': to } as React.CSSProperties}>\n                {child}\n              </div>\n            )\n          })}\n        </div>\n      </div>\n    </Track>\n  )\n}\n"
    },
    {
      "slug": "wall-floor-fold",
      "category": "Pinned scenes",
      "title": "Wall to floor fold",
      "tagline": "Copy runs down a wall and, at the fold, keeps going onto the floor toward you. A clone in perspective, --sv-pin driving both.",
      "page": "https://scrollvars.dev/fx/wall-floor-fold.html",
      "requires": {
        "styles": [
          "pin"
        ],
        "min": "1.19.0",
        "tailwind": true
      },
      "file": "WallFloorFold.tsx",
      "content": "// ScrollVars fx · wall-floor-fold\n// Requires: npm i scrollvars · import 'scrollvars/styles/pin.css' (for .sv-stage)\n// The CSS below is one constant string, injected as raw HTML: a <style>\n// child is raw text, so React 18's server renderer escaping `>` to `&gt;`\n// never decodes and a child-combinator rule would be dropped either way\n// (there is none here). CSP is unchanged from any inline <style>: a\n// style-src nonce or hash, or lift the string into your own CSS.\n'use client'\nimport * as React from 'react'\nimport { Track } from 'scrollvars/react'\n\n// React 19 knows inert as a boolean attribute (a string would be dropped as falsy); React 18\n// does not know it and drops booleans, so it gets the empty string instead. Both render inert=\"\".\nconst INERT = (React.version.startsWith('18') ? { inert: '' } : { inert: true }) as unknown as Record<string, never>\n\nconst css = `\n.fold-scene { perspective: var(--sv-fold-depth, 650px); perspective-origin: 50% var(--sv-fold-eye, 11%); }\n.fold-wall { position: absolute; inset: 0 0 auto 0; height: var(--sv-fold-line, 70vh); overflow: hidden; }\n.fold-line { position: absolute; left: 0; right: 0; top: var(--sv-fold-line, 70vh); height: 1px; background: currentColor; opacity: .2; }\n.fold-floor { position: absolute; left: 0; width: 100%; top: var(--sv-fold-line, 70vh); height: 100%; overflow: hidden;\n  transform-origin: 50% 0; rotate: x var(--sv-fold-angle, 90deg); mask-image: linear-gradient(#000 55%, transparent); }\n.fold-copy { width: min(720px, 100% - 40px); position: relative; left: 50%;\n  translate: -50% calc(var(--sv-pin, 0) * -1 * var(--sv-fold-travel, 280vh)); }\n.fold-floor .fold-copy { translate: -50% 0; }\n/* the floor copy scrolls on its own wrapper, in the same units as the wall: no\n   zoom or scale here, engines disagree on how zoom treats vh (the floor drifted). */\n.fold-floor .fold-shift { translate: 0 calc(-1 * (var(--sv-pin, 0) * var(--sv-fold-travel, 280vh) + var(--sv-fold-line, 70vh))); }\nhtml:not(.sv-on) .fold-wall { position: static; height: auto; overflow: visible; }\nhtml:not(.sv-on) .fold-floor, html:not(.sv-on) .fold-line { display: none; }\nhtml:not(.sv-on) .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; }\n@media (prefers-reduced-motion: reduce) {\n  .fold-scene { perspective: none; }\n  .fold-wall { position: static; height: auto; overflow: visible; }\n  .fold-floor, .fold-line { display: none; }\n  .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; }\n}\n:where([data-sv-motion=\"reduce\"]) .fold-scene { perspective: none; }\n:where([data-sv-motion=\"reduce\"]) .fold-wall { position: static; height: auto; overflow: visible; }\n:where([data-sv-motion=\"reduce\"]) .fold-floor, :where([data-sv-motion=\"reduce\"]) .fold-line { display: none; }\n:where([data-sv-motion=\"reduce\"]) .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; }\n`\n\nexport function WallFloorFold({\n  children,\n  pin = '380vh',\n  depth = 650,\n  eye = '11%',\n  angle = '90deg',\n  fold = '70vh',\n  travel = '280vh',\n  className,\n  nonce,\n}: {\n  children: React.ReactNode\n  /** Total scroll length: match the copy's height plus one viewport. */\n  pin?: string\n  depth?: number | string\n  eye?: string\n  angle?: string\n  fold?: string\n  /** How far the copy travels, in vh: keep this at pin minus 100vh. */\n  travel?: string\n  className?: string\n  nonce?: string\n}) {\n  const vars = {\n    '--sv-fold-depth': typeof depth === 'number' ? `${depth}px` : depth,\n    '--sv-fold-eye': eye,\n    '--sv-fold-angle': angle,\n    '--sv-fold-line': fold,\n    '--sv-fold-travel': travel,\n  } as React.CSSProperties\n  return (\n    <Track pin={pin} className={className} style={vars}>\n      <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />\n      <div className=\"sv-stage fold-scene\">\n        <div className=\"fold-wall\">\n          <article className=\"fold-copy\">{children}</article>\n        </div>\n        <div className=\"fold-line\" aria-hidden=\"true\" />\n        {/* the same words again, laid flat toward the viewer: aria-hidden +\n            inert together keep this copy out of the accessibility tree and\n            the tab order, so the wall stays the only readable copy */}\n        <div className=\"fold-floor\" aria-hidden=\"true\" {...INERT}>\n          <div className=\"fold-shift\"><article className=\"fold-copy\">{children}</article></div>\n        </div>\n      </div>\n    </Track>\n  )\n}\n"
    },
    {
      "slug": "gsap-scrub",
      "category": "Interop",
      "title": "GSAP timeline under scrub",
      "tagline": "Author the choreography in GSAP, let ScrollVars drive it. One listener, one writer.",
      "page": "https://scrollvars.dev/fx/gsap-scrub.html",
      "requires": {
        "styles": [
          "pin"
        ],
        "deps": {
          "gsap": "^3"
        },
        "min": "1.18.0"
      },
      "file": "GsapScrub.tsx",
      "content": "// ScrollVars fx · gsap-scrub\n// Requires: npm i scrollvars gsap · import 'scrollvars/styles/pin.css'\n// Author choreography in GSAP, let ScrollVars drive it: one scroll\n// listener, one writer. Never ALSO create a ScrollTrigger for it.\n// Honesty: this page pays GSAP's bundle. Adopt per page, not globally.\n'use client'\nimport * as React from 'react'\nimport gsap from 'gsap'\nimport { prefersReducedMotion, onMotionChange } from 'scrollvars'\nimport { Track } from 'scrollvars/react'\n\nexport function GsapScrub({\n  children,\n  buildTimeline,\n  height = '250vh',\n  className,\n}: {\n  children: React.ReactNode\n  /** Build and return a PAUSED timeline over the given stage element. */\n  buildTimeline: (stage: HTMLDivElement) => gsap.core.Timeline\n  height?: string\n  className?: string\n}) {\n  const stage = React.useRef<HTMLDivElement>(null)\n  const tl = React.useRef<gsap.core.Timeline | null>(null)\n  const progress = React.useRef(0)\n  // A failed or released tracker never fires onPin again: without this the\n  // timeline stays wherever it last was, opacity 0 forever on a from()\n  // recipe. Fail visible, never fail hidden.\n  const tracking = React.useRef(true)\n  // Reads only refs, so identity does not matter to the effect below: the\n  // same behavior every render, no reason to memoize it.\n  const settle = () => {\n    tl.current?.progress(prefersReducedMotion() || !tracking.current ? 1 : progress.current)\n  }\n  React.useEffect(() => {\n    if (stage.current) {\n      tl.current = buildTimeline(stage.current)\n      settle()\n    }\n    const off = onMotionChange(settle)\n    return () => { off(); tl.current?.kill() }\n  }, [buildTimeline])\n  return (\n    <Track\n      pin={height}\n      onPin={(p) => { progress.current = p; settle() }}\n      onStatus={(status) => { tracking.current = status !== 'failed' && status !== 'released'; settle() }}\n      className={className}\n    >\n      <div ref={stage} className=\"sv-stage\" style={{ display: 'grid', placeItems: 'center' }}>\n        {children}\n      </div>\n    </Track>\n  )\n}\n"
    },
    {
      "slug": "three-scene",
      "category": "Interop",
      "title": "Three.js scene on the pin",
      "tagline": "A WebGL scene scrubbed by scroll. The canvas harness runs the lifecycle, Three renders.",
      "page": "https://scrollvars.dev/fx/three-scene.html",
      "requires": {
        "styles": [
          "pin"
        ],
        "deps": {
          "three": ">=0.147"
        },
        "min": "1.14.0"
      },
      "file": "ThreeScene.tsx",
      "content": "// ScrollVars fx · three-scene\n// Requires: npm i scrollvars three · import 'scrollvars/styles/pin.css'\n// The canvas harness owns the lifecycle (DPR, resize, pause offscreen,\n// reduced motion, cleanup); Three owns the rendering; scroll feeds progress.\n'use client'\nimport * as React from 'react'\nimport * as THREE from 'three'\nimport { Track, useCanvasEffect } from 'scrollvars/react'\n\nexport function ThreeScene({ height = '250vh', className }: { height?: string; className?: string }) {\n  const progress = React.useRef(0)\n  const three = React.useRef<{ renderer: THREE.WebGLRenderer; scene: THREE.Scene; camera: THREE.PerspectiveCamera; mesh: THREE.Mesh } | null>(null)\n\n  const canvasRef = useCanvasEffect({\n    context: null, // WebGL owns the canvas\n    setup(fx) {\n      const renderer = new THREE.WebGLRenderer({ canvas: fx.canvas, alpha: true, antialias: true })\n      const scene = new THREE.Scene()\n      const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100)\n      const mesh = new THREE.Mesh(\n        new THREE.TorusKnotGeometry(1.1, 0.34, 160, 24),\n        new THREE.MeshNormalMaterial({ wireframe: true })\n      )\n      scene.add(mesh)\n      three.current = { renderer, scene, camera, mesh }\n      return () => {\n        mesh.geometry.dispose()\n        ;(mesh.material as THREE.Material).dispose()\n        renderer.dispose()\n        three.current = null\n      }\n    },\n    resize(fx) {\n      const t = three.current\n      if (!t) return\n      t.renderer.setPixelRatio(fx.dpr)\n      t.renderer.setSize(fx.width, fx.height, false)\n      t.camera.aspect = fx.width / fx.height\n      t.camera.updateProjectionMatrix()\n    },\n    frame(fx, dt) {\n      const t = three.current\n      if (!t) return\n      // reduced motion: no idle spin, and the scroll-driven camera and tilt hold\n      // their finished pose instead of following the pin (round 10)\n      const p = fx.reducedMotion ? 1 : progress.current\n      t.mesh.rotation.y += fx.reducedMotion ? 0 : dt * 0.15\n      t.mesh.rotation.x = p * Math.PI\n      t.camera.position.z = 6 - p * 2.2\n      t.renderer.render(t.scene, t.camera)\n    },\n  })\n\n  return (\n    <Track pin={height} onPin={(p) => (progress.current = p)} className={className}>\n      <div className=\"sv-stage\" style={{ display: 'grid', placeItems: 'center' }}>\n        <canvas ref={canvasRef} style={{ width: 'min(90%, 560px)', height: '60vh' }} />\n      </div>\n    </Track>\n  )\n}\n"
    },
    {
      "slug": "split-reveal",
      "category": "Text",
      "title": "Split reveal",
      "tagline": "A headline broken into words, each rising on its own beat, SplitText without the engine.",
      "page": "https://scrollvars.dev/fx/split-reveal.html",
      "requires": {
        "styles": [
          "core"
        ],
        "min": "1.14.0"
      },
      "file": "SplitReveal.tsx",
      "content": "// ScrollVars fx · split-reveal\n// Requires: npm i scrollvars · import 'scrollvars/styles/core.css' (layout)\n// <Split> renders word/char spans on the server: no client splitting,\n// no CLS, no hydration flash. The full text stays in a visually-hidden span for AT.\n'use client'\nimport * as React from 'react'\nimport { Reveal, Split } from 'scrollvars/react'\n\nexport function SplitReveal({\n  children,\n  by = 'word',\n  stagger = 60,\n  className,\n}: {\n  children: string\n  by?: 'word' | 'char'\n  stagger?: number\n  className?: string\n}) {\n  return (\n    <Reveal once>\n      <Split as=\"h2\" by={by} stagger={stagger} className={className ? `sv-split-rise ${className}` : 'sv-split-rise'}>\n        {children}\n      </Split>\n    </Reveal>\n  )\n}\n"
    },
    {
      "slug": "rotating-words",
      "category": "Text",
      "title": "Rotating words",
      "tagline": "One word exits up, the next rises from below. A clipped column on one variable.",
      "page": "https://scrollvars.dev/fx/rotating-words.html",
      "requires": {
        "styles": [
          "state"
        ],
        "min": "1.17.0"
      },
      "file": "RotatingWords.tsx",
      "content": "// ScrollVars fx · rotating-words\n// Requires: npm i scrollvars · import 'scrollvars/styles/state.css' (layout)\n'use client'\nimport * as React from 'react'\nimport { onMotionChange, prefersReducedMotion } from 'scrollvars'\n\n// the rotating column reads as every word in a row to a screen reader, so it\n// is aria-hidden and a visually hidden span says the phrase once\nconst SR: React.CSSProperties = { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clipPath: 'inset(50%)', whiteSpace: 'nowrap' }\n\nexport function RotatingWords({\n  words,\n  interval = 2000,\n  className,\n}: {\n  words: string[]\n  interval?: number\n  className?: string\n}) {\n  const [index, setIndex] = React.useState(0)\n  // no rotation under reduced motion (the OS setting or the page's switch)\n  const [reduced, setReduced] = React.useState(false)\n  React.useEffect(() => {\n    setReduced(prefersReducedMotion())\n    return onMotionChange(setReduced)\n  }, [])\n  // WCAG 2.2.2: content that auto-rotates past 5s needs a keyboard pause,\n  // persistent across hover/focus (not just the OS reduced-motion setting).\n  const [paused, setPaused] = React.useState(false)\n  // A shrinking list strands the last index, same shape as useScenes: clamp\n  // here, on the render that sees the new length, instead of waiting for the\n  // next tick. An empty list schedules no interval at all: (i + 1) % 0 is\n  // NaN, and NaN never recovers ((NaN + 1) % n is NaN for any n), so a list\n  // that arrives late, after a tick already fired on an empty one, would\n  // stay NaN forever.\n  const last = Math.max(words.length - 1, 0)\n  if (index > last) setIndex(last)\n  const current = Math.min(index, last)\n  React.useEffect(() => {\n    if (words.length === 0 || reduced || paused) return\n    const t = setInterval(() => setIndex((i) => (i + 1) % words.length), interval)\n    return () => clearInterval(t)\n  }, [words.length, interval, reduced, paused])\n  return (\n    <>\n      <span className={className ? `sv-words ${className}` : 'sv-words'}\n        style={{ '--sv-word': current } as React.CSSProperties} aria-hidden=\"true\">\n        {words.map((w) => (\n          <span key={w}>{w}</span>\n        ))}\n      </span>\n      <span style={SR}>{words.join(', ')}</span>\n      <button type=\"button\" className=\"sv-words-pause\" aria-pressed={paused}\n        onClick={() => setPaused((p) => !p)}>\n        Pause rotation\n      </button>\n    </>\n  )\n}\n"
    },
    {
      "slug": "pointer-tilt",
      "category": "Pointer",
      "title": "Pointer tilt",
      "tagline": "Cards tilt toward the cursor with a moving glare. One delegated listener.",
      "page": "https://scrollvars.dev/fx/pointer-tilt.html",
      "requires": {
        "styles": [
          "tilt"
        ],
        "min": "1.9.0",
        "tailwind": true
      },
      "file": "PointerTiltGrid.tsx",
      "content": "// ScrollVars fx · pointer-tilt\n// Requires: npm i scrollvars · import 'scrollvars/styles/tilt.css' (layout)\n// Give each card the sv-tilt class; one delegated listener covers the grid.\n'use client'\nimport * as React from 'react'\nimport { usePointer } from 'scrollvars/react'\n\nexport function PointerTiltGrid({\n  children,\n  className = 'grid grid-cols-3 gap-6',\n}: {\n  children: React.ReactNode\n  className?: string\n}) {\n  const ref = usePointer<HTMLDivElement>()\n  return (\n    <div ref={ref} className={className}>\n      {children}\n    </div>\n  )\n}\n"
    },
    {
      "slug": "coverflow-slider",
      "category": "Sliders",
      "title": "Coverflow slider",
      "tagline": "Native-scroll carousel; slides scale, fade and turn by their distance from center.",
      "page": "https://scrollvars.dev/fx/coverflow-slider.html",
      "requires": {
        "styles": [
          "slider"
        ],
        "min": "1.12.3"
      },
      "file": "CoverflowSlider.tsx",
      "content": "// ScrollVars fx · coverflow-slider\n// Requires: npm i scrollvars · import 'scrollvars/styles/slider.css' (layout)\n// Chrome knobs: --sv-arrow-* / --sv-dot-* on this element or :root.\n// The CSS below is one constant string injected as raw HTML: React 18's server\n// renderer escapes a <style> child (`>` becomes `&gt;`) and <style> is raw text, so\n// the entity never decodes and every child-combinator rule is dropped. CSP is\n// unchanged from any inline <style>: a style-src nonce or hash, or lift the string\n// into your own stylesheet.\n'use client'\nimport * as React from 'react'\nimport { Slide, Slider } from 'scrollvars/react'\n\nconst css = `\n.cf-slide { scale: calc(1 - min(max(var(--sd, 0), -1 * var(--sd, 0)) * 0.12, 0.3));\n  opacity: calc(1 - min(max(var(--sd, 0), -1 * var(--sd, 0)) * 0.35, 0.7));\n  transform: perspective(900px) rotateY(clamp(-24deg, calc(var(--sd, 0) * -16deg), 24deg)); }\n@media (prefers-reduced-motion: reduce) { .cf-slide { scale: none; opacity: 1; transform: none; } }\n/* the same under html[data-sv-motion=\"reduce\"], the site's own switch */\n:where([data-sv-motion=\"reduce\"]) .cf-slide { scale: none; opacity: 1; transform: none; }\n`\n\n/* Fragments opened, keyed like the kit's own rail (src/react/index.tsx's\n   private slideList): toArray keys each level from \".0\", so a fragment's\n   children would collide with their uncles without the parent's key in\n   front. A child that is already a <Slide> (its own span, its own class)\n   gets cf-slide added to it instead of being wrapped a second time, or\n   --sv-span would sit on the inner div while the column rule sizes direct\n   children. Anything that is not an element (a portal renders elsewhere)\n   passes through untouched: it is not a card, and dropping it would delete\n   content the caller wrote. */\nfunction coverflowSlides(children: React.ReactNode, prefix = ''): React.ReactNode[] {\n  const list: React.ReactNode[] = []\n  React.Children.toArray(children).forEach((child) => {\n    if (!React.isValidElement<Record<string, unknown>>(child)) {\n      list.push(child)\n      return\n    }\n    const key = prefix ? `${prefix}:${child.key ?? ''}` : (child.key ?? '')\n    if (child.type === React.Fragment) {\n      list.push(...coverflowSlides((child.props as { children?: React.ReactNode }).children, key))\n    } else if (child.type === Slide) {\n      const props = child.props as { className?: string }\n      list.push(React.cloneElement(child, { key, className: ['cf-slide', props.className].filter(Boolean).join(' ') }))\n    } else {\n      list.push(\n        <Slide key={key} className=\"cf-slide\">\n          {child}\n        </Slide>\n      )\n    }\n  })\n  return list\n}\n\nexport function CoverflowSlider({\n  children,\n  perView = { base: 1.2, md: 2.5, xl: 4 },\n  ...rest\n}: React.ComponentProps<typeof Slider>) {\n  return (\n    <>\n      {/* the same nonce the Slider gets: under a strict CSP this sheet needs it too */}\n      <style nonce={rest.nonce} dangerouslySetInnerHTML={{ __html: css }} />\n      <Slider perView={perView} gap={16} arrows dots {...rest}>\n        {coverflowSlides(children)}\n      </Slider>\n    </>\n  )\n}\n"
    },
    {
      "slug": "marquee",
      "category": "Sliders",
      "title": "Marquee",
      "tagline": "An infinite strip (logos, taglines) that pauses on hover.",
      "page": "https://scrollvars.dev/fx/marquee.html",
      "requires": {
        "styles": [
          "ui"
        ],
        "min": "1.14.0"
      },
      "file": "LogoMarquee.tsx",
      "content": "// ScrollVars fx · marquee\n// Requires: npm i scrollvars · import 'scrollvars/styles/ui.css' (layout)\n'use client'\nimport * as React from 'react'\nimport { Marquee } from 'scrollvars/react'\n\nexport function LogoMarquee({\n  children,\n  speed = 24,\n  className,\n}: {\n  children: React.ReactNode\n  speed?: number\n  className?: string\n}) {\n  return (\n    <Marquee speed={speed} className={className}>\n      {children}\n    </Marquee>\n  )\n}\n"
    },
    {
      "slug": "hero-cinematic",
      "category": "Sections",
      "title": "Cinematic hero",
      "tagline": "Split headline rising on a beat, pointer-parallax glow, a marquee strip, and the whole block fades out as you scroll past (--sv-t).",
      "page": "https://scrollvars.dev/fx/hero-cinematic.html",
      "requires": {
        "styles": [
          "core",
          "ui"
        ],
        "min": "1.14.0"
      },
      "file": "HeroCinematic.tsx",
      "content": "// ScrollVars fx · hero-cinematic\n// Requires: npm i scrollvars · import 'scrollvars/styles/core.css' and 'scrollvars/styles/ui.css'\n// Split headline rising on a beat, pointer-parallax glow, marquee strip; the block\n// fades and scales out as it leaves (--sv-t). The CSS below is the whole section,\n// one constant string injected as raw HTML: React 18's server renderer escapes a\n// <style> child (`>` becomes `&gt;`) and <style> is raw text, so the entity never\n// decodes and every child-combinator rule is dropped. CSP is unchanged from any\n// inline <style>: a style-src nonce or hash, or lift the string into your own CSS.\n'use client'\nimport * as React from 'react'\nimport { Track, Split, Marquee, usePointer } from 'scrollvars/react'\n\nconst css = `\n.sv-hero { position: relative; min-height: 100svh; display: grid; place-items: center; overflow: hidden; isolation: isolate; text-align: center; }\n.sv-hero .hero-orb { position: absolute; width: 52vmin; height: 52vmin; border-radius: 50%; filter: blur(70px); opacity: .5; z-index: -1;\n  translate: calc(var(--mx, 0) * var(--hero-parallax, 40px)) calc(var(--my, 0) * var(--hero-parallax, 40px)); transition: translate .5s ease-out; }\n.sv-hero .hero-orb.a { background: var(--hero-glow, #f2c14e); top: -14%; left: -8%; }\n.sv-hero .hero-orb.b { background: var(--hero-glow-2, #ffb454); bottom: -16%; right: -10%; --hero-parallax: -60px; }\n.sv-hero .hero-inner { padding: 60px 24px 90px; --hero-out: clamp(0, (var(--sv-t, .5) - .5) * 2, 1); opacity: calc(1 - var(--hero-out)); scale: calc(1 - var(--hero-out) * .12); }\n@media (prefers-reduced-motion: reduce) { .sv-hero .hero-orb { translate: none; transition: none; } .sv-hero .hero-inner { opacity: 1; scale: none; } }\n/* the same under html[data-sv-motion=\"reduce\"], the site's own switch */\n:where([data-sv-motion=\"reduce\"]) .sv-hero .hero-orb { translate: none; transition: none; }\n:where([data-sv-motion=\"reduce\"]) .sv-hero .hero-inner { opacity: 1; scale: none; }\n.sv-hero .hero-eyebrow { font-size: 12px; letter-spacing: .18em; text-transform: uppercase; opacity: .8; }\n.sv-hero .hero-title { font-size: clamp(36px, 6.4vw, 78px); line-height: 1.02; letter-spacing: -.03em; max-width: 14ch; margin: 14px auto 18px; font-weight: 800; }\n.sv-hero .hero-sub { max-width: 42ch; margin: 0 auto 26px; font-size: 17px; opacity: .75; }\n.sv-hero .hero-strip { position: absolute; left: 0; right: 0; bottom: 0; background: rgba(20, 33, 26, .9); padding: 14px 0; border-top: 1px solid rgba(128,128,128,.25); font-size: 13px; letter-spacing: .12em; text-transform: uppercase; opacity: .7; }\n.sv-hero .hero-strip span { margin: 0 18px; }\n`\n\nexport function HeroCinematic({\n  eyebrow,\n  title,\n  copy,\n  cta,\n  strip = ['Brand', '·', 'Motion', '·', 'Web', '·', 'Type', '·'],\n  className,\n  nonce,\n}: {\n  eyebrow?: string\n  /** Plain text: it is split into words on the server, each rising on its own beat. */\n  title: string\n  copy?: React.ReactNode\n  cta?: React.ReactNode\n  strip?: string[]\n  className?: string\n  nonce?: string\n}) {\n  const ref = usePointer<HTMLElement>({ selector: '.sv-hero' }) // --mx/--my (-1..1) on the section itself\n  return (\n    <section ref={ref} className={className ? 'sv-hero ' + className : 'sv-hero'}>\n      <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />\n      <div className=\"hero-orb a\" />\n      <div className=\"hero-orb b\" />\n      <Track travel>\n        <div className=\"hero-inner\">\n          {eyebrow && <p className=\"hero-eyebrow sv-rise\">{eyebrow}</p>}\n          <Split as=\"h1\" className=\"hero-title sv-split-rise\">\n            {title}\n          </Split>\n          {copy && (\n            <p className=\"hero-sub sv-rise\" style={{ '--sv-order': 6 } as React.CSSProperties}>\n              {copy}\n            </p>\n          )}\n          {cta && (\n            <p className=\"sv-rise\" style={{ '--sv-order': 7 } as React.CSSProperties}>\n              {cta}\n            </p>\n          )}\n        </div>\n      </Track>\n      <Marquee className=\"hero-strip\">\n        {strip.map((s, i) => (\n          <span key={i}>{s}</span>\n        ))}\n      </Marquee>\n    </section>\n  )\n}\n"
    },
    {
      "slug": "timeline-scrub",
      "category": "Sections",
      "title": "Pinned timeline",
      "tagline": "Pin the section; the scroll draws the line, counts the year and lights each milestone over its own slice of the pin.",
      "page": "https://scrollvars.dev/fx/timeline-scrub.html",
      "requires": {
        "styles": [
          "pin"
        ],
        "min": "1.14.0"
      },
      "file": "TimelineScrub.tsx",
      "content": "// ScrollVars fx · timeline-scrub\n// Requires: npm i scrollvars · import 'scrollvars/styles/pin.css' (sv-stage, sv-range)\n// Pinned: the scroll draws the line, counts the year (a CSS counter) and lights each\n// milestone over its own slice of the pin. No JS beyond the driver.\n// The CSS below is one constant string injected as raw HTML: React 18's server\n// renderer escapes a <style> child (`>` becomes `&gt;`) and <style> is raw text, so\n// the entity never decodes and every child-combinator rule is dropped. CSP is\n// unchanged from any inline <style>: a style-src nonce or hash, or lift the string\n// into your own stylesheet.\n'use client'\nimport * as React from 'react'\nimport { Track } from 'scrollvars/react'\n\nconst css = `\n.sv-timeline .tl-sticky { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); align-items: center; gap: 40px; padding: 32px clamp(20px, 5vw, 64px); box-sizing: border-box; min-height: 100%; }\n.sv-timeline .tl-year { font-size: clamp(64px, 12vw, 150px); line-height: 1; font-weight: 700; letter-spacing: -.04em; font-variant-numeric: tabular-nums;\n  counter-reset: tl-year calc(var(--tl-from) + var(--sv-pin, 1) * var(--tl-span)); }\n.sv-timeline .tl-year .tl-count::after { content: counter(tl-year); }\n.sv-timeline .tl-cap { display: block; margin-top: 10px; font-size: 12px; letter-spacing: .16em; text-transform: uppercase; opacity: .7; }\n.sv-timeline .tl-track { position: relative; padding-left: 34px; }\n.sv-timeline .tl-line { position: absolute; left: 8px; top: 8px; bottom: 8px; width: 2px; background: rgba(128,128,128,.25); }\n.sv-timeline .tl-line::after { content: \"\"; position: absolute; inset: 0; background: currentColor; transform-origin: top; scale: 1 var(--sv-pin, 1); }\n.sv-timeline .tl-items { list-style: none; margin: 0; padding: 0; display: grid; gap: clamp(18px, 4vh, 40px); }\n.sv-timeline .tl-items > li { position: relative; --sv-distance: 1.6rem; }\n.sv-timeline .tl-items > li::before { content: \"\"; position: absolute; left: -32px; top: 6px; width: 12px; height: 12px; border-radius: 50%;\n  background: color-mix(in oklab, currentColor calc(var(--sv-r, 1) * 100%), rgba(128,128,128,.35)); }\n.sv-timeline .tl-items b { display: block; font-size: 12px; letter-spacing: .12em; margin-bottom: 4px; }\n.sv-timeline .tl-items p { margin: 0; max-width: 34ch; }\n[data-sv-flow].sv-timeline .tl-sticky { min-height: 0; --sv-pin: 1; }\n/* under reduced motion the stage is in flow and the driver keeps writing --sv-pin:\n   settle the year and the line at their finished value, like the shipped presets */\n@media (prefers-reduced-motion: reduce) { .sv-timeline .tl-sticky { --sv-pin: 1; } }\n/* the same under html[data-sv-motion=\"reduce\"], the site's own switch */\n:where([data-sv-motion=\"reduce\"]) .sv-timeline .tl-sticky { --sv-pin: 1; }\n@media (max-width: 640px) { .sv-timeline .tl-sticky { grid-template-columns: 1fr; align-content: center; gap: 22px; } }\n`\n\n// aria-label is prohibited on generic roles (p/span/div). Axe\n// `aria-prohibited-attr`: so the readable text is a visually-hidden child.\nconst SR_ONLY: React.CSSProperties = {\n  position: 'absolute',\n  width: 1,\n  height: 1,\n  overflow: 'hidden',\n  clipPath: 'inset(50%)',\n  whiteSpace: 'nowrap',\n}\n\nexport interface TimelineStep {\n  year: number\n  text: React.ReactNode\n  /** Slice of the pin (0..1) this milestone animates over; defaults to evenly staggered, overlapping. */\n  range?: [number, number]\n}\n\nexport function TimelineScrub({\n  steps,\n  caption = 'years of shipping',\n  height = '320vh',\n  className,\n  nonce,\n}: {\n  steps: TimelineStep[]\n  caption?: string\n  /** Scroll length of the pinned stretch. */\n  height?: string\n  className?: string\n  nonce?: string\n}) {\n  const from = steps[0]?.year ?? 0\n  const span = (steps[steps.length - 1]?.year ?? from) - from\n  const n = steps.length\n  return (\n    <Track\n      pin={height}\n      className={className ? 'sv-timeline ' + className : 'sv-timeline'}\n      style={{ '--tl-from': from, '--tl-span': span } as React.CSSProperties}\n    >\n      <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />\n      <div className=\"sv-stage\"><div data-sv-fit=\"\" className=\"tl-sticky\">\n        <div>\n          <span className=\"tl-year\">\n            <span style={SR_ONLY}>{from + span}</span>\n            <span className=\"tl-count\" aria-hidden=\"true\" />\n          </span>\n          <span className=\"tl-cap\">{caption}</span>\n        </div>\n        <div className=\"tl-track\">\n          <i className=\"tl-line\" />\n          <ol className=\"sv-range sv-range-rise tl-items\">\n            {steps.map((s, i) => {\n              const [a, b] = s.range ?? [i / n, Math.min((i + 1.4) / n, 1)]\n              return (\n                <li key={s.year} style={{ '--sv-from': a, '--sv-to': b } as React.CSSProperties}>\n                  <b>{s.year}</b>\n                  <p>{s.text}</p>\n                </li>\n              )\n            })}\n          </ol>\n        </div>\n      </div></div>\n    </Track>\n  )\n}\n"
    },
    {
      "slug": "sticky-steps",
      "category": "Sections",
      "title": "Sticky steps",
      "tagline": "Media stays put while the copy scrolls; each step swaps the shot. The product-page pattern, with --sv-scene doing the swapping.",
      "page": "https://scrollvars.dev/fx/sticky-steps.html",
      "requires": {
        "styles": [
          "pin"
        ],
        "min": "1.18.0"
      },
      "file": "StickySteps.tsx",
      "content": "// ScrollVars fx · sticky-steps\n// Requires: npm i scrollvars · import 'scrollvars/styles/pin.css' (sv-stage)\n// Media stays put while the copy scrolls; each step swaps the shot. --sv-scene does the\n// swapping, the crossfade is one max() per element. Without JS the shots stack in flow.\n// The CSS below is one constant string injected as raw HTML: React 18's server\n// renderer escapes a <style> child (`>` becomes `&gt;`) and <style> is raw text, so\n// the entity never decodes. Here that dropped the whole --st-d rule (its selector\n// list carries a child combinator) and every shot stayed at full opacity. CSP is\n// unchanged from any inline <style>: a style-src nonce or hash, or lift the string\n// into your own stylesheet.\n'use client'\nimport * as React from 'react'\nimport { onMotionChange, prefersReducedMotion, refresh as refreshFit } from 'scrollvars'\nimport { useScenes } from 'scrollvars/react'\n\nconst useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect\n\nconst css = `\n.sv-steps .st-grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); align-items: center; gap: clamp(24px, 5vw, 64px); padding: 32px clamp(20px, 5vw, 64px); box-sizing: border-box; min-height: 100%; }\n.sv-steps .st-media { position: relative; aspect-ratio: 4 / 3; border-radius: 18px; overflow: hidden; display: grid; }\n.sv-steps .st-shot { margin: 0; display: grid; place-items: center; }\n.sv-steps .st-shot > * { width: 100%; height: 100%; object-fit: cover; }\n.sv-steps .st-shot, .sv-steps .st-steps > li, .sv-steps .st-dots i {\n  --st-d: min(1, max(calc(var(--sv-scene, 0) - var(--i)), calc(var(--i) - var(--sv-scene, 0)))); }\n.sv-on .sv-steps:where(.st-ready, .st-measuring) .st-shot { position: absolute; inset: 0; }\n.sv-on .sv-steps:where(.st-ready) .st-shot { opacity: calc(1 - var(--st-d)); scale: calc(1.06 - var(--st-d) * .06); }\n.sv-steps:not(.st-ready):not(.st-measuring) .st-media { overflow: visible; }\n.sv-steps:not(.sv) .sv-stage, .sv-steps.st-static .sv-stage { position: static; height: auto; overflow: visible; }\n.sv-steps.st-static { height: auto !important; }\n.sv-steps.st-static .st-grid { min-height: 0; }\n.sv-steps.st-static .st-media { aspect-ratio: auto; gap: 8px; }\n@media (prefers-reduced-motion: reduce) { .sv-on .sv-steps .st-shot { position: static; opacity: 1; scale: none; } .sv-steps .st-media { gap: 8px; aspect-ratio: auto; } }\n/* the same under html[data-sv-motion=\"reduce\"], the site's own switch */\n.sv-on:where([data-sv-motion=\"reduce\"]) .sv-steps .st-shot { position: static; opacity: 1; scale: none; }\n:where([data-sv-motion=\"reduce\"]) .sv-steps .st-media { gap: 8px; aspect-ratio: auto; }\n.sv-steps .st-steps { list-style: none; margin: 0; padding: 0; display: grid; gap: clamp(20px, 5vh, 44px); }\n.sv-steps .st-steps > li { opacity: calc(.65 + .35 * (1 - var(--st-d))); translate: calc(var(--st-d) * -8px) 0; } /* the floor, with the labels at .8, keeps an inactive step's text at 4.5:1 (axe) */\nhtml:not(.sv-on) .sv-steps .st-steps > li { opacity: 1; translate: none; }\n.sv-steps .st-steps b { display: block; font-size: 12px; letter-spacing: .12em; text-transform: uppercase; opacity: .8; margin-bottom: 6px; }\n.sv-steps .st-steps h3 { margin: 0 0 6px; font-size: clamp(20px, 2.6vw, 28px); }\n.sv-steps .st-steps p { margin: 0; max-width: 36ch; opacity: .8; }\n.sv-steps .st-dots { position: absolute; left: 50%; bottom: 18px; translate: -50% 0; display: flex; gap: 8px; }\n.sv-steps .st-dots i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: calc(1 - var(--st-d) * .7); scale: calc(1.6 - var(--st-d) * .6); }\n.sv-steps.st-static .st-steps > li { opacity: 1; translate: none; }\n/* Placed after the rules above (same specificity, later wins): the stage is\n   unpinned under reduced motion, so --sv-scene keeps writing but every\n   non-active step would otherwise sit at 30% opacity forever and the copy\n   would slide with the raw scroll. Reset both the steps and the dots. */\n@media (prefers-reduced-motion: reduce) { .sv-steps .st-steps > li { opacity: 1; translate: none; } .sv-steps .st-dots i { opacity: 1; scale: none; } }\n/* the same under html[data-sv-motion=\"reduce\"], the site's own switch */\n:where([data-sv-motion=\"reduce\"]) .sv-steps .st-steps > li { opacity: 1; translate: none; }\n:where([data-sv-motion=\"reduce\"]) .sv-steps .st-dots i { opacity: 1; scale: none; }\n@supports not (translate: 0) {\n  .sv-on .sv-steps .st-shot { position: static; opacity: 1; scale: none; }\n  .sv-steps .st-media { aspect-ratio: auto; gap: 8px; }\n}\n.sv-on [data-sv-off].sv-steps .st-shot { position: static; opacity: 1; scale: none; }\n[data-sv-off].sv-steps .st-media { aspect-ratio: auto; gap: 8px; }\n[data-sv-flow].sv-steps .st-grid { min-height: 0; }\n.sv-on [data-sv-flow].sv-steps .st-shot { position: static; opacity: 1; scale: none; }\n[data-sv-flow].sv-steps .st-media { aspect-ratio: auto; gap: 8px; }\n[data-sv-flow].sv-steps .st-steps > li { opacity: 1; translate: none; }\n[data-sv-flow].sv-steps .st-dots { display: none; }\n.sv-steps.st-static .st-dots { display: none; }\n@media (max-width: 640px) { .sv-steps .st-grid { grid-template-columns: 1fr; align-content: center; gap: 18px; } }\n`\n\nexport interface StickyStep {\n  title: React.ReactNode\n  text?: React.ReactNode\n  /** The shot for this step: an <img>, <video>, or any node. */\n  media: React.ReactNode\n  label?: string\n}\n\n// React 19 knows inert as a boolean attribute (a string would be dropped as falsy); React 18\n// does not know it and drops booleans, so it gets the empty string instead. Both render inert=\"\".\nconst INERT = (React.version.startsWith('18') ? { inert: '' } : { inert: true }) as unknown as Record<string, never>\n\n// Without native inert (Firefox 78-111, Safari 14.1-15.4, inside the README floor)\n// the attribute is inert in name only: an inactive shot's links stay tabbable while\n// the shot itself is visually hidden. Crossfading never turns on there; every shot\n// stays static and reachable instead.\nconst inertSupported = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype\n\nexport function StickySteps({ steps, className, nonce }: { steps: StickyStep[]; className?: string; nonce?: string }) {\n  // the active index (integer changes only) makes the inactive shots inert, so a\n  // crossfaded shot cannot keep focusable links; applied after mount so the\n  // server markup stays fully usable without JS\n  const status = React.useRef<import('scrollvars').AttachmentStatus>('attaching')\n  const flow = React.useRef(true)\n  const sync = React.useRef<(() => void) | undefined>(undefined)\n  const { ref, scene } = useScenes<HTMLDivElement>(steps.length, {\n    // an empty list (a CMS editor removed every step) pins nothing: the stage\n    // stays in flow at its natural height instead of a 0vh wrapper\n    pin: steps.length ? steps.length * 100 + 'vh' : undefined,\n    onStatus: value => { status.current = value; sync.current?.() },\n    onFlow: value => { flow.current = value; sync.current?.() },\n  })\n  const currentScene = React.useRef(scene)\n  currentScene.current = scene\n  // after mount only (server markup stays fully usable), and never under reduced\n  // motion, where the shots stack in flow and must all stay reachable. Live:\n  // a switch mid-session drops or restores inert/aria-hidden immediately.\n  const [interactive, setInteractive] = React.useState(false)\n  useLayoutEffect(() => {\n    const el = ref.current\n    if (!el) return\n    // Read the actual layout: failed boot, the watchdog and missing CSS all\n    // leave static shots reachable. Only the crossfade hides inactive shots.\n    const update = () => {\n      const shot = el.querySelector('.st-shot')\n      const stage = el.querySelector('.sv-stage')\n      // Stack before the driver's first fit read, without hiding shots from AT.\n      const empty = !shot\n      const measuring = !empty && status.current === 'attaching' && !el.hasAttribute('data-sv-flow') && !prefersReducedMotion()\n      let ready = !empty && status.current === 'active' && !flow.current && !prefersReducedMotion() && inertSupported\n      if (ready || measuring) el.classList.remove('st-static')\n      el.classList.toggle('st-measuring', measuring)\n      el.classList.toggle('st-ready', ready)\n      ready = ready && !!stage && getComputedStyle(stage).position === 'sticky' &&\n        !!shot && getComputedStyle(shot).position === 'absolute'\n      const fit = el.querySelector<HTMLElement>('[data-sv-fit]')\n      // Keep the candidate's stage constrained until the driver reads overflow.\n      // Expanding it here would swallow the fit latch on content replacement.\n      const pendingFit = ready && !!fit && Math.max(fit.offsetHeight, fit.scrollHeight) > stage!.clientHeight + 1\n      ready = ready && !!fit && Math.max(fit.offsetHeight, fit.scrollHeight) <= stage!.clientHeight + 1\n      el.classList.toggle('st-measuring', measuring || pendingFit)\n      el.classList.toggle('st-ready', ready)\n      el.classList.toggle('st-static', !ready && !pendingFit && (empty || status.current !== 'attaching'))\n      el.querySelectorAll('.st-shot').forEach((shot, i) => {\n        // Set both directions even when React batches reduce then auto into\n        // one render whose interactive state has not changed.\n        if (ready && i !== currentScene.current) {\n          shot.setAttribute('inert', '')\n          shot.setAttribute('aria-hidden', 'true')\n        } else {\n          shot.removeAttribute('inert')\n          shot.removeAttribute('aria-hidden')\n        }\n      })\n      setInteractive(ready)\n      // Candidate probing writes classes too. Do not observe our own writes\n      // again, especially when missing CSS rejects that candidate.\n      observer?.takeRecords()\n      if (pendingFit) refreshFit()\n    }\n    let observer: MutationObserver | undefined\n    let stopMotion: (() => void) | undefined\n    let stopped = false\n    const stop = () => {\n      if (stopped) return\n      stopped = true\n      sync.current = undefined\n      const attempt = (fn: () => void) => { try { fn() } catch { /* continue settling this controller */ } }\n      attempt(() => observer?.disconnect())\n      attempt(() => stopMotion?.())\n      attempt(() => el.classList.remove('st-measuring'))\n      attempt(() => el.classList.remove('st-ready'))\n      attempt(() => el.classList.add('st-static'))\n      el.querySelectorAll('.st-shot').forEach(shot => {\n        attempt(() => shot.removeAttribute('inert'))\n        attempt(() => shot.removeAttribute('aria-hidden'))\n      })\n      setInteractive(false)\n    }\n    const fail = (error: unknown) => {\n      if (stopped) return\n      stop()\n      try { console.error('[scrollvars] StickySteps enhancement failed', error) } catch { /* reporting is best effort */ }\n    }\n    const refresh = () => {\n      if (stopped) return\n      try { update() } catch (error) { fail(error) }\n    }\n    sync.current = refresh\n    try {\n      observer = new MutationObserver(refresh)\n      observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })\n      observer.observe(el, { attributes: true, attributeFilter: ['class', 'data-sv-flow'] })\n      stopMotion = onMotionChange(refresh)\n      if (stopped) { stopMotion(); return stop }\n      refresh()\n      // The first fit must run next frame, including for an offscreen section.\n      if (!stopped && status.current === 'attaching') refreshFit()\n    } catch (error) { fail(error) }\n    return stop\n  }, [ref])\n  useLayoutEffect(() => { sync.current?.() }, [scene, steps])\n  return (\n    <div ref={ref} className={className ? 'sv-steps ' + className : 'sv-steps'}>\n      <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />\n      <div className=\"sv-stage\"><div data-sv-fit=\"\" className=\"st-grid\">\n        <div className=\"st-media\">\n          {steps.map((s, i) => (\n            <figure\n              key={i}\n              className=\"st-shot\"\n              style={{ '--i': i } as React.CSSProperties}\n              {...(interactive && i !== scene ? INERT : {})}\n              aria-hidden={interactive && i !== scene ? true : undefined}\n            >\n              {s.media}\n            </figure>\n          ))}\n        </div>\n        <ol className=\"st-steps\">\n          {steps.map((s, i) => (\n            <li key={i} style={{ '--i': i } as React.CSSProperties}>\n              <b>{s.label ?? 'Step ' + (i + 1)}</b>\n              <h3>{s.title}</h3>\n              {s.text && <p>{s.text}</p>}\n            </li>\n          ))}\n        </ol>\n        <div className=\"st-dots\" aria-hidden=\"true\">\n          {steps.map((_, i) => (\n            <i key={i} style={{ '--i': i } as React.CSSProperties} />\n          ))}\n        </div>\n      </div></div>\n    </div>\n  )\n}\n"
    },
    {
      "slug": "stats-countup",
      "category": "Sections",
      "title": "Stats count-up",
      "tagline": "Numbers count from zero when the block enters. CSS counters + a registered property. The transition IS the animation.",
      "page": "https://scrollvars.dev/fx/stats-countup.html",
      "requires": {
        "styles": [
          "core",
          "state"
        ],
        "min": "1.14.0"
      },
      "file": "StatsCountup.tsx",
      "content": "// ScrollVars fx · stats-countup\n// Requires: npm i scrollvars · import 'scrollvars/styles/core.css' and 'scrollvars/styles/state.css'\n// state.css owns sv-acts; core.css declares --sv-live, and the acts clock is\n// calc(var(--sv-live) * var(--sv-acts-count)): without core.css every number\n// renders 0 the moment the driver boots.\n// Numbers count from zero when the block enters: a CSS counter driven by the\n// registered --sv-act transition. No JS, no innerText. Without JS or under\n// reduced motion the final numbers render immediately.\n// The CSS below is one constant string, injected as raw HTML: a <style> child\n// is escaped by React 18's server renderer (`>` becomes `&gt;`, dropping every\n// child-combinator rule), and <style> is raw text, so the entity never decodes.\n// CSP is unchanged from any inline <style>: allow it with a style-src nonce or\n// hash, or lift this string into your own stylesheet.\n'use client'\nimport * as React from 'react'\nimport { Track } from 'scrollvars/react'\n\nconst css = `\n.sv-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 18px; margin: 0; }\n.sv-stats > div { display: flex; flex-direction: column; padding: 22px 12px; border-radius: 14px; border: 1px solid rgba(128,128,128,.25); text-align: center; }\n.sv-stats dt { order: 2; font-size: 13px; margin-top: 8px; opacity: .7; }\n.sv-stats dd { margin: 0; font-size: clamp(34px, 6vw, 64px); line-height: 1; font-weight: 800; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }\n.sv-stats .stat { counter-reset: n calc(var(--sv-act, 1) * var(--sv-max)); }\n.sv-stats .stat .count::after { content: counter(n) attr(data-suffix); }\nhtml:not(.sv-on) .sv-stats .stat { counter-reset: n var(--sv-max); }\n@media (prefers-reduced-motion: reduce) { .sv-stats .stat { counter-reset: n var(--sv-max); } }\n:where([data-sv-motion=\"reduce\"]) .sv-stats .stat { counter-reset: n var(--sv-max); }\n`\n\n// aria-label is prohibited on generic roles (p/span/div). Axe\n// `aria-prohibited-attr`: so the readable text is a visually-hidden child.\nconst SR_ONLY: React.CSSProperties = {\n  position: 'absolute',\n  width: 1,\n  height: 1,\n  overflow: 'hidden',\n  clipPath: 'inset(50%)',\n  whiteSpace: 'nowrap',\n}\n\nexport interface Stat {\n  label: React.ReactNode\n  value: number\n  suffix?: string\n}\n\nexport function StatsCountup({\n  stats,\n  duration = 1.8,\n  className,\n  nonce,\n}: {\n  stats: Stat[]\n  /** Seconds the count takes. */\n  duration?: number\n  className?: string\n  nonce?: string\n}) {\n  return (\n    <Track\n      once\n      className={className ? 'sv-acts ' + className : 'sv-acts'}\n      style={{ '--sv-acts-count': 1, '--sv-acts-duration': duration + 's' } as React.CSSProperties}\n    >\n      <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />\n      <dl className=\"sv-stats\">\n        {stats.map((s, i) => (\n          <div key={i}>\n            <dt>{s.label}</dt>\n            {/* both halves sit INSIDE the dd: a dl group takes dt/dd only (a loose\n                span there is not in the content model), and a term whose only dd\n                is aria-hidden reaches assistive tech with no definition at all */}\n            <dd className=\"stat\" style={{ '--sv-max': s.value } as React.CSSProperties}>\n              <span style={SR_ONLY}>{s.value}{s.suffix ?? ''}</span>\n              <span className=\"count\" data-suffix={s.suffix ?? ''} aria-hidden=\"true\" />\n            </dd>\n          </div>\n        ))}\n      </dl>\n    </Track>\n  )\n}\n"
    },
    {
      "slug": "case-study-rail",
      "category": "Sections",
      "title": "Case-study rail",
      "tagline": "Oversized project cards traverse a pinned stage with --sv-pin; long content returns to a readable list.",
      "page": "https://scrollvars.dev/fx/case-study-rail.html",
      "requires": {
        "styles": [
          "pin"
        ],
        "min": "1.14.0"
      },
      "file": "CaseStudyRail.tsx",
      "content": "// ScrollVars fx · case-study-rail\n// Requires: npm i scrollvars · import 'scrollvars/styles/pin.css'\n'use client'\nimport * as React from 'react'\nimport { Track } from 'scrollvars/react'\n\nconst css = `\n.sv-casework { --work-accent: #c5f36b; color: #f3f0e8; background: #171a16; }\n.sv-casework .work-head { padding: 48px clamp(24px, 5vw, 72px) 24px; }\n.sv-casework h2 { font-size: clamp(36px, 7vw, 88px); letter-spacing: -.055em; line-height: 1; margin: 12px 0; }\n.sv-casework .work-stage { display: flex; align-items: center; }\n.sv-casework .work-fit { width: 100%; }\n.sv-casework .work-rail { display: flex; gap: 24px; width: max-content; padding: 24px; box-sizing: border-box; }\n.sv-casework .work-card { flex: none; width: min(70vw, 800px); padding: clamp(20px, 4vw, 48px); box-sizing: border-box; border: 1px solid #ffffff30; border-radius: 12px; }\n.sv-casework .work-art { height: clamp(100px, 30vh, 280px); border-radius: 8px; overflow: hidden; background: radial-gradient(circle at 65% 40%, var(--work-accent) 0 18%, transparent 18.5%), repeating-linear-gradient(135deg, #303d28 0 40px, #263020 40px 80px); }\n.sv-casework .work-art img { display: block; width: 100%; height: 100%; object-fit: cover; }\n.sv-casework .work-meta { display: flex; justify-content: space-between; margin: 22px 0 12px; color: var(--work-accent); font: 12px monospace; text-transform: uppercase; }\n.sv-casework h3 { font-size: clamp(28px, 5vw, 54px); line-height: 1.05; margin: 0 0 12px; letter-spacing: -.04em; }\n.sv-casework .work-card:nth-child(2n) { --work-accent: #9ee4ee; }\n.sv-casework .work-card:nth-child(3n) { --work-accent: #ff9d73; }\n.sv-casework .work-card p { max-width: 44ch; margin: 0; line-height: 1.5; }\n.sv-casework .work-footer { padding: 28px; text-align: right; }\n.sv-casework .work-footer a { color: inherit; }\n/* This rail starts at its first card. cqw measures the real stage, not the page. */\n@supports (width: 1cqw) {\n  .sv-casework .work-stage { container-type: inline-size; }\n  .sv-casework .work-card { width: min(80cqw, 800px); }\n  .sv-on .sv-casework .work-rail { transform: translateX(calc(var(--sv-pin, 0) * min(100cqw - 100%, 0px))); will-change: transform; }\n}\nhtml:not(.sv-on) .sv-casework .work-rail,\n.sv-casework [data-sv-flow] .work-rail,\n.sv-casework [data-sv-off] .work-rail { width: auto; flex-wrap: wrap; transform: none; will-change: auto; }\nhtml:not(.sv-on) .sv-casework .work-card,\n.sv-casework [data-sv-flow] .work-card,\n.sv-casework [data-sv-off] .work-card { width: 100%; }\n/* below the cqw floor: wrap like no-JS instead of a keyboard-unreachable\n   horizontal scroller (WCAG 2.1.1, no tabindex on the scroller). */\n@supports not (width: 1cqw) {\n  .sv-casework > .sv { height: auto !important; }\n  .sv-casework .work-stage { height: auto; position: static; }\n  .sv-casework .work-rail { width: auto; flex-wrap: wrap; transform: none; will-change: auto; }\n  .sv-casework .work-card { width: 100%; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .sv-on .sv-casework .work-rail { width: auto; flex-wrap: wrap; transform: none; will-change: auto; }\n  .sv-casework .work-card { width: 100%; }\n}\n/* the same under html[data-sv-motion=\"reduce\"], the site's own switch */\n.sv-on:where([data-sv-motion=\"reduce\"]) .sv-casework .work-rail { width: auto; flex-wrap: wrap; transform: none; will-change: auto; }\n:where([data-sv-motion=\"reduce\"]) .sv-casework .work-card { width: 100%; }\n`\n\nexport interface CaseStudy {\n  title: string\n  summary: string\n  category: string\n  image?: { src: string; alt: string }\n}\n\nexport function CaseStudyRail({ title = 'Selected work', projects, href, className, nonce }: {\n  title?: string\n  projects: CaseStudy[]\n  /** One link after the rail; the moving cards are deliberately non-interactive. */\n  href?: string\n  className?: string\n  nonce?: string\n}) {\n  return (\n    <section className={className ? 'sv-casework ' + className : 'sv-casework'}>\n      <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />\n      <header className=\"work-head\"><small>Independent thinking · tangible results</small><h2>{title}</h2></header>\n      <Track pin={Math.max(projects.length, 1) * 100 + 'vh'}>\n        <div className=\"sv-stage work-stage\"><div className=\"work-fit\" data-sv-fit=\"\">\n          <div className=\"work-rail\">\n            {projects.map((project, i) => <article className=\"work-card\" key={i}>\n              <div className=\"work-art\">{project.image && <img src={project.image.src} alt={project.image.alt} />}</div>\n              <div className=\"work-meta\"><span>{String(i + 1).padStart(2, '0')}</span><span>{project.category}</span></div>\n              <h3>{project.title}</h3><p>{project.summary}</p>\n            </article>)}\n          </div>\n        </div></div>\n      </Track>\n      {href && <footer className=\"work-footer\"><a href={href}>Explore all projects →</a></footer>}\n    </section>\n  )\n}\n"
    },
    {
      "slug": "editorial-manifesto",
      "category": "Sections",
      "title": "Editorial manifesto",
      "tagline": "Big statements gain contrast in sequence as --sv-t advances. Natural document flow, no pin and no split spans.",
      "page": "https://scrollvars.dev/fx/editorial-manifesto.html",
      "requires": {
        "styles": [],
        "min": "1.14.0"
      },
      "file": "EditorialManifesto.tsx",
      "content": "// ScrollVars fx · editorial-manifesto\n// Requires: npm i scrollvars · no preset stylesheet required\n'use client'\nimport * as React from 'react'\nimport { Track } from 'scrollvars/react'\n\nconst css = `\n.sv-manifesto { background: #e9e4db; color: #232722; padding: clamp(40px, 9vw, 128px) clamp(24px, 6vw, 88px); }\n.sv-manifesto .manifesto-label { display: flex; justify-content: space-between; padding-bottom: 22px; border-bottom: 1px solid currentColor; font: 12px monospace; text-transform: uppercase; }\n.sv-manifesto .manifesto-copy { padding-block: 32px; }\n.sv-manifesto .manifesto-copy p { font-size: clamp(30px, 5.8vw, 82px); font-weight: 600; line-height: 1.08; letter-spacing: -.045em; margin: 0 0 .55em; }\n.sv-on .sv-manifesto .manifesto-copy p { --read: clamp(0, calc(var(--sv-t, 1) * (var(--count) + 1) - var(--i)), 1); opacity: calc(.55 + .45 * var(--read)); } /* the floor keeps unread copy at 3:1 on this paper (axe, large text) */\n.sv-manifesto .manifesto-end { max-width: 36ch; margin-left: auto; font-size: 16px; line-height: 1.6; }\n[data-sv-off].sv-manifesto .manifesto-copy p { opacity: 1; }\n@media (prefers-reduced-motion: reduce) { .sv-on .sv-manifesto .manifesto-copy p { opacity: 1; } }\n/* the same under html[data-sv-motion=\"reduce\"], the site's own switch */\n.sv-on:where([data-sv-motion=\"reduce\"]) .sv-manifesto .manifesto-copy p { opacity: 1; }\n`\n\nexport function EditorialManifesto({ label = 'What we believe', paragraphs, closing, className, nonce }: {\n  label?: string\n  paragraphs: string[]\n  closing?: string\n  className?: string\n  nonce?: string\n}) {\n  return <Track as=\"section\" travel className={className ? 'sv-manifesto ' + className : 'sv-manifesto'} style={{ '--count': paragraphs.length } as React.CSSProperties}>\n    <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />\n    <header className=\"manifesto-label\"><span>{label}</span><span>Scroll to read ↓</span></header>\n    <div className=\"manifesto-copy\">{paragraphs.map((text, i) => <p key={i} style={{ '--i': i } as React.CSSProperties}>{text}</p>)}</div>\n    {closing && <p className=\"manifesto-end\">{closing}</p>}\n  </Track>\n}\n"
    },
    {
      "slug": "cube-windows",
      "category": "Pointer",
      "title": "Cube windows",
      "tagline": "A box turns in 3D as the section scrolls by and tilts to the pointer; its outline becomes the clip-path of the photo inside. The photo itself never moves.",
      "page": "https://scrollvars.dev/fx/cube-windows.html",
      "requires": {
        "styles": [],
        "min": "1.17.0"
      },
      "file": "CubeWindows.tsx",
      "content": "// ScrollVars fx · cube-windows\n// Requires: npm i scrollvars · no preset stylesheet required\n// A box turns in 3D as the section scrolls by and tilts to the pointer; its\n// outline becomes the clip-path of whatever this renders inside it. The\n// content itself never moves, it is only seen through the window.\n//\n// The projection math below (mask3d-core) is a self-contained, dependency-free\n// block: copy it once per project even if you use it in more than one place,\n// or lift it into its own module. It is unit-tested on its own upstream\n// (test/mask3d-core.test.mjs in the scrollvars repo) before it lands here.\n'use client'\nimport * as React from 'react'\nimport { onMotionChange, prefersReducedMotion, track } from 'scrollvars'\n\n/**\n * cube-windows' projection math: a box turns in 3D, its 8 corners project to\n * 2D, and their convex hull is the outline. Correct for any CONVEX solid\n * with no holes; a concave shape or one with a hole (glyph letters, a star)\n * needs the projected faces themselves unioned instead, which costs more.\n *\n * Measured against that faces-union approach on this exact box recipe\n * (demo/bench/harness/mask3d-cost.mjs, three runs, 300 boxes, 240 frames):\n * the hull ran consistently cheaper, about a quarter less per-frame task\n * time, and produces the identical silhouette for a plain box (checked by\n * nonzero-winding point-in-path sampling, not eyeballed). The simpler,\n * cheaper solid wins for the one recipe that exists; nothing here is kept\n * for a shape that has not shipped.\n *\n * This module is the source of truth: it is unit-tested directly (compiled\n * in test/mask3d-core.test.mjs with the repo's own esbuild, the same way the\n * fx gallery's plain-JS pane is derived from it, scripts/fx-data.mjs) so a\n * change here proves itself before it goes back into a live page.\n */\n\ntype Point = [number, number]\ntype Box = { w: number; h: number; d: number }\ntype Pose = { ry: number; rx: number }\n\n// The 8 corners of the box, turned by `pose` and projected with perspective\n// `focal`. Element-width units in, element-width units out (times the\n// perspective scale each corner earns from its own depth).\nfunction corners(box: Box, pose: Pose, focal: number): Point[] {\n  const { w, h, d } = box\n  const { ry, rx } = pose\n  const cosY = Math.cos(ry), sinY = Math.sin(ry)\n  const cosX = Math.cos(rx), sinX = Math.sin(rx)\n  const pts: Point[] = []\n  for (let i = 0; i < 8; i++) {\n    let x = (i & 1 ? .5 : -.5) * w, y = (i & 2 ? .5 : -.5) * h, z = (i & 4 ? .5 : -.5) * d\n    ;[x, z] = [x * cosY + z * sinY, -x * sinY + z * cosY]\n    ;[y, z] = [y * cosX - z * sinX, y * sinX + z * cosX]\n    const s = focal / (focal + z)\n    pts.push([x * s, y * s])\n  }\n  return pts\n}\n\n// The outline of a convex solid is the convex hull of its corners (monotone\n// chain, Andrew's algorithm): 4 points face-on, up to 6 from a corner-on\n// turn. Any orientation works, no area or winding bookkeeping needed.\nfunction hull(points: Point[]): Point[] {\n  const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1])\n  const cross = (o: Point, a: Point, b: Point) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])\n  const half = (list: Point[]): Point[] => {\n    const out: Point[] = []\n    for (const q of list) {\n      while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop()\n      out.push(q)\n    }\n    return out.slice(0, -1)\n  }\n  return [...half(sorted), ...half([...sorted].reverse())]\n}\n\nconst r1 = (n: number): number => Math.round(n * 10) / 10\n\nfunction toClipPath(points: Point[], ox: number, oy: number, scale: number): string {\n  return `polygon(${points.map(([x, y]) => `${r1(ox + x * scale)}px ${r1(oy + y * scale)}px`).join(',')})`\n}\n\n// The extent every corner can reach across `poses`, so a recipe can fit the\n// box inside its element without cutting a corner off at any reachable turn.\nfunction bounds(box: Box, poses: Pose[], focal: number) {\n  let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity\n  for (const pose of poses) {\n    for (const [x, y] of corners(box, pose, focal)) {\n      x0 = Math.min(x0, x); x1 = Math.max(x1, x)\n      y0 = Math.min(y0, y); y1 = Math.max(y1, y)\n    }\n  }\n  return { x0, x1, y0, y1 }\n}\n\n\nconst css = `\n.cube-window { position: relative; aspect-ratio: 4 / 3; overflow: hidden; border-radius: 12px; }\n.cube-window img, .cube-window video { display: block; width: 100%; height: 100%; object-fit: cover; }\n/* no JS / old engines: no clip-path is ever set, so the content stays fully visible */\n`\n\n// Same recipe as the CSS tab's mountCubeWindow, in React idiom: an effect\n// owns the whole lifecycle (fit, pointer inertia, scroll travel, reduced\n// motion, cleanup), the component only renders the box and its content.\nfunction useCubeWindow(ref: React.RefObject<HTMLDivElement | null>, boxSize: [number, number, number], turn: [number, number, number, number]) {\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    // Below Safari 13.1 without compat(): fail visible, not fail loud. An\n    // unguarded ResizeObserver here throws and unmounts the React root,\n    // the opposite of the content-stays-whole contract this recipe\n    // documents everywhere else.\n    if (typeof ResizeObserver !== 'function') return\n    const FOCAL = 2.4, HOVER = { y: .5, x: .35 }, MARGIN = 6, EASE = .12\n    const box3: Box = { w: boxSize[0], h: boxSize[1], d: boxSize[2] }\n    const [ry0, ry1, rx0, rx1] = turn\n    const poseAt = (t: number, hx: number, hy: number): Pose => ({\n      ry: ry0 + (ry1 - ry0) * t + hx * HOVER.y,\n      rx: rx0 + (rx1 - rx0) * t - hy * HOVER.x,\n    })\n\n    let t = .5, fit: { w: number; h: number; k: number; ox: number; oy: number } | null = null, last = '', raf = 0\n    const hover = { x: 0, y: 0 }, target = { x: 0, y: 0 }\n\n    const refit = () => {\n      const w = el.clientWidth, h = el.clientHeight\n      if (!w || !h || (fit && fit.w === w && fit.h === h)) return false\n      const poses: Pose[] = []\n      for (let s = 0; s <= 20; s++)\n        for (const hx of [-1, 0, 1]) for (const hy of [-1, 0, 1]) poses.push(poseAt(s / 20, hx, hy))\n      const b = bounds(box3, poses, FOCAL)\n      const k = Math.min((w - MARGIN * 2) / (b.x1 - b.x0), (h - MARGIN * 2) / (b.y1 - b.y0))\n      fit = { w, h, k, ox: w / 2 - ((b.x0 + b.x1) / 2) * k, oy: h / 2 - ((b.y0 + b.y1) / 2) * k }\n      return true\n    }\n\n    const draw = () => {\n      if (!fit) return\n      const still = prefersReducedMotion()\n      const pose = poseAt(still ? .5 : t, still ? 0 : hover.x, still ? 0 : hover.y)\n      const clip = toClipPath(hull(corners(box3, pose, FOCAL)), fit.ox, fit.oy, fit.k)\n      if (clip !== last) { el.style.clipPath = clip; last = clip }\n    }\n\n    const settle = () => {\n      raf = 0\n      hover.x += (target.x - hover.x) * EASE\n      hover.y += (target.y - hover.y) * EASE\n      draw()\n      if (Math.abs(target.x - hover.x) + Math.abs(target.y - hover.y) > .002) raf = requestAnimationFrame(settle)\n    }\n    const onMove = (e: PointerEvent) => {\n      if (prefersReducedMotion()) return\n      const r = el.getBoundingClientRect()\n      target.x = ((e.clientX - r.left) / r.width) * 2 - 1\n      target.y = ((e.clientY - r.top) / r.height) * 2 - 1\n      if (!raf) raf = requestAnimationFrame(settle)\n    }\n    const onLeave = () => { target.x = target.y = 0; if (!raf) raf = requestAnimationFrame(settle) }\n\n    const ro = new ResizeObserver(() => { if (refit()) { last = ''; draw() } })\n    ro.observe(el)\n    refit()\n    draw()\n    el.addEventListener('pointermove', onMove)\n    el.addEventListener('pointerleave', onLeave)\n    const stopMotion = onMotionChange(() => { hover.x = hover.y = target.x = target.y = 0; draw() })\n    const stopTrack = track(el, { view: false, onTravel: (v) => { t = v; draw() } })\n\n    return () => {\n      stopTrack(); stopMotion(); ro.disconnect()\n      el.removeEventListener('pointermove', onMove)\n      el.removeEventListener('pointerleave', onLeave)\n      if (raf) cancelAnimationFrame(raf)\n      el.style.clipPath = ''\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [boxSize[0], boxSize[1], boxSize[2], turn[0], turn[1], turn[2], turn[3]])\n}\n\nexport function CubeWindows({\n  box: boxSize = [.9, .5, .5],\n  turn = [-1.4, .35, .8, -.1],\n  className,\n  children,\n  nonce,\n}: {\n  /** Width, height, depth of the box, as a share of the element's width. */\n  box?: [number, number, number]\n  /** ry from, ry to, rx from, rx to (radians), across the element's scroll travel. */\n  turn?: [number, number, number, number]\n  className?: string\n  children?: React.ReactNode\n  nonce?: string\n}) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  useCubeWindow(ref, boxSize, turn)\n  return (\n    <div ref={ref} className={className ? 'cube-window ' + className : 'cube-window'}>\n      <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />\n      {children}\n    </div>\n  )\n}\n"
    }
  ]
}