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.
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: .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:pulseHintkeeps 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-previewwrapper: 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 setaria-hidden="true"on it and put the instruction in the container'saria-label. - Keyboard and screen readers. The
overflow: autocontainer has no focusable child: Chrome 130+ and Firefox make it focusable with Tab (the arrow keys scroll, the effect follows), Safari does not — settabindex="0",role="region"and anaria-label. The word "PARALLAXE" is real text, read by screen readers; the shapes are empty, silentdivs. If the layers are decorative,aria-hidden="true"on.parallax-scroll-spaceavoids announcing a lone word inside a region that scrolls for nothing. - Layer contrast. The word:
#a855f74.99:1 and#ec48995.6:1 on#0a0a0fat 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.
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):
<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>
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 |
|---|---|---|
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
#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).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.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.