Scroll Choreography Sequence
Seven text elements that enter one by one — fade, 22 px rise, zoom from 96 to 100% — each in its own data-from / data-to window over a travel of 2.2 scene heights, reversible with the wheel. They never leave; under prefers-reduced-motion the scene shows fully assembled from the start, with no travel. Zero dependencies.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 effects, including 12 free. Explore the category →
3 usage examples



How it works
Anatomy: .scq-scene (width: 100%, aspect-ratio: 16/10, overflow: hidden, radial gradient #20264f → #0a0e1f) holds a .scq-scroll container in position: absolute; inset: 0; overflow-y: auto, scrollbar hidden (scrollbar-width: none backed by ::-webkit-scrollbar { width: 0 }). Inside, a .scq-spacer of 320% of the height and a .scq-sticky block (position: sticky; top: 0, height: 31.25% — exactly 100 / 3.2, i.e. the visible height of the container) that centres the .scq-stage (480 px max, centred text). Measured on a blank 900 px page: a 884 × 552.5 px scene, 1,768 px of scrollable content, a usable travel of 1,215 px (2.2 heights); at 1,264 px wide, 790 px tall and 1,738 px of travel. The seven .scq-el — kicker, title, subtitle, three chips, button — each carry data-from and data-to: 0 → 0.2; 0.1 → 0.35; 0.25 → 0.5; 0.4 → 0.6; 0.5 → 0.7; 0.6 → 0.8; 0.8 → 1.
The script (ES5, 1,804 characters including its header comment, a single IIFE) walks every .scq-scene and attaches a passive scroll listener to the container plus a resize on window. On each event, update() computes the progress p = scrollTop / (scrollHeight − clientHeight), then for each element t = clamp((p − from) / (to − from)) between 0 and 1 — from and to read with parseFloat(el.dataset.from) || 0 and || 1 — and writes it as a CSS variable: el.style.setProperty('--t', t.toFixed(3)). The CSS does the rest, with no transition and no @keyframes: opacity: var(--t, 0) and transform: translateY(calc((1 − var(--t)) × 22px)) scale(calc(.96 + var(--t) × .04)), with will-change: opacity, transform. Checked at p = 0.375, the middle of the subtitle's window: --t: 0.501, opacity 0.501, translateY(10.98px), scale(.98). The .scq-prog i bar reads --scq-p (width calc(var(--scq-p) × 100%)) and the .scq-hint cue goes to opacity: 0 (.3 s transition) as soon as p > 0.04 — 49 px of wheel at this size.
This is scrubbing: nothing is timed, position does everything, and the reverse gesture takes the scene apart (measured: back at the top, all seven --t return to 0). The windows overlap — the title starts at 0.1 while the kicker is halfway — hence the cascade. But t never comes back down once to is passed: elements enter and stay. At p = 1 all seven sit at --t: 1.000, together in the same scene; this code does not move an element out to make room for the next (the FAQ explains how to add it). Measured edge case: data-from equal to data-to divides by zero, giving ±Infinity that the clamp turns into 0 then 1 — an instant appearance at p = from. What is sold is this effect alone: a 1,804-character IIFE, no global variable, zero libraries, zero images, zero fonts to load — the CSS itself declares the background (solid #0a0e1f under the gradient), the font (sans-serif) and the box-sizing of its descendants, so the scene behaves the same pasted into a blank page as on this page (measured: 0 requests, 0 console errors, seven elements at --t: 1.000 at the end of the travel). The scroll and resize listeners stay in place as long as the page lives: if you remove a scene from the DOM dynamically, remove them too, otherwise the resize attached to window keeps the scene in memory. It is the architecture of Sticky Horizontal Reveal (fx-0796: same scrolling container, same pinned block, same bar, same cue at the 0.04 threshold) applied to per-element windows instead of a horizontal travel.
Accessibility
- prefers-reduced-motion: the scene shows assembled, with no travel. The script reads
matchMedia('(prefers-reduced-motion: reduce)')and, when the preference is on, adds thescq-reducedclass to the scene before stopping — that is the class the CSS expects: spacer and pinned block at100%(nothing left to scroll), seven elements forced toopacity: 1andtransform: nonewith!important, cue and bar set todisplay: none. Measured with the preference on, on a blank 1,280 px page: 0 px of travel, seven elements at opacity 1 from load, content centred in the scene, cue and bar absent. The choice is deliberate: rather than a parallel@media, a single class drives the no-motion state, and you can add it by hand (see customisation). - Keyboard and cue: the
overflow-y: autocontainer has notabindexand its scrollbar is hidden in every browser. Measured in Chromium: Tab does reach the container (keyboard-focusable scrollers since Chrome 130) and two Down arrows move it 80 px — but neither Safari nor earlier Chrome versions do. Addtabindex="0"and anaria-labelto.scq-scroll, and keep a visible indication: the cue fades after 4% of the travel, and the 3 px bar (track at 1.25:1 on the background,#5cffd8fill at 15.2:1) is then the only reminder that there is more to scroll. - Screen readers: the seven elements stay in the accessibility tree at every position (opacity only, no
visibilityordisplay), so the full text is read in HTML order whatever the scene shows — that is the right behaviour, do not putaria-hiddenon them. The cue and the bar already carryaria-hidden="true". The “Commencer” button is aspan: replace it with anaor abutton, and note that an element at opacity 0 receives clicks (nopointer-events: none; measured atp = 0,elementFromPointat the button's centre returns the button) — it is clickable before it is visible. - Contrast and framing: measured in Chromium on the pixels under the text, kicker
#5cffd812.6:1, title 15.9:1, subtitle#c7cbe010.1:1, white chips on 10% white 12.0:1, button#0a0e1fon white 16.8:1, 12 px cue 17.2:1 — everything passes. The problem lies elsewhere: the height comes fromaspect-ratio: 16/10alone. Measured at 360 px wide (a 344 × 215 px scene), the title wraps to two lines and the assembled content measures 217 px: the bottom of the button sticks out 22 px below the progress bar, clipped byoverflow: hidden(at 320 px: 217 px of content for a 200 px scene); at 364 px of scene, everything fits (187 px for 227). The cue, meanwhile, wraps to two lines as soon as the scene is narrower than 396 px (left: 50%leaves it only half the width; measured: two lines at 384 px, one at 404 px). Give the scene amin-height(or a taller ratio below 480 px) and the cuewhite-space: nowrap.
Browser compatibility
ES5 JavaScript (var, function) with dataset, NodeList.forEach, classList, matchMedia and style.setProperty on a custom property; CSS: position: sticky, inset, variables inside calc() within transform, will-change, isolation, and above all aspect-ratio, the sole source of the scene's height — without it, measured, the scene is 0 px tall. That one sets the floor: Chrome 88, Firefox 89, Safari 15. No animation-timeline: unlike the native-CSS scroll effects of this category (Stagger Cards fx-0540, Stagger Reveal CSS fx-0552, fx-0545, fx-0546…), which no version of Firefox reads, this choreography works in Firefox. scrollbar-width: none (Chrome 121, Firefox 64, Safari 18.2) is backed by ::-webkit-scrollbar { width: 0 }: the bar is hidden everywhere. Zero dependencies: no library, no image, no font to load — the shipped file contains this effect alone.
Without JavaScript the scene is blank: the seven elements stay at opacity: 0 (the default of var(--t, 0)), the “Scroll to build the scene” cue shows and 1,215 px of travel scroll for nothing. Without aspect-ratio (browsers older than 2021) the scene has no height and occupies nothing: give it an explicit height. Under prefers-reduced-motion, the script adds scq-reduced: the scene shows assembled from the start, with no travel (see accessibility).
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="scq-scene">
<div class="scq-hint" aria-hidden="true">Scroll to build the scene</div>
<div class="scq-prog" aria-hidden="true"><i></i></div>
<div class="scq-scroll">
<div class="scq-spacer">
<div class="scq-sticky">
<div class="scq-stage">
<div class="scq-el scq-kicker" data-from="0" data-to="0.2">…</div>
<div class="scq-el scq-title" data-from="0.1" data-to="0.35">…</div>
<p class="scq-el scq-sub" data-from="0.25" data-to="0.5">…</p>
<div class="scq-chips">
<span class="scq-el scq-chip" data-from="0.4" data-to="0.6">…</span>
<span class="scq-el scq-chip" data-from="0.5" data-to="0.7">…</span>
<span class="scq-el scq-chip" data-from="0.6" data-to="0.8">…</span>
</div>
<span class="scq-el scq-cta" data-from="0.8" data-to="1">…</span>
</div>
</div>
</div>
</div>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
data-from / data-to (HTML, per element) |
0 → 0.2; 0.1 → 0.35; 0.25 → 0.5; 0.4 → 0.6; 0.5 → 0.7; 0.6 → 0.8; 0.8 → 1 | Each element's entry window as a fraction of the travel: --t is 0 before from, 1 after to, linear in between. Overlap the windows for a cascade, space them out for turn-taking; a missing attribute means 0 and 1 (the element enters over the whole travel); from equal to to means an instant appearance. A data-to="0" is read as 1 (|| 1). |
Travel length (CSS — .scq-spacer { height: 320% } and .scq-sticky { height: 31.25% }) |
320% / 31.25% — 2.2 scene heights of wheel (1,215 px at 884 px wide) | The two values are coupled: the pinned block's height must stay 100 / (spacer ÷ 100), otherwise it overflows or leaves a gap. 250% ⇒ 40%: 1.5 heights of travel, faster entries; 400% ⇒ 25%: three heights, more time per element. |
Scene format (CSS — .scq-scene { width: 100%; aspect-ratio: 16/10 }) |
16/10, full width — 552 px tall at 884 px, 790 px at 1,264 px, 215 px at 344 px | The sole source of the height. Add max-width to avoid a 790 px scene on large screens, and min-height: 360px (or a taller ratio below 480 px) so the assembled content fits on mobile — measured, it overflows by 22 px at 360 px wide, as soon as the scene is 354 px or narrower. |
Entry amplitude (CSS — .scq-el: 22px and .96 + … × .04) |
22 px rise, zoom from 96 to 100% | Each element's path between --t 0 and 1, on top of the fade. 40px for a bold rise, 0px for a pure fade; .9 + … × .1 emphasises the zoom. Everything stays on the compositor thanks to will-change. |
Cue threshold (JS — p > 0.04) and its text (HTML — .scq-hint) |
4% of the travel (49 px at 884 px wide), “Scrollez pour composer la scène” | The cue fades out (.3 s transition) past this threshold and comes back when scrolling up. Raise it to 0.15 so it accompanies the first entry; add white-space: nowrap so it does not wrap below 396 px of scene width. |
| Colours (CSS) | background #0a0e1f under radial-gradient(circle at 50% 30%, #20264f, #0a0e1f 72%), #5cffd8 accent (kicker, bar), #c7cbe0 subtitle, chips at 10% / 18% white, white button on #0a0e1f |
The accent is used twice (kicker and .scq-prog i): change it in both places. The solid colour set under the gradient is the scene's effective background (fallback if the gradient does not apply, and what contrast tools read): change it along with it. On a light background, also invert .scq-stage { color }, the cue's background (rgba(0,0,0,.4)) and the track (rgba(255,255,255,.14)). |
Font (CSS — .scq-scene { font-family: sans-serif }) |
sans-serif (system generic) |
The scene declares its own font so as not to inherit the host page's (Times by default on a blank page). Replace it with your site's, or set inherit to take the surrounding one — which is what the three example scenes above do. |
scq-reduced class (HTML, on .scq-scene) |
absent (added by the script under prefers-reduced-motion) | No-motion state: spacer and pinned block at 100% (no more travel), seven elements forced visible with !important, cue and bar hidden, script inert. The script adds it itself when prefers-reduced-motion is on; add it by hand to ship the scene assembled without the system preference — screenshot, printable version, e-mail preview. |
Number of elements (HTML — any element carrying .scq-el) |
7 | The script animates every .scq-el in the scene whatever its markup; the cost per scroll event is n writes of --t. Each element is a compositing layer (will-change): beyond about thirty, remove that rule. |
FAQ
t = clamp((p − from) / (to − from)) only goes up, and once to is passed the element stays at --t: 1. Measured at the end of the travel, all seven elements are shown together — the catalogue's “scene that assembles” is accurate, a scene that takes turns does not exist. To get one, four lines in update(): read data-out and data-out-to (parseFloat(el.dataset.out), with || 2 so nothing leaves by default), compute u = 1 − clamp((p − out) / (outTo − out)), and write Math.min(t, u) instead of t (checked: title with data-out="0.9" data-out-to="1", --t = 0.502 at p = 0.95 and 0 at p = 1). Since --t drives both the opacity and the rise, the exit replays the entry backwards: the element sinks 22 px while fading. Then give each manifesto value data-from="0" data-to="0.1" data-out="0.18" data-out-to="0.2", the next one from 0.2, and take them out of the flow (position: absolute inside .scq-stage), otherwise the previous one's slot stays empty.overscroll-behavior) announces it. That is the price of a nested container with a hidden scrollbar. For a real page block: remove .scq-scroll, give .scq-spacer a height in vh (320vh) and .scq-sticky height: 100vh; in the script, replace the container's listener with window.addEventListener('scroll', update) and compute p = clamp(−rect.top / (spacer.offsetHeight − innerHeight)) with rect = spacer.getBoundingClientRect(). Everything else — windows, --t, bar, cue — is unchanged. If you keep the container, at least shorten the spacer to 250% / 40%.opacity and translateY(30px) on its own animation-timeline: view(), between entry and entry 40% of its arrival in the viewport, with a fixed 50 ms offset per :nth-child; the cascade comes from the layout — each card enters when it appears — and is set in CSS, with no per-element attribute. Scroll Choreography shares a single clock p for all the elements of a pinned stage, and the HTML says who enters when: a title at 10%, a button at 80%, regardless of their position in the page. Second difference, decisive depending on your audience: animation-timeline exists in no version of Firefox, where Stagger Cards does not cascade; fx-0797 works there. Pick fx-0540 for a grid of cards revealing along the page in Chrome and Safari; fx-0797 for a stage composed in the order you write, everywhere.