# ScrollVars fx, llms.txt (copy-paste effects) > A growing library of scroll/pointer/state effects on the ScrollVars engine > (npm i scrollvars, 12.9 KB gzip as ESM; the fx/sv.js IIFE on these pages is 13.1 KB). Each effect below includes when to > use it and its knobs. Effects include Tailwind/CSS/React recipes; Sections > expose the complete CLI source (CSS included). Engine API: see > https://scrollvars.dev/llms.txt ## Staggered reveal (staggered-reveal) Children rise in sequence when the section enters the viewport. Category: Reveals Use for: Heroes, editorial sections, any "content arrives" moment. Knobs: --sv-distance (travel), --sv-stagger (delay step), --sv-duration, --sv-ease Install: npx scrollvars add staggered-reveal Styles: scrollvars/styles/core.css ### Tailwind ```html

Title

Copy

More

``` ### CSS ```html

Title

Copy

/* needs styles/core.css (or paste the preset): */ .sv, [data-sv] { --sv-live: 0; } .sv.sv-live { --sv-live: 1; } .sv-on .sv .sv-rise { opacity: var(--sv-live); translate: 0 calc((1 - var(--sv-live)) * var(--sv-distance, 6rem)); transition: opacity .8s var(--sv-ease, cubic-bezier(0.28, 0.84, 0.42, 1)), translate .8s var(--sv-ease, cubic-bezier(0.28, 0.84, 0.42, 1)); transition-delay: calc(var(--sv-order, 0) * var(--sv-stagger, 90ms)); } /* the same sheet's reduced-motion override, and it has to sit AFTER the rule it beats: equal specificity, later wins */ @media (prefers-reduced-motion: reduce) { .sv-on :is(.sv, [data-sv]) :is(.sv-rise, .sv-fade, .sv-slide-l, .sv-slide-r, .sv-drift) { transition: none; animation: none; opacity: 1; translate: none; } } /* the same under html[data-sv-motion="reduce"], the site's own switch */ .sv-on:where([data-sv-motion="reduce"]) :is(.sv, [data-sv]) :is(.sv-rise, .sv-fade, .sv-slide-l, .sv-slide-r, .sv-drift) { transition: none; animation: none; opacity: 1; translate: none; } /* a focused entrance child must be visible now, not after its stagger delay (WCAG 2.4.7): must sit AFTER the reduce blocks above, same source-order rule */ .sv-on .sv .sv-rise:focus-within { transition: none; opacity: 1; translate: none; } ``` ### React ```tsx {/* every direct child, stagger for free */}

Title

Copy

// or per-child knobs as props: first second ``` ## Deck spread (deck-spread) A centered card deck deals itself into the grid. On arrival or scrubbed. Category: Reveals Use for: Feature cards, pricing tiers, portfolio grids that deserve an entrance. Knobs: --sv-mid = (N−1)/2, --sv-order per card, --sv-gap; map --sv-spread yourself to scrub Install: npx scrollvars add deck-spread Styles: scrollvars/styles/core.css, scrollvars/styles/pin.css ### Tailwind ```html
01
02
03
``` ### CSS ```html
…
…
…
/* styles/core.css ships it; the math, if you want it inline: */ .sv-spread { display: flex; gap: var(--sv-gap, 16px); justify-content: center; } .sv-spread > * { --sv-d: calc(var(--sv-order, 0) - var(--sv-mid, 1)); translate: calc(var(--sv-d) * (var(--sv-spread, 0) - 1) * (100% + var(--sv-gap, 16px))) 0; rotate: calc(var(--sv-d) * (1 - var(--sv-spread, 0)) * -5deg); } /* scrub instead of play: */ .mine > * { --sv-spread: clamp(0, calc(var(--sv-t) * 2), 1); } /* the same sheet's reduced-motion override, last so it wins on source order: */ @media (prefers-reduced-motion: reduce) { .sv-spread > *, .sv-on .sv .sv-spread.sv-spread-in > * { translate: none; rotate: none; transition: none; } } /* the same under html[data-sv-motion="reduce"], the site's own switch */ :where([data-sv-motion="reduce"]) .sv-spread > *, .sv-on:where([data-sv-motion="reduce"]) .sv .sv-spread.sv-spread-in > * { translate: none; rotate: none; transition: none; } ``` ### React ```tsx
{cards.map((c, i) => ( ))}
{/* Item carries the vars as props. No style attr; sv-spread targets direct children, and the Item wrapper IS the direct child. */} ``` ## Curtain (curtain) Two panels slide apart as you scroll through a pinned stretch. Category: Pinned scenes Use for: Chapter breaks, big reveals, section transitions. Knobs: outer height = scroll length; panel content/colors are yours Install: npx scrollvars add curtain Styles: scrollvars/styles/pin.css ### Tailwind ```html
revealed content
``` ### CSS ```html
…
…
…
/* the preset (styles/pin.css), plus the two panels, which are yours: */ .sv-stage .panel { position: absolute; inset: 0; width: 50%; background: #14141a; } .sv-stage .sv-curtain-r { left: 50%; } .sv .sv-curtain-l { translate: calc(var(--sv-pin, 0) * -101%) 0; } .sv .sv-curtain-r { translate: calc(var(--sv-pin, 0) * 101%) 0; } /* the same sheet's reduced-motion override, last so it wins on source order: */ @media (prefers-reduced-motion: reduce) { .sv .sv-curtain-l, .sv .sv-curtain-r { display: none; } } /* the same under html[data-sv-motion="reduce"], the site's own switch */ :where([data-sv-motion="reduce"]) .sv .sv-curtain-l, :where([data-sv-motion="reduce"]) .sv .sv-curtain-r { display: none; } ``` ### React ```tsx
``` ## Horizontal rail (horizontal-rail) Vertical scroll travels a horizontal track through a pinned stage. Category: Pinned scenes Use for: Process steps, timelines, galleries that read left-to-right. Knobs: outer height = scroll length; works at any viewport width (min() keeps it moving) Install: npx scrollvars add horizontal-rail Styles: scrollvars/styles/pin.css ### Tailwind ```html
{cards.map(c => )}
``` ### CSS ```html
…cards…
/* the preset (styles/pin.css), plus the stage layout, which is yours: */ .rail-stage { display: flex; align-items: center; } /* --sv-stage-width is measured by the driver; --sv-rail-start overrides it */ .sv .sv-rail { width: max-content; display: flex; gap: 1rem; padding: 0 10vw; translate: calc((1 - var(--sv-pin, 0)) * var(--sv-rail-start, var(--sv-stage-width, 100vw)) + var(--sv-pin, 0) * min(var(--sv-stage-width, 100vw) - 100%, 0px)) 0; } /* the same sheet's reduced-motion override, last so it wins on source order: */ @media (prefers-reduced-motion: reduce) { .sv .sv-rail { translate: none; width: auto; flex-wrap: wrap; } } /* the same under html[data-sv-motion="reduce"], the site's own switch */ :where([data-sv-motion="reduce"]) .sv .sv-rail { translate: none; width: auto; flex-wrap: wrap; } ``` ### React ```tsx
{cards}
``` ## Sequenced scrub (sequenced-scrub) Each child animates over its own slice of the pin. Choreography without a timeline. Category: Pinned scenes Use for: Pinned stories where A plays over 0–40%, B over 30–70%, C over 60–100%. Knobs: --sv-from / --sv-to per child (slice of the clock), --sv-distance; override --sv-clock to pick the clock Install: npx scrollvars add sequenced-scrub Styles: scrollvars/styles/pin.css ### Tailwind ```html

First

Second

Third

``` ### CSS ```html

First

Second

Third

/* styles/pin.css ships it; the mechanism, if you want it inline: */ /* the clock lives on the container at zero specificity, so your own rule on that container (--sv-clock: var(--sv-t)) or an inline style wins */ :where(.sv .sv-range) { --sv-clock: var(--sv-pin, var(--sv-t, 0)); } .sv .sv-range > * { --sv-r: clamp(0, calc((var(--sv-clock) - var(--sv-from, 0)) / (var(--sv-to, 1) - var(--sv-from, 0))), 1); } /* consume --sv-r however you like, ALWAYS with a fallback of 1, and floor an opacity so unread text still meets 4.5:1 (the presets floor at .55): */ .mine > * { opacity: calc(.55 + .45 * var(--sv-r, 1)); scale: calc(.8 + var(--sv-r, 1) * .2); } /* the same sheet's reduced-motion override, last so it wins on source order: */ @media (prefers-reduced-motion: reduce) { .sv .sv-range > * { --sv-r: 1; } } /* the same under html[data-sv-motion="reduce"], the site's own switch */ :where([data-sv-motion="reduce"]) .sv .sv-range > * { --sv-r: 1; } ``` ### React ```tsx
{steps.map((s, i, all) => ( ))}
// JS twin for canvas/WebGL consumers: // track(el, { pin: '250vh', onPin: (p) => uniform.set(mapRange(p, 0.3, 0.7)) }) ``` ## Wall to floor fold (wall-floor-fold) Copy runs down a wall and, at the fold, keeps going onto the floor toward you. A clone in perspective, --sv-pin driving both. Category: Pinned scenes Use for: Manifestos, case-study openers, one long passage that deserves the corner. Knobs: data-sv-pin (total scroll length) and --sv-fold-travel (pin length minus 100vh, keep them matched); --sv-fold-depth, --sv-fold-eye, --sv-fold-angle, --sv-fold-line (perspective) Install: npx scrollvars add wall-floor-fold Styles: scrollvars/styles/pin.css ### Tailwind ```html
…your copy…
``` ### CSS ```html
…your copy…
/* geometry as custom properties, so a page can tune them without touching the rules: depth (perspective), eye (perspective-origin y), fold (where the wall ends and the floor starts), travel (total scroll distance the copy moves, matched to data-sv-pin minus 100vh). */ .fold-scene { perspective: var(--sv-fold-depth, 650px); perspective-origin: 50% var(--sv-fold-eye, 11%); } .fold-wall { position: absolute; inset: 0 0 auto 0; height: var(--sv-fold-line, 70vh); overflow: hidden; } .fold-line { position: absolute; left: 0; right: 0; top: var(--sv-fold-line, 70vh); height: 1px; background: currentColor; opacity: .2; } /* drawn at twice the size and scaled back down: the text nearest the camera stays sharp */ .fold-floor { position: absolute; left: 0; width: 100%; top: var(--sv-fold-line, 70vh); height: 100%; overflow: hidden; transform-origin: 50% 0; rotate: x var(--sv-fold-angle, 90deg); mask-image: linear-gradient(#000 55%, transparent); /* fades out before it reaches the camera */ } /* centered with left: 50% + a -50% translate (relative to the copy's own, possibly overflowing, width) rather than margin: auto, which clamps to 0 and goes flush-left the moment the floor copy is wider than the floor itself (narrow viewports): a symmetric overflow reads as "close to the camera", a one-sided one reads as a clipped bug. */ .fold-copy { width: min(720px, 100% - 40px); position: relative; left: 50%; translate: -50% calc(var(--sv-pin, 0) * -1 * var(--sv-fold-travel, 280vh)); } .fold-floor .fold-copy { translate: -50% 0; } /* the floor copy scrolls on its own wrapper, in the same units as the wall: no zoom or scale here, engines disagree on how zoom treats vh (the floor drifted). */ .fold-floor .fold-shift { translate: 0 calc(-1 * (var(--sv-pin, 0) * var(--sv-fold-travel, 280vh) + var(--sv-fold-line, 70vh))); } /* no JS: the wall reads as a normal column; the floor clone never renders */ html:not(.sv-on) .fold-wall { position: static; height: auto; overflow: visible; } html:not(.sv-on) .fold-floor, html:not(.sv-on) .fold-line { display: none; } html:not(.sv-on) .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; } /* released later (data-sv-off): the same reset, for a tracker that stops */ [data-sv-off] .fold-wall { position: static; height: auto; overflow: visible; } [data-sv-off] .fold-floor, [data-sv-off] .fold-line { display: none; } [data-sv-off] .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; } /* the same sheet's reduced-motion override, last so it wins on source order: .sv-stage already returns to flow (pin.css); this drops the perspective, unclips the wall and hides the floor clone. Flat, no clone, no 3D. */ @media (prefers-reduced-motion: reduce) { .fold-scene { perspective: none; } .fold-wall { position: static; height: auto; overflow: visible; } .fold-floor, .fold-line { display: none; } .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; } } /* the same under html[data-sv-motion="reduce"], the site's own switch */ :where([data-sv-motion="reduce"]) .fold-scene { perspective: none; } :where([data-sv-motion="reduce"]) .fold-wall { position: static; height: auto; overflow: visible; } :where([data-sv-motion="reduce"]) .fold-floor, :where([data-sv-motion="reduce"]) .fold-line { display: none; } :where([data-sv-motion="reduce"]) .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; } ``` ### React ```tsx // npx scrollvars add wall-floor-fold import { WallFloorFold } from './components/fx/WallFloorFold' function Manifesto() { return (

Every page is a place.

We build brands the way architects build rooms…

) } // pin defaults to '380vh': pass a longer one to match more copy, and keep // travel (default '280vh') at pin minus 100vh. depth/eye/angle/fold are // props too, written as --sv-fold-* vars. // The floor is the same children, rendered a second time and marked // aria-hidden + inert: it never reaches assistive tech or the tab order. ``` ## GSAP timeline under scrub (gsap-scrub) Author the choreography in GSAP, let ScrollVars drive it. One listener, one writer. Category: Interop Use for: The page that genuinely needs timeline authoring. You keep the driver; GSAP renders. Knobs: the timeline is yours; ScrollVars owns scroll (pin/onPin). Never let GSAP add its own scroll listener Install: npx scrollvars add gsap-scrub Styles: scrollvars/styles/pin.css ### Tailwind ```html
…
``` ### CSS ```html
…stage…
``` ### React ```tsx const stage = useRef(null) const tl = useRef(null) const progress = useRef(0) const tracking = useRef(true) // reads only refs, so identity does not matter to the effect below const settle = () => tl.current?.progress(prefersReducedMotion() || !tracking.current ? 1 : progress.current) useEffect(() => { // scoped to this instance's own stage, never a bare class selector: two // of these on one page must not animate each other's children tl.current = gsap.timeline({ paused: true }) .from(stage.current!.children, { y: 140, opacity: 0, stagger: 0.2 }) settle() const off = onMotionChange(settle) return () => { off(); tl.current?.kill() } }, []) { progress.current = p; settle() }} onStatus={(status) => { tracking.current = status !== 'failed' && status !== 'released'; settle() }} >
…
``` ## Three.js scene on the pin (three-scene) A WebGL scene scrubbed by scroll. The canvas harness runs the lifecycle, Three renders. Category: Interop Use for: Hero 3D moments, product tours, camera paths, WebGL driven by the same variables. Knobs: mountEffect({ context: null }) hands you the raw canvas; feed progress via onPin/onTravel closures Install: npx scrollvars add three-scene Styles: scrollvars/styles/pin.css ### Tailwind ```html
``` ### CSS ```html
``` ### React ```tsx 'use client' const progress = useRef(0) const canvasRef = useCanvasEffect({ context: null, setup(fx) { /* THREE renderer on fx.canvas */ }, resize(fx) { /* size + DPR */ }, frame(fx, dt) { /* render with progress.current */ }, }) (progress.current = p)}>
``` ## Split reveal (split-reveal) A headline broken into words, each rising on its own beat, SplitText without the engine. Category: Text Use for: Hero headlines, section titles, any text that deserves an entrance. Knobs: data-sv-split (word|char) does the splitting; --sv-stagger (beat), --sv-duration; pair with sv-reading to scrub instead Install: npx scrollvars add split-reveal Styles: scrollvars/styles/core.css ### Tailwind ```html

Words arrive one by one

``` ### CSS ```html

Words arrive one by one

/* the preset (styles/core.css): */ .sv, [data-sv] { --sv-live: 0; } .sv.sv-live { --sv-live: 1; } /* non-replaced inline boxes ignore translate: the animated spans need it */ .sv-split > span[aria-hidden] { display: inline-block; } .sv-on .sv .sv-split-rise > span { opacity: var(--sv-live); translate: 0 calc((1 - var(--sv-live)) * 0.6em); transition: opacity var(--sv-duration, 800ms) var(--sv-ease, cubic-bezier(0.28, 0.84, 0.42, 1)), translate var(--sv-duration, 800ms) var(--sv-ease, cubic-bezier(0.28, 0.84, 0.42, 1)); transition-delay: calc(var(--sv-order, 0) * var(--sv-stagger, 90ms)); } /* the same sheet's reduced-motion override, and it has to sit AFTER the rule it beats: equal specificity, later wins */ @media (prefers-reduced-motion: reduce) { .sv-on :is(.sv, [data-sv]) .sv-split-rise > span { transition: none; animation: none; opacity: 1; translate: none; } } /* the same under html[data-sv-motion="reduce"], the site's own switch */ .sv-on:where([data-sv-motion="reduce"]) :is(.sv, [data-sv]) .sv-split-rise > span { transition: none; animation: none; opacity: 1; translate: none; } /* a focused split heading must reveal every word at once, not after its stagger delay (WCAG 2.4.7): must sit AFTER the reduce blocks above, same source-order rule */ .sv-on .sv .sv-split-rise:focus-within > span { transition: none; opacity: 1; translate: none; } /* scrub instead of play: the same spans feed sv-reading directly */

…

``` ### React ```tsx import { Split } from 'scrollvars/react' Words arrive one by one {/* renders the spans ON THE SERVER. The split markup is in the HTML, so no client-side splitting, no layout shift, no hydration flash. by="char" for letters. */} ``` ## Rotating words (rotating-words) One word exits up, the next rises from below. A clipped column on one variable. Category: Text Use for: Hero headlines: "We build ______". Knobs: --sv-word (index), --sv-duration; drive it from state, scenes or an interval Install: npx scrollvars add rotating-words Styles: scrollvars/styles/state.css ### Tailwind ```html

we build brands, websites and products

``` ### CSS ```html

we build brands, websites and products

/* the preset (styles/state.css): */ .sv-words { display: inline-flex; flex-direction: column; vertical-align: baseline; height: 1.15em; overflow: hidden; } .sv-words > * { display: block; height: 1.15em; line-height: 1.15; translate: 0 calc(var(--sv-word, 0) * -1.15em); transition: translate .65s var(--sv-ease, cubic-bezier(.28,.84,.42,1)); } /* the same sheet's reduced-motion override, last so it wins on source order: */ @media (prefers-reduced-motion: reduce) { .sv-words > * { transition-duration: .01ms; } } /* the same under html[data-sv-motion="reduce"], the site's own switch */ :where([data-sv-motion="reduce"]) .sv-words > * { transition-duration: .01ms; } // drive it (state, scenes, or a timer), and stop on request: let paused = false document.getElementById('words-pause').addEventListener('click', () => { paused = !paused document.getElementById('words-pause').setAttribute('aria-pressed', String(paused)) }) if (!paused) el.style.setProperty('--sv-word', nextIndex) ``` ### React ```tsx const [word, setWord] = useState(0)

we build{' '} {/* the column reads as three words in a row to a screen reader: say the phrase once */} {words.join(', ')}

``` ## Pointer tilt (pointer-tilt) Cards tilt toward the cursor with a moving glare. One delegated listener. Category: Pointer Use for: Product cards, team grids, anything that should feel physical. Knobs: tilt degrees and glare live in the preset. Override .sv-tilt to taste Install: npx scrollvars add pointer-tilt Styles: scrollvars/styles/tilt.css ### Tailwind ```html
…
``` ### CSS ```html
…
…
/* preset in styles/tilt.css. Tilt + glare from --mx/--my */ ``` ### React ```tsx const ref = usePointer()
{cards.map(c =>
…
)}
``` ## Coverflow slider (coverflow-slider) Native-scroll carousel; slides scale, fade and turn by their distance from center. Category: Sliders Use for: Anywhere you were about to install Swiper. Knobs: --sd per slide (written by slider()); --sv-per-view breakpoints; chrome vars --sv-arrow-*/--sv-dot-* Install: npx scrollvars add coverflow-slider Styles: scrollvars/styles/slider.css ### Tailwind ```html {cards.map(c => ( ))} ``` ### CSS ```html
…
×N
/* the coverflow is pure CSS on --sd: */ .slide { scale: calc(1 - min(max(var(--sd, 0), -1 * var(--sd, 0)) * 0.12, 0.3)); opacity: calc(1 - min(max(var(--sd, 0), -1 * var(--sd, 0)) * 0.35, 0.7)); transform: perspective(900px) rotateY(calc(var(--sd, 0) * -16deg)); } @media (prefers-reduced-motion: reduce) { .slide { scale: none; opacity: 1; transform: none; } } /* the same under html[data-sv-motion="reduce"], the site's own switch */ :where([data-sv-motion="reduce"]) .slide { scale: none; opacity: 1; transform: none; } /* scroll-linked transforms return to flow */ ``` ### React ```tsx {cards.map(c => )} // chrome: --sv-arrow-* / --sv-dot-* vars, prevIcon/nextIcon, renderDot, or your own UI via ref ``` ## Marquee (marquee) An infinite strip (logos, taglines) that pauses on hover. Category: Sliders Use for: Logo walls, ticker bands. The honest replacement for Swiper loop. Knobs: --sv-marquee-duration (one loop), --sv-gap; --sv-marquee-hover: running disables the pause Install: npx scrollvars add marquee Styles: scrollvars/styles/ui.css ### Tailwind ```html
{logos}{/* duplicate once, aria-hidden */}
``` ### CSS ```html
…content… …content again (aria-hidden)…
/* the preset (styles/ui.css): */ .sv-marquee { overflow: hidden; display: flex; } .sv-marquee-track { display: flex; gap: var(--sv-gap, 48px); width: max-content; padding-right: var(--sv-gap, 48px); animation: sv-marquee var(--sv-marquee-duration, 30s) linear infinite; } .sv-marquee:hover .sv-marquee-track, .sv-marquee:focus-within .sv-marquee-track { animation-play-state: paused; } .sv-marquee-controlled > .sv-marquee-track.sv-paused { animation-play-state: paused; } /* while the track holds focus, present it statically and wrapped (WCAG 2.4.7): pausing alone can leave a focused item translated off the strip */ .sv-marquee-track:focus-within { animation: none; width: auto; flex-wrap: wrap; } .sv-marquee-track:focus-within > .sv-marquee-dup { display: none; } @keyframes sv-marquee { to { translate: -50% 0; } } /* the same sheet's reduced-motion override, last so it wins on source order: without width/flex-wrap the strip keeps overflowing past the viewport with nothing left to scroll it into view */ @media (prefers-reduced-motion: reduce) { .sv-marquee-track { animation: none; width: auto; flex-wrap: wrap; } .sv-marquee-track > .sv-marquee-dup { display: none; } } /* the same under html[data-sv-motion="reduce"], the site's own switch */ :where([data-sv-motion="reduce"]) .sv-marquee-track { animation: none; width: auto; flex-wrap: wrap; } :where([data-sv-motion="reduce"]) .sv-marquee-track > .sv-marquee-dup { display: none; } ``` ### React ```tsx {logos.map(l => {l.name})} ``` ## Cinematic hero (hero-cinematic) Split headline rising on a beat, pointer-parallax glow, a marquee strip, and the whole block fades out as you scroll past (--sv-t). Category: Sections Use for: Landing pages, studio reels, product launches. The first fold that has to land. Knobs: --sv-stagger (word beat), --hero-parallax (px of pointer drift), --sv-marquee-duration; swap the orbs for images or video Install: npx scrollvars add hero-cinematic Styles: scrollvars/styles/core.css, scrollvars/styles/ui.css ### React ```tsx // ScrollVars fx · hero-cinematic // Requires: npm i scrollvars · import 'scrollvars/styles/core.css' and 'scrollvars/styles/ui.css' // Split headline rising on a beat, pointer-parallax glow, marquee strip; the block // fades and scales out as it leaves (--sv-t). The CSS below is the whole section, // one constant string injected as raw HTML: React 18's server renderer escapes a //