Zoom on Scroll
A 60 px square held sticky inside a 280 px box, its scale following the scroll — scale = 1 + 4 × scrollTop ÷ 620 — up to 300 px, while its corner radius goes from 12 to 2 px (10 px on screen once enlarged) and its opacity from 1 to .8. At 5×, the square overshoots the box by 40 px at the top.
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
The sold HTML is four elements: the frame .demo-preview (centered flex, min-height: 300px, position: relative, #0a0a0f background), the scrolling box .scroll-container#zoom-scroll (100% × 280px, overflow: hidden auto, scroll-behavior: smooth, a 4 px scrollbar styled with ::-webkit-scrollbar), the stage .zoom-inner (flex, justify-content: center, align-items: flex-start, 900 px tall, padding-top: 100px) and the target .zoom-target#zoom-target: 60 × 60 px, 135° gradient #6366f1 → #a855f7 → #ec4899, border-radius: 12px, position: sticky; top: 80px, the word ZOOM at 11.2 px weight 800, and transition: transform .1s linear, border-radius .3s. Below the box, the label .scroll-hint-label (absolute, 8 px from the bottom of the frame, 10.4 px, white at 30%) pulses between .3 and .7 opacity every 2 s; its text “Scrollez pour explorer” is hard-coded in French in the HTML. The travel is 900 − 280 = 620 px. No inline style, no site CSS variable, no console error.
The script (10 lines) attaches a single scroll listener to the box. On each event: t = scrollTop ÷ (scrollHeight − clientHeight), i.e. scrollTop ÷ 620, then three writes to style — transform: scale(1 + 4t), border-radius: max(12 − 10t, 0), opacity: 1 − .2t. One variable, three outputs, 155 px of travel per unit of scale. Measured in 62 px steps: 1.4 / 11 px / .98 at the first step, 3 / 7 px / .9 halfway, 5 / 2 px / .8 at the bottom. The radius never reaches zero: 12 − 10t is 2 px at t = 1 (the Math.max(…, 0) never kicks in), and since transform enlarges the corners too, the rendered corner measures 10 px at 5× — the “slab” keeps a rounding of 3% of its side, against 20% at rest. The two transitions differ in length: after a 245 px jump (PageDown), the scale reaches its value in 104 ms, the radius takes 300 ms — when the size settles, the corner still shows 6.2 px (31 px rendered) and finishes tightening 200 ms later.
The target starts 100 px from the top (the padding-top), slides 20 px, then sticky; top: 80px pins it: its center stays 110 px from the top of the box for the remaining 600 px, and the transform grows around that center (transform-origin 30 px 30 px). At 5×, the 300 px square spans from −40 to 260 px: 40 px are clipped at the top (13% of the square) by overflow: hidden, 20 px stay free at the bottom — measured identical at 320, 800 and 1,440 px wide; width only changes the side margins (2 px each in a 304 px box). scroll-behavior: smooth does nothing to wheel notches in Chromium (a 100 px notch = 2 events in 114 ms) but animates script-set positions: scrollTop = 620 reads back 0 immediately, lands at 408 ms, and the scale finishes at 508 ms. The listener lives on the box, nothing global to remove; but a top-level const zoomScroll plus getElementById limit the code to one instance per page — pasted twice: Identifier 'zoomScroll' has already been declared, second copy inert (measured: transform: none after 620 px of scrolling).
Accessibility
- prefers-reduced-motion not in the code: measured under emulation, the scale reaches 5 at the bottom, the label keeps pulsing and the 100 and 300 ms transitions remain. The growth is driven by the reader (nothing moves without them, except the label), which makes it an interaction-triggered animation (WCAG 2.3.3) — add
@media (prefers-reduced-motion: reduce) { .zoom-target { transition: none } .scroll-hint-label { animation: none } .scroll-container { scroll-behavior: auto } }, and if the square's content is readable at rest, cap the scale (1 + 2t) behindmatchMedia. - Keyboard: measured in Chromium, Tab focuses the box without a
tabindex(outline: auto 1pxring), arrow down moves 40 px (+0.26 of scale), PageDown or Space 245 px (+1.58), End goes all the way (5×). Firefox and Safari do not focus a scrolling box withouttabindex="0"on#zoom-scroll: add it, with a visible:focus-visible. Wheel and nested touch scrolling work; on a touch screen, the gesture must start inside the box. - Contrast and legibility: the word ZOOM (white, weight 800) measures 4.5:1 on the blue, 4.0:1 on the purple and 3.5:1 on the pink of the gradient — below 4.5:1 on two thirds of the square, at 11.2 px. At 5× it is 56 px (3:1 threshold) but opacity drops to .8: text and background both blend toward
#0a0a0f, 3.9:1 / 3.6:1 / 3.2:1 — a narrow pass on the pink. If the square carries real content (QR, chart, formula), keepopacity: 1and fix thetop: at maximum, the top 40 px are outside the frame, hence unreadable. The label pulses between 2.6:1 and 9.8:1: an instruction hard to read half of the time. - Screen readers and no JavaScript: the target is a
divwhose text is read as is; the scale change is not announced (nothing to announce if it is decorative: setaria-hidden="true"). If the square holds information, it must exist at a readable size without scrolling — a screen reader does not “zoom” by scrolling. The label “Scrollez pour explorer” is read in French whatever the page language. Without the script, the box scrolls, the square slides from 100 to 80 px then stays pinned at 60 px, radius 12: nothing grows, only the label pulses.
Browser compatibility
ES2015 script (const, arrow function, `scale(${e})` template), position: sticky, transition and a gradient. A single recent property: the two-value form overflow: hidden auto — a browser that does not understand it drops the declaration, the box stretches and nothing scrolls. No animation-timeline or scroll-timeline: the scale comes from a classic scroll listener writing a transform, so the effect works on Firefox, unlike its neighbours driven by native CSS scroll animations (fx-0590, fx-0570). Zero dependencies.
Without JavaScript, the box scrolls, the square slides 20 px then stays pinned at 60 px, radius 12, opacity 1: nothing grows, only the label pulses. Without overflow: hidden auto (Chrome < 68, Firefox < 61, Safari < 12.1), the box is 900 px tall and nothing scrolls. Without position: sticky, the square rises with the content while growing and leaves through the top.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="demo-preview">
<div class="scroll-container" id="zoom-scroll">
<div class="zoom-inner">
<div class="zoom-target" id="zoom-target">…</div>
</div>
</div>
<span class="scroll-hint-label">…</span>
</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 |
|---|---|---|
Scale factor (JS — e = 1 + 4 * t) |
1 → 5× (60 → 300 px) | Final size = side × factor. In a 280 px box, 5× does not fit (300 px): 4.6× is the largest that can be centered (1 + 3.6 * t). 1 + 1 * t gives a discreet 2×, read as a highlight. |
Radius (JS — r = 12 - 10 * t, Math.max(r, 0)) |
12 → 2 px (10 px rendered at 5×) | The transform enlarges the corners too: rendered radius = radius × scale. For true square corners, 12 − 12 * t; for a constant on-screen rounding, 12 / e. The Math.max only matters if the slope exceeds 12. |
Opacity (JS — 1 - .2 * t) |
1 → .8 | The slab becomes 20% translucent at the end of the travel and its white text turns grey (#cecece on a dark background). Set it to 1 for content meant to be read, or invert it (.6 + .4 * t) to make the square emerge. |
Travel (CSS — .zoom-inner { height: 900px }, .scroll-container { height: 280px }) |
620 px (155 px per unit) | Travel = stage height − box height, read back by the script (scrollHeight − clientHeight): nothing to change in JS. A 1,500 px stage = 1,220 px of travel, 305 px per unit, a slower zoom; the stage is also the sticky containing block, the target stays pinned inside it to the end. |
Pin point (CSS — .zoom-target { top: 80px }) |
80 px (center at 110 px, 40 px clipped at 5×) | The square's center pins at top + 30 px. To center it in the box: top = height ÷ 2 − 30, i.e. 110 px for 280 px. The final square must fit in the box: 300 px require a box of at least 300 px (326 px and top: 128px in the first scene). |
Size and content (CSS — width/height: 60px, HTML — the ZOOM text) |
60 × 60 px, the word ZOOM at 11.2 px | Everything inside grows with the box: 11.2 px text becomes 56 px at 5×, a 60 px <img> is stretched to 300 px (blurry) — provide a 300 px source displayed at 60 px, or SVG (QR, chart, formula). The square is not mandatory: 96 × 60 px give 480 × 300. |
Responsiveness (CSS — transition: transform .1s linear, border-radius .3s) |
100 ms / 300 ms | Lag between the scroll and the size: at 0 the square sticks to the gesture, at .3s it floats. The radius's 300 ms make it trail 200 ms behind the size (31 px rendered when the scale settles, 10 px afterwards): align both on .1s. |
Smooth scrolling and label (CSS — scroll-behavior: smooth, .scroll-hint-label) |
smooth / “Scrollez pour explorer” pulsing 2 s | smooth animates script-set positions (scrollTop = 620: reads back 0, reached at 408 ms) — switch to auto if a button or anchor drives the box. The label is a catalog leftover: hard-coded French, absolute relative to .demo-preview (without that frame it drops to the bottom of the page), two lines below 341 px wide. |
FAQ
top: 80px: its center settles 110 px from the top of a 280 px box, and the transform grows around that center. At 5×, 300 px span from −40 to 260 px: the top 40 px leave the box, which overflow: hidden auto clips, while 20 px stay free at the bottom. Measured identical at 320, 800 and 1,440 px wide — width changes nothing. Two fixes: center with top: 110px and cap at 4.6× (1 + 3.6 * t, 276 px), or keep 5× in a box of at least 300 px (height: 326px, top: 128px). The three scenes on this page center the target and size the box so the final slab fits whole.12 − 10t, hence 2 px at t = 1 — the Math.max(r, 0) never kicks in, the value never drops below 2. And since transform: scale() enlarges the box with its corners, the rendered radius is radius × scale: 12 px at rest, 10 px at 5×. As a share of the side, the rounding goes from 20% to 3.3% — that shift is what gives the slab impression, not a right angle. For true square corners, write 12 − 12t; for a constant on-screen rounding, 12 / e. Note also that the radius transition lasts 300 ms against 100 ms for the scale: after a PageDown, the corner is still at 6.2 px (31 px rendered) when the size has already settled, and finishes tightening 200 ms later.animation-timeline: scroll() animates scale(1) → scale(2.2) and border-radius: 20px → 4px on a 120 px box placed in the middle of an 800 px stage, not sticky despite its name and with an empty JavaScript field. Chrome 115+, Edge 115+, Safari 26; nothing on Firefox, which does not enable scroll-driven animations. fx-0580 does the math in JavaScript: a scroll listener, a variable t, a transform written on every event — it runs on Firefox 61+, the target stays pinned at 110 px while the stage scrolls behind it, the factor goes to 5× instead of 2.2 and the opacity drops to .8. Above all, t is a number the rest of the page can read: show a percentage, unlock a button, change a text — out of reach for a CSS animation.