CSS-driven scroll effects
Add JS once. Control everything through CSS.
One CSS variable. Unlimited effects. All logic lives in CSS.
Effect 01 — Parallax
Three layers, three --speed values. Mobile override in pure @media.
.layer-1 { --speed: 0.05; }
.layer-2 { --speed: 0.15; }
.layer-3 { --speed: 0.30; }
.layer {
transform: translateY(calc(var(--scroll-offset) * var(--speed) * 1px));
transition: transform 0.12s ease-out;
}
Effect 02 — Fade
abs() makes the effect symmetrical — fades both on enter and exit.
.card {
opacity: clamp(0, calc(1 - abs(var(--scroll-offset)) / 380), 1);
transform: translateY(calc(var(--scroll-offset) * 0.04px));
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
All effect logic lives in CSS.
Zero dependencies, single file.
Override any variable in media queries.
Effect 03 — Hue rotate
Not just transforms. filter, background, clip-path — anything that
accepts a number.
.gradient {
filter: hue-rotate(
calc(var(--scroll-offset) / 2 * 1deg)
);
transition: filter 0.15s ease-out;
}
Effect 04 — Scale
One variable, two effects. Odd items float up, even float down.
.item {
scale: clamp(0.4, calc(1 - abs(var(--scroll-offset)) / 500), 1.15);
transition: scale 0.15s ease-out;
}
Effect 05 — 3D Rotate
Scroll offset drives both rotateY and rotateX. Parent needs
perspective.
.card {
transform:
rotateY(calc(var(--scroll-offset) / 8 * 1deg))
rotateX(calc(var(--scroll-offset) / -12 * 1deg));
transition: transform 0.15s ease-out;
}
.scene { perspective: 900px; }
Effect 06 — Clip-path reveal
clip-path: inset() driven by abs(--scroll-offset) — fully visible only at viewport
center.
.bar {
clip-path: inset(0
calc(clamp(0%, (abs(var(--scroll-offset)) - 60) / 3 * 1%, 100%))
0 0 round 12px);
transition: clip-path 0.15s ease-out;
}
Effect 07 — Progress ring
stroke-dashoffset on an SVG circle — fills to 100% when element is centered. Works with CSS
or driven via JS update.
/* 2π × r = 691 for r=110 */
.ring {
stroke-dasharray: 691;
stroke-dashoffset: clamp(0px,
calc(691px * (1 - clamp(0,
1 - abs(var(--scroll-offset)) / 320,
1))), 691px);
transition: stroke-dashoffset 0.2s ease-out;
}
Effect 08 — Blur focus
Items are blurry when away from viewport center, and come into focus as they approach it.
.item {
filter: blur(clamp(0px, abs(var(--scroll-offset)) / 40 * 1px, 12px));
opacity: clamp(0.15, calc(1 - abs(var(--scroll-offset)) / 350), 1);
transition: filter 0.2s ease-out, opacity 0.2s ease-out;
}
Effect 09 — Letter spacing
Letters spread apart as the element scrolls away from center. Combine with opacity for a
cinematic feel.
Effect 10 — Border radius morph
border-radius driven by abs(--scroll-offset) — perfectly square at center, circle at
edges.
.item {
border-radius: clamp(12px, abs(var(--scroll-offset)) / 5 * 1px, 50%);
transition: border-radius 0.2s ease-out;
}
Effect 11 — Skew
Odd bands skew right, even skew left — created with :nth-child(even) negating the same variable.
.band { transform: skewX(calc(var(--scroll-offset) / 20 * 1deg)); }
.band:nth-child(even) { transform: skewX(calc(var(--scroll-offset) / -20 * 1deg)); }
Effect 12 — Box shadow depth
Shadow grows as the element reaches viewport center — like a card physically rising off the page.
.card {
box-shadow: 0
calc((1 - abs(var(--scroll-offset)) / 280) * 40px)
calc((1 - abs(var(--scroll-offset)) / 280) * 80px)
rgba(108,92,231, calc((1 - abs(var(--scroll-offset)) / 280) * 0.5));
transition: box-shadow 0.2s ease-out, transform 0.2s ease-out;
}
Deep shadow at center
Elevates on approach
Falls back at edges
Effect 13 — Color temperature
HSL hue shifts from 220° (cool blue) through 120° (green) to
30° (warm orange) as the element scrolls.
.swatch {
background: hsl(
calc(220deg - clamp(-100deg,
var(--scroll-offset) / 4 * 1deg,
100deg)),
80%, 55%
);
transition: background 0.2s ease-out;
}
Effect 14 — Progress bars
Bars fill to 100% exactly when the element center hits the viewport center — great for skill bars or step indicators.
.fill {
width: clamp(0%,
calc((1 - abs(var(--scroll-offset)) / 320) * 100%),
100%);
transition: width 0.2s ease-out;
}
Effect 15 — Background zoom
background-size changes with scroll — the grid zooms in or out. Works for any repeating
pattern: dots, lines, checkerboard.
.grid {
background-image:
repeating-linear-gradient(0deg, ...),
repeating-linear-gradient(90deg, ...);
background-size:
calc(40px + clamp(0px, abs(var(--scroll-offset)) / 8 * 1px, 80px))
calc(40px + clamp(0px, abs(var(--scroll-offset)) / 8 * 1px, 80px));
transition: background-size 0.2s ease-out;
}
Effect 16 — translateX
Elements drift left or right. Odd cards go right, even go left — one variable, opposite directions via sign inversion.
Effect 17 — rotate (Z axis)
Each dial rotates at a different speed. Scroll direction maps directly to clockwise / counter-clockwise rotation.
Effect 18 — translateZ + perspective
Cards at different Z depths — the closer the card, the faster it approaches. Pure CSS perspective.
.tz-scene { perspective: 800px; }
.back { transform: translateZ(calc(var(--scroll-offset) * -0.2px)); }
.mid { transform: translateZ(calc(var(--scroll-offset) * -0.5px)); }
.front { transform: translateZ(calc(var(--scroll-offset) * -0.9px)); }
Effect 19 — skewY
skewY creates a paper-fold illusion. Combined with opacity it looks like pages turning.
Effects 20–24 — Five filters in one section
All five driven by the same variable. Each filter responds to abs(--scroll-offset) — maximum
effect at the edges, neutral at center.
Effects 25–26 — invert · drop-shadow
invert() flips colors — 0% at center, 100% at edges. drop-shadow() blur radius grows
on approach.
Effects 27–28 — text color · border color
HSL hue shifts on both color and border-color. The hue angle is calculated directly
from --scroll-offset.
Effect 29 — text-shadow
Blur radius of text-shadow shrinks to 0 at center — the text sharpens and glows as it reaches
focus.
Effect 30 — font-size
Font size peaks at viewport center and shrinks toward the edges — like a word physically stepping into a spotlight.
Effect 31 — word-spacing
word-spacing at its natural value at center, expands toward the edges — a typographic stretch
effect.
Design driven by CSS
One variable rules all
Scroll to transform
Effect 32 — line-height
line-height is tight at the edges, relaxed at center. Feels like the text is breathing.
One CSS variable.
Unlimited effects.
No config in JS.
All logic in CSS.
Effect 33 — clip-path circle()
Circular mask grows from 0 to 100% as element approaches center. Different from inset() —
grows radially from the middle.
.box {
clip-path: circle(
clamp(0%, calc(
(1 - abs(var(--scroll-offset)) / 320) * 80%
), 80%)
);
transition: clip-path 0.2s ease-out;
}
Effect 34 — clip-path polygon
Polygon vertices are driven by --scroll-offset. At center: square. At edges: star with inward
points.
.star {
clip-path: polygon(
50% calc(clamp(0%, abs(var(--scroll-offset))/5*1%, 50%)),
100% 50%, 50% 100%, 0% 50%
);
transition: clip-path 0.2s ease-out;
}
Effect 35 — outline-offset
outline-offset expands away from the element at the edges and collapses to zero at center — a
radar-ping feel.
Effect 36 — border-width
border-width peaks when element is at viewport center. The thickest border = perfect position
indicator.
Effect 37 — stroke-width (SVG)
SVG stroke-width driven by CSS variable — concentric rings, each at different breath rate.
circle {
stroke-width: clamp(1px,
calc((1 - abs(var(--scroll-offset)) / 300) * 20px),
20px);
transition: stroke-width 0.2s ease-out;
}
Effect 38 — SVG fill + opacity
CSS custom properties work on SVG fill and opacity just like HTML elements.
Effect 39 — background-position
The background image moves at a slower rate than the element — classic parallax without any extra DOM elements.
.el {
background-position:
50%
calc(50% + var(--scroll-offset) * 0.3px);
transition: background-position 0.2s ease-out;
}
Effect 40 — stagger (nth-child delay)
One ScrollVar instance, one variable. The stagger is pure CSS — transition-delay via
:nth-child.
.item:nth-child(1) { transition-delay: 0ms; }
.item:nth-child(2) { transition-delay: 60ms; }
.item:nth-child(3) { transition-delay: 120ms; }
Effect 41 — backdrop-filter blur
The glass panel's blur radius responds to scroll — clear at center, fully frosted at the edges.
.glass {
backdrop-filter: blur(
clamp(0px,
abs(var(--scroll-offset)) / 15 * 1px,
24px)
);
transition: backdrop-filter 0.2s ease-out;
}
Effect 42 — multi-transform stack
All three transforms applied simultaneously from one variable. The element orbits into position as it reaches center.
Effect 43 — perspective squish
Cards shrink to nothing at edges and spring to full size at center — the scaleX/scaleY
split creates a squash effect.
Effect 44 — outline-color
outline-color cycles through the spectrum as the element scrolls — like a chromatic aberration
ring around focused UI elements.
Effect 45 — scaleX / scaleY split
When scaleX grows, scaleY shrinks proportionally — the cartoon squash-and-stretch illusion. Preserves area while deforming shape.
Effect 46 — conic-gradient rotation
hue-rotate on a conic gradient — the color wheel spins as you scroll. The starting angle maps
to --scroll-offset.
.wheel {
background: conic-gradient(
from calc(var(--scroll-offset) * 0.5deg),
#6c5ce7, #fd79a8, #fdcb6e,
#00b894, #6c5ce7
);
transition: background 0.15s ease-out;
}
Effect 47 — text-indent
text-indent slides text in from the left. Combined with opacity it looks like a
title card appearing on screen.
Effect 48 — translateY stagger wave
Each item has the same variable but a different transition-delay — the wave cascades across the
row purely in CSS.
Effect 49 — composite (6 effects in 1)
One element. One variable. Six simultaneous CSS effects: translate, rotate, scale, blur, hue-rotate, opacity.
.card {
transform:
translateY(calc(var(--scroll-offset) * 0.3px))
rotate(calc(var(--scroll-offset) / 10 * 1deg))
scale(clamp(0.3, calc(1 - abs(var(--scroll-offset))/400), 1.1));
filter:
blur(clamp(0px, abs(var(--scroll-offset))/30*1px, 8px))
hue-rotate(calc(var(--scroll-offset) * 0.5deg));
opacity: clamp(0.1, calc(1 - abs(var(--scroll-offset))/400), 1);
}
Effect 50 — all-in-one showcase strip
Each tile uses a different CSS property. All driven by a single new ScrollVar('.showcase-tile').
How it works
ScrollVar writes (elementCenter − viewportCenter) in pixels to a CSS variable on every scroll
frame.
new ScrollVar('.hero');
.hero {
--speed: 0.15;
transform: translateY(
calc(var(--scroll-offset)
* var(--speed) * 1px)
);
}
/* negative → element below center */
/* zero → element at center */
/* positive → element above center */
elementCenter − viewportCenter
<section
data-scroll-var="--hero-offset"
></section>
new ScrollVar('[data-scroll-var]');
Get started
copy scrollvar.js to your project
import ScrollVar from './scrollvar.js'