Scroll✨ Premium

Parallax Layers

A 280 px frame scrolling over 620 px, a single scroll listener and six style writes per event: background glow lifted 60 px and scaled ×1.5, three shapes rotated 180°, word scaled ×1.8 and faded to 50% — except the two front layers are position: absolute and leave the frame before 26% of the run.

CSSJavaScriptParallaxe

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

Wine list of a fine-dining restaurant — the bottle you turn to read the label — Parallax Layers example 1

① Wine list of a fine-dining restaurant — the bottle you turn to read the label

WhenA restaurant's wine list page: one frame per wine, cellar background, label medallion, wine name in the foreground, vintage and price in the margin.
WhyThe middle layer's rotation evokes the real gesture of turning a bottle to read its label — no other effect in the category produces this rotating motion on a layer separate from the background translation. The cellar background scrolls slowly with a slight zoom, the medallion turns 180° as if to be read from the other side, and the wine's name grows before fading to make room for the next vintage.
Settings540 × 360 frame: .parallax-viewport and the three layers at 360 px, .parallax-scroll-space at 600 px (240 px run) switched to display: grid, the three layers in grid-area: 1 / 1; position: sticky; top: 0 so they stay in frame up to the 180°; background disc replaced by the cellar (540 × 360, no blur), 120 px medallion in the middle layer's .plx-element, word in Georgia 28 px, gold → cream gradient. JS unchanged.
Solar system map in an online physics course — the orbit encoded in the effect — Parallax Layers example 2

② Solar system map in an online physics course — the orbit encoded in the effect

WhenInteractive figure of a chapter on orbital motion: star field, sun at the center of the orbit ring, planet on the ring, body name and distance in the foreground.
WhyThe three different speeds combined with the middle layer's rotation reproduce orbital motion with no extra CSS — the effect's mechanics directly encode the physics being taught, where a simple two-layer parallax would only decorate. The star field scrolls slowly, the planet travels half an orbit around the sun (180°), the body's name and distance grow then fade as you scroll.
SettingsSame frame settings (360 px, 240 px run, stacked sticky layers). Background: star field made of multiple radial-gradients inside .plx-element (the ×1.5 zoom becomes an approach). Middle: 170 px orbit ring with the sun centered (invariant under rotation) and the planet on the ring; rotate(180·e deg) becomes half a revolution. Foreground: "Mars · 228 M km" at 26 px.
Segment sheet in a cycling training tool — context, effort and result in one gesture — Parallax Layers example 3

③ Segment sheet in a cycling training tool — context, effort and result in one gesture

WhenSheet of a timed segment in a performance analysis tool: photo of the pass in the background, chronometric arc, record time, segment name and profile in the margin.
WhyThe simultaneous translate + rotate + scale + opacity combination on three layers condenses three pieces of information (context, effort, result) into a single scroll interaction — a static overlay would have needed tabs or tooltips. The pass scrolls slowly in the background, the chronometric arc turns on the middle layer, the record time grows and fades in the foreground.
SettingsSame frame settings. Background: dawn sky and two ridges in clip-path: polygon() inside .plx-element. Middle: 140 px ring in conic-gradient (270° arc, twelve ticks) masked to an annulus, 2 px needle — the 180° rotation reads as a stopwatch hand. Foreground: "12:47" in 40 px monospaced digits, mint → cyan gradient, faded to 50% at the end stop.

How it works

Anatomy: .parallax-viewport#parallax-scroll is the scrolling container — width: 100%, height: 280px, overflow: hidden auto, styled 4 px WebKit scrollbar. It holds .parallax-scroll-space, a 900 px block in position: relative: the run is 900 − 280 = 620 px. Three layers live inside. #plx-bg (.st-parallax-layer: position: sticky; top: 0, 280 px, centered flex, pointer-events: none, z-index: 1) carries a 200 px .plx-element, a radial-gradient(rgba(99,102,241,.15), transparent 70%) disc under filter: blur(20px) — measured at the end of the run, its brightest point is rgb(20,21,40) on #0a0a0f, barely a glow. #plx-mid (position: absolute; top: 0, 280 px, z-index: 2) lines up, in a flex .plx-element with gap: 12px, three 40 px .plx-shape: a diamond (2 px violet border at 40%, 8 px radius, rotate(45deg)), a circle (pink border at 40%) and a triangle (clip-path: polygon(50% 0, 0 100%, 100% 100%), cyan at 20%). #plx-fg (absolute too, z-index: 3) centers a span.plx-text "PARALLAXE" at 1.4 rem weight 800 — 135 × 25 px measured — with a #a855f7 → #ec4899 gradient clipped by background: … text and -webkit-text-fill-color: transparent. Underneath, .scroll-hint-label "Scrollez pour explorer": absolute, 8 px from the bottom, 0.65 rem, white at 30%, opacity pulsing from .3 to .7 every 2 s (pulseHint).

The JavaScript is one scroll listener on the container, with no requestAnimationFrame and no smoothing. On each event it reads l = scrollTop and e = l / (scrollHeight − clientHeight) — progress from 0 to 1 over the 620 px — then writes six inline styles, all linear: the background disc gets translateY(−60·e px) scale(1 + .5·e); the middle layer translateY(−.3·l px) and its three shapes rotate(180·e deg); the front layer translateY(−.6·l px), the word scale(1 + .8·e) and opacity: 1 − .5·e. Measured with the wheel in Chromium, at 300 px (e = 0.484): background −29 px / ×1.24, middle −90 px, rotation 87°, front −180 px, word ×1.39 at 76% opacity; at the end stop: −60 px / ×1.5, −186 px, 180°, −372 px, ×1.8 at 50%. No inertia: every value is exactly proportional to the scroll and retraces its steps when you scroll back. Each event costs four querySelector calls and six style writes; the listener is never removed.

The geometry contradicts the "three speeds" promise. Only the background layer is sticky; the middle and the front are position: absolute; top: 0 inside the space that scrolls: they already move up at the content's speed, and the negative translation is added on top — 1.3× and 1.6× the scroll, upward. Measured: the word leaves the frame at 100 px of run (e = 0.16, barely ×1.13 and 92% opacity), the three shapes at 160 px (e = 0.26, 46° of rotation). Of the 620 px, 520 show nothing but the sticky glow sliding by 60 px. The 180°, the ×1.8 and the fade to 50% play out off-screen. Two signs of reworked code: .parallax-viewport is declared twice, first position: sticky; top: 0 then position: relative — the first rule is dead — and the .st-parallax-layer class (the sticky one) is only set on the background. For a three-layer parallax over the whole run, all three must be made sticky and stacked, and the two translations reduced (see FAQ); the rest of the script is unchanged.

Accessibility

  • prefers-reduced-motion not handled. Measured under reduce: pulseHint keeps running and, at 300 px of scroll, the middle layer is rotated 87° and the word scaled ×1.39. A parallax is the textbook case of WCAG 2.3.3 (animation from interaction). Gate the listener on !matchMedia('(prefers-reduced-motion: reduce)').matches — the layers then stay at rest, an honest fallback — and add @media (prefers-reduced-motion: reduce) { .scroll-hint-label { animation: none } }.
  • The "Scrollez pour explorer" hint is hard-coded in French, at 10.4 px, white at 30% under a pulsing opacity: computed contrast of 1.22:1 to 1.83:1 on #0a0a0f (2.62:1 without the pulse), far from 4.5:1. It is also positioned relative to the .demo-preview wrapper: pasted without it, measured in a 600 px window, it lands at y = 579 while the container ends at y = 400. Either make it a readable instruction (14 px, white at 70%), or set aria-hidden="true" on it and put the instruction in the container's aria-label.
  • Keyboard and screen readers. The overflow: auto container has no focusable child: Chrome 130+ and Firefox make it focusable with Tab (the arrow keys scroll, the effect follows), Safari does not — set tabindex="0", role="region" and an aria-label. The word "PARALLAXE" is real text, read by screen readers; the shapes are empty, silent divs. If the layers are decorative, aria-hidden="true" on .parallax-scroll-space avoids announcing a lone word inside a region that scrolls for nothing.
  • Layer contrast. The word: #a855f7 4.99:1 and #ec4899 5.6:1 on #0a0a0f at rest (22 px bold: the 3:1 large-text threshold is met), but 2.05:1 to 2.15:1 at 50% opacity at the end of the run, and 3.96:1 / 3.53:1 on white. The shapes, decorative (3:1 threshold), are below it: borders at 40% = 1.70:1 and 1.76:1, triangle at 20% = 1.45:1; raise the alphas to .8 if they must be seen. The background glow is at 1.14:1: invisible on a light background.

Browser compatibility

Requires ES2015 (const, arrow functions, template literals), position: sticky, clip-path: polygon(), filter: blur(), the two-value overflow: hidden auto syntax and the text keyword in the background shorthand — that one sets the Chrome threshold. No library, no CSS variables, no animation-timeline: the parallax is computed in JavaScript on the scroll event, so it works in Firefox, unlike the five other parallaxes of this category (fx-0538, fx-0548, fx-0557, fx-0573, fx-0592), which are native CSS scroll-driven — measured in Firefox 153: CSS.supports('animation-timeline: scroll()') returns false and fx-0548 does not move there, while fx-0576 rotates 73° after a 300-unit wheel. Same transforms, same text gradient and same triangle measured in WebKit.

Chrome 120+✓ Full
Firefox 61+✓ Full
Safari 14+✓ Full
Edge 120+✓ Full
Mobile iOS 14+✓ Full (touch scrolling, scroll events during momentum)
Android Chrome 120+✓ Full

Without JavaScript, the area scrolls but nothing transforms: the glow stays stuck, the shapes and the word move up with the content and leave the frame. Chrome and Edge before 120 (December 2023) reject text in the background shorthand: the whole declaration is dropped and, with -webkit-text-fill-color: transparent, the word is invisible — add -webkit-background-clip: text next to it. Without clip-path (Safari before 13.1 unprefixed), the triangle is a cyan square.

The code

HTML structure to paste into your page (CSS + JS available with a premium account):

index.html — structure
<div class="parallax-viewport" id="parallax-scroll">
  <div class="parallax-scroll-space">
    <div class="st-parallax-layer parallax-bg-layer" id="plx-bg">
      <div class="plx-element"></div>
    </div>
    <div class="parallax-mid-layer" id="plx-mid">
      <div class="plx-element">
        <div class="plx-shape"></div>
        <div class="plx-shape"></div>
        <div class="plx-shape"></div>
      </div>
    </div>
    <div class="parallax-fg-layer" id="plx-fg">
      <span class="plx-text">PARALLAXE</span>
    </div>
  </div>
</div>
<span class="scroll-hint-label">Scrollez pour explorer</span>
🔒 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
Run (CSS — .parallax-scroll-space height 900px) 900 px, i.e. a 620 px run for a 280 px frame e is normalized: the amplitudes (−60 px, 180°, ×1.8) are reached at the end stop whatever the height; a longer run slows everything down. But −.3·l and −.6·l are absolute pixels: at 1,500 px of run, the middle layer climbs 450 px.
Frame height (CSS — .parallax-viewport, .st-parallax-layer, .parallax-mid-layer, .parallax-fg-layer: 280px) 280 px, four times The four values change together: the layers are sized by hand, not with height: 100%. The .demo-preview wrapper also imposes min-height: 300px.
Background (JS — translateY(${-60*e}px) scale(${1+.5*e})) −60 px and ×1.5 at the end stop Amplitude of the background disc's slide and zoom. −120 / 1 for a background that climbs without growing. The 200 px disc blurred by 20 px stays nearly invisible (1.14:1): raise the gradient's alpha from .15 to .4 to see it.
Middle layer (JS — translateY(${-.3*l}px) and rotate(${180*e}deg)) −0.3 px per scrolled pixel, 180° at the end stop The translation adds to the layer's natural scrolling (absolute): 1.3× in total. 360 for a full turn; 0 for a layer that only slides. The three shapes rotate as one block around the center of .plx-element.
Foreground (JS — translateY(${-.6*l}px), scale(${1+.8*e}), opacity = 1 − .5*e) −0.6 px per pixel, ×1.8 and 50% at the end stop 1 − e for a word that disappears completely; 1 + 2·e for a title zoom. The whole span scales, not the font: the text stays sharp.
Layer stacking (CSS — .parallax-mid-layer, .parallax-fg-layer: position absolute) absolute inside the scrolling space (out of frame at 100 and 160 px) For a parallax over the whole run: .parallax-scroll-space { display: grid } and, on the three layers, grid-area: 1 / 1; position: sticky; top: 0; then −.15·l and −.2·l in the script. Measured that way on 280 px with a 620 px run: shapes and word visible up to the end stop (the word at 86%). With sticky alone, the word still leaves at 310 px.
Colors (CSS — glow rgba(99,102,241,.15), borders .4, gradient #a855f7 → #ec4899) indigo / violet, pink, cyan / violet → pink The word's gradient is clipped by background: … text: add -webkit-background-clip: text for Chrome and Edge before 120, otherwise the word is transparent there. On a light background the word holds 3.96:1 / 3.53:1: darken toward #7e22ce → #be185d.
Hint (HTML — .scroll-hint-label) "Scrollez pour explorer", 10.4 px, white at 30%, 2 s pulse Text to translate or remove; it depends on the position: relative of .demo-preview. Under prefers-reduced-motion, stop pulseHint.

FAQ

It is what the sold code does, measured: the word leaves the frame at 100 px of run out of 620 (16%), the three shapes at 160 px (26%). Only #plx-bg carries the .st-parallax-layer class (position: sticky); #plx-mid and #plx-fg are position: absolute; top: 0 inside .parallax-scroll-space, which scrolls. So they climb at the content's speed, plus their translation (−.3·l, −.6·l): 1.3× and 1.6× the scroll. The 180° rotation, the ×1.8 and the fade play out off-screen. Fix in three rules and two numbers: .parallax-scroll-space { display: grid }, on the three layers grid-area: 1 / 1; position: sticky; top: 0 — they stack in the same cell and stay stuck for the whole run, only the script's translations move them — then −.15·l and −.2·l instead of −.3 and −.6. Measured with these settings: shapes and word visible up to the end stop. With sticky alone and the original factors, the word still leaves at 310 px (−372 px at the end stop for a 280 px frame).
The engine. Those three (like Parallax fx-0538 and Parallax CSS fx-0557) are driven by animation-timeline: scroll() or a named scroll-timeline: zero JavaScript, but nothing moves in Firefox — measured in Firefox 153, CSS.supports('animation-timeline: scroll()') returns false and fx-0548's layers keep transform: none after scrolling — and the amplitude is frozen in @keyframes. Here, a scroll listener computes every value: the effect runs in Firefox (73° after a 300-unit wheel) and every factor is a number in the script. Motion-wise: fx-0548, fx-0557 and fx-0573 only translate; fx-0592 combines translation, scale(1 → 1.3), ±5 to 10° rotations and an opacity from .7 to 1 over five layers; fx-0576 is the only one that rotates a layer by 180° and grows then fades the foreground (×1.8, 50%). And the only one where two layers out of three leave the frame before a third of the run — see the previous question.
The script is not wrapped: four consts at script level (parallaxScroll, plxBg, plxMid, plxFg), no IIFE, and four getElementById calls — a single instance is possible. Measured: the second paste raises this syntax error and the second block does not run (the first, already started, keeps working). For several instances: wrap the script in (() => { … })() or, better, replace the ids with classes and loop over document.querySelectorAll('.parallax-viewport'), each instance finding its layers with querySelector inside its own container; the HTML ids become unnecessary. The original's parallaxScroll && plxBg && … guard only protects against missing HTML, not against a duplicate.