# 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:
firstsecond
```
## 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
{/* 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
```
## 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
// 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…
…your copy again…
```
### CSS
```html
…your copy…
…your copy again…
/* 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() }
}, [])
```
## 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 */ },
})
```
## 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
brandswebsitesproductsbrands, websites and products
```
### CSS
```html
we build
brandswebsitesproductsbrands, 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{' '}
{words.map(w => {w})}
{/* 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
```
## 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
//