What are CSS scroll effects?
The label covers any front-end technique where CSS properties alone change an element’s position, appearance or snapping behavior as the page scrolls. No JavaScript scroll listeners involved.
Some of these change how a scroll container behaves: position: sticky, scroll snap, scroll-behavior and overscroll-behavior. The other group is scroll-driven animations, where animation-timeline ties ordinary keyframes to scroll position instead of time.
What falls outside is any effect where a script reads the scroll position on every event and moves things by hand. A scroll listener written in JavaScript can fake a sticky header or parallax just fine, it simply doesn’t count here.
The Firefox Source Docs point out that scroll-linked effects built on listeners run on the main thread and lag behind asynchronous scrolling, and that a lot of them can be rewritten in CSS so the browser keeps them in step with the scroll instead.
So the rule of thumb is this. If the browser is driving the effect, it counts, and if your own script is, it doesn’t.
Which CSS properties create scroll effects?
Four of them work without any animation at all: scroll-behavior, scroll-snap-type, position: sticky and overscroll-behavior. Everything animated goes through animation-timeline.
| Property | Effect | JavaScript needed | Typical use |
|---|
| scroll-behavior: smooth | Animated jumps to anchors and scripted scrolls | No | In-page navigation |
| scroll-snap-type, scroll-snap-align | Scrolling stops at defined positions | No | Carousels, full-screen sections |
| position: sticky | Element pins at an offset | No | Headers, sidebars, table headings |
| overscroll-behavior | Blocks scroll chaining and bounce | No | Modals, chat panels |
| animation-timeline | Animation progress follows scroll | No | Progress bars, reveals, parallax |
Snapping and smooth scrolling
scroll-snap-type goes on the scroll container and takes an axis (x, y, block, inline or both) plus an optional strictness keyword, as MDN documents.
Set it to mandatory and the container has to settle on a snap position whenever scrolling stops. With proximity, the browser makes that call itself, depending on where the scroll ended.
scroll-snap-align goes on the children and accepts start, end, center or none. Under mandatory snapping, MDN warns that a child with the value none (or left unset) can never be scrolled into view.
scroll-behavior: smooth animates anchor jumps and scripted scrolls. It won’t smooth out a mouse wheel, which catches a surprising number of people.
Sticky positioning
A sticky element behaves like a relative one until the scroll reaches its offset, then it pins.
It needs an offset such as top: 0, because without one nothing sticks, and a scroll container to pin inside, usually the page itself. It also needs room in its parent, since the element stops at the parent’s edge.
That last rule trips people up. Put a sticky navigation bar inside a short wrapper and it releases the moment the wrapper ends. I’ve seen this eat a whole afternoon.
Overscroll and scroll chaining
The overscroll-behavior properties have worked in Chrome, Firefox and Safari since September 2022 (Safari 16). In April 2026, though, web-features, the data behind MDN’s Baseline labels, withdrew their Baseline status, because browsers disagree on scroll containers that have no overflow.
Set overscroll-behavior: contain on a modal body or chat panel and reaching its end no longer scrolls the page behind it. It also turns off pull-to-refresh, and the value none goes further by removing the overscroll glow or bounce as well.
How do scroll-driven animations work?
Instead of a clock, the animation reads scroll position. You write ordinary keyframes, then point animation-timeline at a scroll progress timeline or a view progress timeline.
The browser maps scroll distance to animation progress, so there is no listener to throttle and no frame loop to run.
Scroll progress timeline
Progress here means how far a scroll container has scrolled along one axis, 0% at the start and 100% at the end (Chrome for Developers).
The scroll() function builds one inline, and it takes a scroller and an axis. The default scroller is nearest, the closest ancestor scroll container. Use root when you want the document viewport, or self if the element itself scrolls.
The axis is block, inline, x or y, so animation-timeline: scroll(root block) is the standard form for a page-level progress bar. Declare it after the animation shorthand, because the shorthand resets the timeline.
To share one timeline between several elements, name it with scroll-timeline-name and a dashed identifier such as –hero-scroll, then reference that name in animation-timeline.
View progress timeline
Where the scroll version watches the whole scroller, a view progress timeline watches a single element.
The view() function links progress to the element’s position inside its scroll container, so the animation starts as the element enters and finishes as it leaves.
Every list item can carry its own view() timeline. Staggered reveals then need no delay math in script, and view-timeline-name works the same way scroll-timeline-name does when elements need to share one.
Controlling start and end with animation-range
animation-range chooses which slice of the timeline the animation covers.
MDN’s list of timeline range names runs through cover, contain, entry, exit, entry-crossing, exit-crossing and scroll. The table below covers the four that come up most.
| Range name | Starts when | Ends when |
|---|
| cover | Element begins to enter the scrollport | Element has fully left (the default) |
| contain | Element is fully contained by the scrollport, or fully covers it | Element is no longer fully contained by or fully covering the scrollport |
| entry | Element begins to enter | Element has fully entered |
| exit | Element begins to leave | Element has fully left |
Percentages inset a range. A form MDN lists, animation-range: entry 10% exit 100%, starts 10% into the entry phase and runs to the end of the exit phase.
For an element taller than the scrollport, contain still has something to track. It runs from the moment the element first completely covers the scrollport to the moment it no longer does.
How to build common scroll effects with CSS
Most real pages get by with a reading progress bar, a reveal on scroll, parallax layers and a shrinking sticky header. Each one sticks to a single timeline type and needs only a few lines of CSS.
Reading progress bar
Point the timeline at scroll(root block) and animate transform from scaleX(0) to scaleX(1), with transform-origin set to the left edge. Fix the bar to the top of the page.
The bar is a plain element, so any progress bar styled with CSS works as the base. Animate transform, not width, because width forces layout on every frame.
Reveal on scroll
- Write keyframes that run from opacity 0 and translateY(24px) to opacity 1 and translateY(0).
- Apply them with animation: reveal linear both, where the both fill mode keeps the start and end states.
- Add animation-timeline: view() after the shorthand.
- Limit the slice with animation-range: entry, so the reveal finishes as the element finishes entering.
- Wrap the whole rule in @supports (animation-timeline: view()).
Step 5 gets skipped a lot. Without it, a browser that ignores the timeline applies only half the effect, and the animation plays once on the normal clock at page load instead of following the scroll. Inside @supports, unsupported browsers just show plain, static content.
Parallax layers
Layers move at different speeds against the same scroll. A named scroll timeline makes that easy, since every layer shares one source of progress and only the translate distance in its keyframes differs.
For the design side of parallax scrolling, restraint wins. It is the pattern most likely to feel like too much, so one or two layers usually beat five. Layered setups you can copy are collected in these CSS parallax examples.
Sticky header and section scrubbing
A shrinking header starts with position: sticky and top: 0, plus a scroll() timeline that scales the logo and fades a shadow in. Section scrubbing works differently. A tall container holds a sticky child, and the container’s scroll progress changes that child (an image swap, a text highlight).
Shrink with transform and opacity. Animating height or padding drags layout back onto the main thread.
When is CSS enough, and when does a JavaScript library still win?
CSS handles progress bars, reveals, parallax, sticky headers and snapping without help. A library still earns its place when you need fine-grained scroll callbacks, pinned multi-step sequences or a scrolling feel the browser does not provide.
CSS animations triggered on scroll now cover the bulk of what libraries were installed for.
| Option | Runs on | Best for | Limit |
|---|
| Native CSS scroll-driven animations | Compositor thread for transform and opacity | Progress bars, reveals, parallax | No progress callbacks, needs recent browser versions |
| GSAP ScrollTrigger | JavaScript, main thread | Pinning, sequences, callbacks | Script weight |
| Lenis | JavaScript scroll layer | Custom scrolling feel | No native scroll-snap support |
| AOS | JavaScript plus CSS transitions | Class-based reveals | Reveals only |
GSAP ScrollTrigger
GSAP’s README states that every plugin, ScrollTrigger included, is free for commercial use, a change the README credits to Webflow. Cost is no longer the argument against it.
What it does well:
- Pinning and scrubbing across several timelines
- Callbacks when a trigger is entered or left
It also works in every browser. The downsides are that it runs in JavaScript, so its work shares the main thread with the rest of the page, and that it adds a dependency to a project that may not need one.
Lenis
Lenis replaces the browser’s scrolling feel with a scripted one, which no CSS property does.
Its README is upfront about the price. CSS scroll-snap isn’t supported (you have to use lenis/snap instead), and smooth scrolling stops working over iframes, since iframes do not forward wheel events.
In its favor are custom scroll inertia plus horizontal and nested scroll. Against it, the snap workaround and the iframe gaps.
AOS
Data attributes drive AOS, a small JavaScript library that triggers CSS animations as elements enter the viewport.
Setup takes minutes, and it works without view timelines. But it only reveals. It can’t scrub an animation along with scroll distance, and view() replaces it wherever support allows.
My own order of operations: start with CSS, bring in GSAP only when you need fine-grained scroll callbacks or pinned sequences, and reach for Lenis only when the product genuinely needs its own scrolling feel and you accept the snap workaround.
Which browsers support CSS scroll effects?
Chrome, Edge, Safari and Firefox now run scroll-driven animations without a flag. Firefox 159, released October 27, 2026, is the first stable Firefox to enable them by default.
Chrome for Developers puts Chrome and Edge at version 115. Safari shows up as version 26 in MDN’s compatibility data, introduced in the Safari 26 beta at WWDC25 (WebKit, June 2025).
MDN’s Firefox 159 release notes list scroll-driven animations as enabled by default, and MDN’s compatibility data now gives Firefox 159 as the first version. Earlier releases kept them behind the layout.css.scroll-driven-animations.enabled preference in about:config.
Scroll-driven animations were also named a focus area for Interop 2026, in an announcement dated February 12, 2026 (web.dev).
On release day, web-features, the dataset behind MDN’s Baseline badges, still listed scroll-driven animations as limited availability, so that badge can lag behind the browsers for a while.
Not every source agrees, though. One developer blog says Safari added support in 18.2 and Firefox shipped it in 133. MDN’s compatibility data and WebKit’s own WWDC25 material contradict both claims, with Safari at 26 and Firefox at 159.
Trust the primary data, then test anyway.
Firefox 158 and earlier, plus Safari before 26, are now the practical gap, so plan for cross-browser compatibility from the first line of CSS. Build each effect as an enhancement and test it in the browser you least expect to break.
How to ship scroll effects in browsers that lack support
Fallbacks stack. A feature test comes first, a polyfill after that, and an Intersection Observer reveal as the last option. Most pages never get past the first.
The feature test should check the exact value you use, such as @supports (animation-timeline: view()). A script can ask the same question with CSS.supports(‘animation-timeline’, ‘scroll()’) before it loads anything heavier.
The scroll-timeline polyfill on GitHub (flackr/scroll-timeline) implements ScrollTimeline and ViewTimeline, and it reads CSS animations that use scroll-timeline or view-timeline.
Its README does flag a limit. Stylesheets loaded from other origins may not be fetched and applied, because of browser security restrictions. And since it is JavaScript, the work lands back on the main thread.
Intersection Observer works differently. The API reports when a target crosses a threshold inside a root, and MDN notes the callback runs on the main thread. The default threshold is 0, so the callback fires as soon as a single pixel enters the viewport.
It reports entering and leaving, not progress. That makes it fine for a class toggle plus a CSS transition, and useless for scrubbing.
If the content only has to stay visible, the feature test alone is enough. Visitors on older browsers who need the same scrubbed effect call for the polyfill. And when an enter or leave toggle will do, Intersection Observer with a CSS transition is the lighter choice.
Start with the feature test. Add the others only when traffic from older browsers justifies the extra script.
Do CSS scroll effects stay smooth?
They stay smooth when the animation touches only compositor-friendly properties. transform and opacity skip layout and paint, so the compositor thread can run them even while the main thread is busy, and Chromium can composite filter animations too.
Stick to transform (translate, scale, rotate), opacity and, where it performs well, filter. Width and height, margin and padding, and top and left all stay bound to the main thread, so avoid animating them.
Lighthouse defines a non-composited animation as any animation that triggers the Style, Layout or Paint step of the rendering pipeline. Those animations can look janky on low-end phones or while heavy tasks occupy the main thread (Chrome for Developers).
The same documentation adds a second cost. Non-composited animations that move elements can raise Cumulative Layout Shift, while composited animations are excluded from it.
The Avoid non-composited animations audit lists every offending DOM node with the failure reasons Chrome reports. Run it after each new effect. A header that shrinks by animating height shows up immediately.
How to make scroll effects accessible
Wrap scroll-driven motion in @media (prefers-reduced-motion: no-preference), so only users who have not asked for less motion receive it. That media query reads the operating system setting, and MDN names scaling and panning large objects as common vestibular triggers.
Each effect carries a different risk, so handle them separately. Parallax should go entirely under reduce. Reveals and slides can swap translate and scale for a plain opacity fade. Progress bars can stay, since they convey position, and smooth scrolling should be reset to scroll-behavior: auto.
WCAG 2.2 still carries Success Criterion 2.3.3, Animation from Interactions. It sits at Level AAA, arrived in WCAG 2.1, and says motion animation triggered by interaction must be disableable unless the motion carries the function or the information.
W3C’s Understanding document singles out parallax scrolling as often non-essential. Reduced motion is one slice of web accessibility, but it is the slice scroll effects touch most.
Guidance isn’t uniform here either. MDN’s example tones a scaling animation down to a dissolve under reduce, while stricter guidance removes motion entirely. A workable split is fades for movement and removal for parallax.
Fallbacks need the same check. A polyfill or Intersection Observer reveal reads the setting with window.matchMedia(‘(prefers-reduced-motion: reduce)’).
Keyboard users need one more fix. A sticky header covers whatever receives focus unless the scroll container sets scroll-padding-top to the header height.
To test, emulate prefers-reduced-motion from the Rendering tools in Chrome DevTools, or switch Reduce Motion on in the operating system.
When do CSS scroll effects break or backfire?
Most failures are silent. The CSS parses, nothing throws, and the effect simply does not appear.
A sticky element that never pins usually has an ancestor with overflow set to hidden, auto or scroll. That ancestor becomes the scrolling context, so the element pins to it instead of the page. Remove the overflow, switch it to overflow: clip, or make that ancestor the scroller.
A progress bar that stays frozen is normally a scroll() problem. It resolves to the nearest ancestor scroll container, which can be a wrapper with overflow: auto rather than the page, and a container with nothing to scroll gives the timeline no progress. Use scroll(root) or name the timeline on the right element.
Mandatory snapping can feel like a trap. Full-page snapping turns browsing into a slide deck and frustrates anyone reading part of a section. Unless the layout really is a deck, proximity is the better default for usability.
Scroll-driven animations versus listener-based effects
The upsides are mostly about what you no longer have to maintain:
- No listener to throttle or clean up
- Compositor-thread execution for transform and opacity
- Declarative rules that read like any other CSS animation
- Static content remains if the browser ignores the timeline
On the downside:
- Support is recent in Firefox (159) and Safari (26), so older versions need a fallback
- It handles animation only, with no per-progress logic
- Range syntax takes practice before it feels natural
CSS Scroll Effects FAQ
What is the difference between scroll-padding and scroll-margin?
scroll-padding lives on the scroll container and shrinks the area where snapped or linked content lands. The target element is where scroll-margin goes, and it pushes that element’s snap edge outward.
For a fixed header, set scroll-padding-top once on the container instead of margins on every target.
Can CSS fire a callback when an effect finishes?
Only partly. A CSS animation driven by a scroll or view timeline dispatches the usual animationstart and animationend events, and the Web Animations API exposes finish events for the same animations. CSS itself has no callback for reaching a particular point or percentage of progress inside a timeline.
Logic that must run on completion or visibility, such as loading data or tracking whether an element is visible, still usually needs JavaScript, typically an Intersection Observer.
Can JavaScript drive the same scroll timelines?
Yes. The Web Animations API exposes ScrollTimeline and ViewTimeline, and either one can be passed as the timeline option of element.animate().
That suits effects whose keyframes are computed at runtime, while static effects stay simpler in CSS.
Is horizontal scrolling possible with CSS only?
Yes. Set overflow-x: auto on the container, add scroll-snap-type: x with scroll-snap-align on the children, and you have a carousel without a script.
To animate along it, use scroll(nearest inline) as the timeline.
Why does my scroll animation not play in Firefox?
Firefox enables scroll-driven animations by default only from version 159 (October 27, 2026). Earlier versions keep them behind the layout.css.scroll-driven-animations.enabled preference, so the animation-timeline declaration is ignored there.
Check the version first, and protect visitors on older releases with an @supports block.
Where to Start With CSS Scroll Effects
Build a reading progress bar first, then a view-timeline reveal, and add parallax last. That order runs from the effect that carries information to the one that carries the most motion.
- Progress bar on scroll(root block)
- Reveal on view() with animation-range: entry
- Parallax layers, removed under reduced motion
To verify, run Chrome, Safari 26, Firefox 159 and one older Firefox, and emulate prefers-reduced-motion in each. The older Firefox run matters most, because it shows the static page that visitors who have not updated yet receive.
The trade-off is that visitors on older browsers get the static page, not the effect. That group shrinks as Firefox 159 rolls out, and MDN’s compatibility data checked on October 27, 2026 lists Firefox 159 next to Chrome 115 and Safari 26.
The progress bar is the quickest first build, and a guide to a CSS progress bar covers the element’s styling before any timeline is attached.