Debugging Scroll Animation Timing Functions
Scroll-driven animations map scroll position β not elapsed time β to animation progress, which changes how animation-timing-function behaves in ways that catch developers off guard. This page targets four specific failure modes β thread contention, range clipping, framework overrides, and scroll-snap interference β all of which manifest as easing that snaps, skips, or stalls. For background on how the browser processes scroll-driven animations end-to-end, see how the rendering pipeline handles scroll animations and the broader Core Animation Fundamentals reference.
When to Use This Approach
The techniques on this page apply when:
- A
cubic-bezier()orsteps()timing function produces unexpected snap or jump behavior on a scroll-driven animation. - The animation plays correctly when tested via DevTools scrubbing but jitters at runtime during actual scroll.
- A framework update broke previously smooth easing on
::view-transitionpseudo-elements. - Adding
scroll-snap-typeto a container caused an otherwise-working animation to skip frames.
They are not the right tool when:
- The animation does not progress at all β that is a timeline scoping or
animation-rangetargeting problem, not a timing function issue. - Jank appears on every animation regardless of timing function β that points to a missing
will-changedeclaration or a non-compositor property; check compositor-safe properties and will-change guidance first. - You need to support Safari 15 or earlier β
animation-timelineis unsupported; consult the progressive enhancement and browser support strategies page instead.
Why Timing Functions Behave Differently Under Scroll
The diagram below illustrates the core mismatch: a time-based animation samples the easing curve at regular wall-clock intervals; a scroll-driven animation samples it at each scroll offset delivered by the userβs input device.
In a time-based animation the browser samples the easing curve at regular 16ms intervals; the user sees a smooth build-up and slow-down. In a scroll-driven animation, samples are taken at each scroll offset the compositor delivers. Scroll fast and only a few samples fall inside the animation-range β the curveβs acceleration and deceleration phases collapse into one or two frames and appear to snap.
This is not a browser bug; it is fundamental to how scroll offset maps to animation progress. The reliable fix is to use linear timing on the animation itself and encode the desired curve shape into the keyframe percentages:
@keyframes scroll-fade {
0% { opacity: 0; transform: translateY(20px); }
25% { opacity: 0.1; transform: translateY(15px); } /* slow start */
50% { opacity: 0.4; transform: translateY(8px); }
75% { opacity: 0.8; transform: translateY(3px); } /* fast middle */
100% { opacity: 1; transform: translateY(0); }
}
.element {
animation: scroll-fade linear both;
animation-timeline: scroll(root);
animation-range: entry 0% cover 50%;
will-change: transform, opacity;
}
Because the easing is baked into the keyframe offsets rather than sampled by the timing engine, the shape survives even a single-frame jump through the range.
Implementation
Step 1 β Identify the failure mode
Open DevTools β Animations panel and scrub the animation manually. If the animation looks correct when scrubbed but jitters at runtime, the easing shape is fine; scroll velocity sampling is the cause (see Step 2). If the animation looks wrong even when scrubbed, the animation-timing-function itself is misconfigured or overridden (see Steps 3 and 5).
Step 2 β Replace timing-function easing with keyframe-stop easing
For any animation where scroll velocity may vary, switch to linear timing and redistribute keyframe stops to approximate the desired curve:
/* Before: easing relies on the timing function sampler */
.card {
animation: reveal cubic-bezier(0.4, 0, 0.2, 1);
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
/* After: easing encoded in keyframe stops, timing function is irrelevant */
@keyframes reveal {
0% { opacity: 0; transform: scale(0.96); }
20% { opacity: 0.05; transform: scale(0.965); } /* cubic ease-in approximation */
40% { opacity: 0.2; transform: scale(0.974); }
60% { opacity: 0.5; transform: scale(0.984); }
80% { opacity: 0.82; transform: scale(0.993); }
100% { opacity: 1; transform: scale(1); }
}
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
Step 3 β Profile for main-thread thread contention
Compositor thread isolation is what lets scroll-driven animations run without touching the main thread. When the main thread stalls, the compositor receives irregular scroll deltas, which causes timing function jitter even with linear easing.
To diagnose:
- DevTools β Performance β start recording.
- Scroll through the animated section at a moderate pace.
- Stop recording. In the Main thread lane, look for long tasks (orange bars exceeding 50 ms).
- Cross-reference those timestamps against the Compositor lane and the Frames lane β if frame drops occur exactly during long tasks, the main thread is starving the compositor.
Common culprits and fixes:
// Bad: synchronous layout read during scroll
document.addEventListener('scroll', () => {
const h = element.getBoundingClientRect().height; // forces layout
element.style.setProperty('--h', h + 'px'); // then writes
});
// Fix: read once, not per scroll event
const h = element.getBoundingClientRect().height;
element.style.setProperty('--h', h + 'px');
// Or defer non-critical hydration
requestIdleCallback(() => hydrateSecondaryComponents());
Step 4 β Widen the range for short animation-range spans
If animation-range covers a very short scroll distance, even linear timing will produce a visible snap because the full progress must resolve within one or two compositor frames. A cubic-bezier easing on a 40 px scroll range on a 2000 px page is functionally invisible regardless of its curve shape.
/* Too short: snaps on any real scroll velocity */
.element {
animation-range: entry 0% entry 5%; /* ~40 px on an 800 px viewport */
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Better: widen the range to give the curve room to resolve */
.element {
animation-range: entry 0% cover 40%;
animation-timing-function: linear; /* or keyframe-stop easing */
}
The animation-range entry and exit keyword reference explains how entry, exit, cover, and contain map to viewport thresholds.
Step 5 β Fix framework stylesheet overrides on view-transition pseudo-elements
React, Vue, and Svelte global CSS resets sometimes set animation: none or animation-timing-function: initial on ::view-transition-image-pair or ::view-transition-group, silently breaking the browserβs built-in crossfade easing. This is separate from scroll timeline easing but produces identical-looking snap behavior on route changes.
Check in DevTools β Elements β Computed: search for animation-timing-function on ::view-transition-old(root) and ::view-transition-new(root). If the value is ease when you expected a custom curve, a stylesheet is resetting it.
Fix by explicitly declaring timing on the pseudo-elements after the reset:
/* Restore timing explicitly β place after framework global styles */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.55s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-fill-mode: both;
}
/* For named transitions, target individually */
::view-transition-old(hero),
::view-transition-new(hero) {
animation-duration: 0.35s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
Step 6 β Decouple scroll-snap from the animation timeline container
scroll-snap-type forces the browser to override natural scroll velocity by snapping the scroll position to alignment points. This compresses animation-range progression: the scroll position jumps, bypassing the intermediate easing samples in a single frame.
Isolate whether scroll-snap is the cause by temporarily removing scroll-snap-type and re-testing. If the jitter disappears, use one of these patterns:
/* Pattern A: widen animation-range to span multiple snap points */
.element {
/* snap points at every 100vh; range spans three of them */
animation-range: entry 0% exit 300vh;
animation-timing-function: linear;
}
/* Pattern B: separate snap container from timeline container */
.scroll-container {
overflow-y: scroll;
/* No scroll-snap-type here β this is the timeline source */
}
.snap-layer {
scroll-snap-type: y mandatory;
/* Snap applies to this inner layer, not the timeline source */
}
.element {
animation-timeline: scroll(.scroll-container block);
}
Verification
After each fix, confirm with both DevTools scrubbing and live scroll:
- Animations panel scrub β drag the playhead across the full range. The keyframe curve shape should match your intent; if using keyframe-stop easing the curve should appear as a series of straight segments approximating the target shape.
- Performance recording β record a scroll session. The Frames lane should show a consistent cadence with no dropped frames during the animation range. Long tasks in Main must not overlap frame drops.
- Programmatic inspection β cross-reference
currentTimeagainstscrollYto detect interpolation gaps:
// Log progress vs scroll position for each scroll-driven animation
const animations = document.getAnimations()
.filter(a => a.timeline && a.timeline.constructor.name !== 'DocumentTimeline');
animations.forEach(a => {
console.log({
easing: a.effect.getTiming().easing,
currentTime: a.currentTime, // 0β1 for scroll-driven (as a fraction)
scrollY: window.scrollY,
target: a.effect.target.className
});
});
Positions where currentTime jumps by more than 0.1 between consecutive scroll events indicate a sampling gap β widen animation-range or switch to keyframe-stop easing.
Edge Cases and Gotchas
steps() with scroll produces intentional jumps β do not mistake this for a bug. steps(5, end) on a scroll-driven animation creates 5 discrete states, each covering 20% of the scroll range. This is correct and can be useful for progress indicators. The issue arises only when developers expect steps() to feel smooth.
animation-fill-mode: both interacts with range clipping. If animation-fill-mode is absent or none, the animation snaps to its from state immediately before animation-range starts and snaps out immediately after it ends. This looks like a timing function problem but is a fill-mode problem. Add both to all scroll-driven animations:
.element {
animation: reveal linear both; /* 'both' is the third value */
animation-timeline: view();
}
Nested scroll containers scope the timeline. scroll(root) references the document scroll; scroll() without arguments references the nearest scrollable ancestor. If your animation lives inside a overflow: auto container but you intended to track page scroll, the wrong timeline is attached. Inspect a.timeline in the console to confirm the source element.
@media (prefers-reduced-motion) guards must wrap the entire animation declaration. Placing the guard only around the timing function leaves the animation active with a different easing but still moving, which can trigger vestibular symptoms. See implementing prefers-reduced-motion safely for the correct guard pattern.
DevTools Animations panel may show a DocumentTimeline even for scroll-driven animations in some browser versions. If a.timeline.constructor.name returns 'DocumentTimeline' at runtime but the animation uses animation-timeline: scroll(), check for a CSS specificity conflict that is overriding the animation-timeline property.
Browser-Specific Notes
Chrome 115+ β full support for animation-timeline, scroll(), and view(). Compositor promotion for scroll-driven animations is automatic when animating only transform and opacity with will-change set. Without will-change, Chrome may fall back to main-thread animation for the first frame.
Firefox 110+ β animation-timeline shipped behind a flag; production support arrived in Firefox 110. The Animations panel in Firefox DevTools does not yet display scroll-driven animation progress as a percentage β use a.currentTime in the console instead.
Safari 18+ β animation-timeline with scroll() and view() shipped in Safari 18 (macOS Sequoia, iOS 18). Earlier versions require the scroll-timeline polyfill. Safariβs compositor promotion heuristics differ slightly from Chromeβs: will-change: transform must be declared explicitly even when animating transform; without it Safari may paint on the main thread and exhibit the timing jitter described in the Thread Contention section above.
Related
- The Rendering Pipeline for Scroll Animations β parent page: compositor thread, layer promotion, and the 16 ms frame budget
- When to Use CSS Animations Over JavaScript Libraries β decision criteria comparing native CSS scroll timelines against JS-based alternatives
- How to Polyfill scroll-timeline for Safari β browser compatibility workarounds including timing function caveats
- CSS Scroll-Driven Animations vs IntersectionObserver β when to reach for each approach and their respective easing capabilities
- Implementing
prefers-reduced-motionβ guard patterns that disable animation timing without leaving the animation partially active