Scroll✨ Premium

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.

ScrollTimelineStagger

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

Brand manifesto as a sequence of values — a studio's “About us” page — Scroll Choreography Sequence example 1

① Brand manifesto as a sequence of values — a studio's “About us” page

WhenA company's “About us” page: four founding values, each with one line of proof, under a kicker and above a signature.
WhyEach value has its window in the HTML — Authenticity from 0 to 20%, Innovation 20 to 40%, Sustainability 40 to 60%, Commitment 60 to 80% — and the reader discovers them at the pace of their wheel, in the intended order, with no timer deciding for them. Text Highlight Scroll (fx-0591) lights up words inside a continuous paragraph; here distinct blocks enter in turn. In the shipped code they stack under one another: at 80% the manifesto is complete on screen — which is what you want for a list of values; to have each one hold the stage alone, see the FAQ.
Settings540 × 322 px scene under a 38 px navigation bar (aspect-ratio: auto, fixed height), Georgia serif, #f2b64a accent on the kicker and the bar, radial background #2c1f15 → #100c0a; six .scq-el: kicker 0 → 0.1, four values 0 → 0.2 / 0.2 → 0.4 / 0.4 → 0.6 / 0.6 → 0.8, signature 0.82 → 1; tabindex="0" and aria-labelledby (pointing at the kicker) on the container.
Opening titles of a projected conference — the host scrolls the screen — Scroll Choreography Sequence example 2

② Opening titles of a projected conference — the host scrolls the screen

WhenA keynote's projection screen: event name, session title, two speakers with their role, theme, room and time, which the host brings up with the wheel while the room settles.
WhyThe windows are HTML attributes: an organiser who swaps a speaker or shifts the theme edits data-from / data-to in the markup, never the script. Scroll Snap Sections (fx-0584) would file each piece of information in a full-page section; here everything composes on one pinned stage, and the reverse gesture puts the title back alone on screen — handy to restart the titles when a latecomer walks in.
SettingsFull-section 540 × 360 px scene, 36 px grid at 7% of #7aa2ff in the background, monospace kicker and footer, 30 px title; six .scq-el: kicker 0 → 0.12, title 0.08 → 0.3, speakers 0.32 → 0.48 and 0.42 → 0.58 (overlapping for the cascade), theme 0.62 → 0.78, room and time 0.84 → 1; initials as avatars, decorative “HDMI 1 · 1920 × 1080” label.
Presenting a clinical research protocol — the five phases of a trial — Scroll Choreography Sequence example 3

③ Presenting a clinical research protocol — the five phases of a trial

WhenA clinical trial's publication page: journal header with DOI, kicker (trial, phase, enrolment), title, then Hypothesis → Method → Results → Discussion → Conclusion as a numbered list along a timeline.
WhyThe protocol's order is fixed and the reader must go through it that way: five consecutive windows (0.2 → 0.34 … 0.84 → 0.98) enforce it, each phase entering under the previous one like a timeline being written. The research team lengthens a phase by changing two numbers in the HTML. Sticky Reveal Stack (fx-0568) stacks full-screen sections and choreographs nothing inside a shared stage; here the list builds before your eyes, on a light background.
SettingsLight variant: #f6f8fb scene, #101827 text, #0f766e accent on badges, kicker and bar, #d5dde8 track, cue on a 78% dark background; text-align: left on the stage, list with a 2 px vertical line and numbers as ::before (content: attr(data-n)); seven .scq-el: kicker 0 → 0.1, title 0.04 → 0.18, five phases 0.14 wide with 0.02 gaps.

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 the scq-reduced class to the scene before stopping — that is the class the CSS expects: spacer and pinned block at 100% (nothing left to scroll), seven elements forced to opacity: 1 and transform: none with !important, cue and bar set to display: 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: auto container has no tabindex and 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. Add tabindex="0" and an aria-label to .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, #5cffd8 fill 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 visibility or display), so the full text is read in HTML order whatever the scene shows — that is the right behaviour, do not put aria-hidden on them. The cue and the bar already carry aria-hidden="true". The “Commencer” button is a span: replace it with an a or a button, and note that an element at opacity 0 receives clicks (no pointer-events: none; measured at p = 0, elementFromPoint at 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 #5cffd8 12.6:1, title 15.9:1, subtitle #c7cbe0 10.1:1, white chips on 10% white 12.0:1, button #0a0e1f on white 16.8:1, 12 px cue 17.2:1 — everything passes. The problem lies elsewhere: the height comes from aspect-ratio: 16/10 alone. 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 by overflow: 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 a min-height (or a taller ratio below 480 px) and the cue white-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.

Chrome 88+✓ Full
Firefox 89+✓ Full
Safari 15+✓ Full
Edge 88+✓ Full
Mobile iOS 15+✓ Full (touch scrolling of the zone; content clipped below 355 px of scene width)
Android Chrome 88+✓ Full (touch scrolling of the zone; content clipped below 355 px of scene width)

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):

index.html — structure
<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>
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
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

Not with this code: 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.
Measured in an ordinary page with a 562 px tall scene: the first thirteen 100 px notches go to the container (1,237 px), the page only moves again on the fourteenth, and nothing (no 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%.
Two different mechanics. Stagger Cards is pure CSS (0 bytes of JS): each card animates 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.