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() or steps() 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-transition pseudo-elements.
  • Adding scroll-snap-type to a container caused an otherwise-working animation to skip frames.

They are not the right tool when:

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.

Easing curve sampling: time-based vs scroll-based Two side-by-side graphs. Left graph shows a cubic-bezier curve sampled at regular time intervals producing smooth intermediate values. Right graph shows the same curve sampled at irregular scroll offsets β€” fast scroll collapses many samples into a few frames, losing the easing shape. Time-based animation Regular samples β†’ smooth easing Time β†’ Progress Scroll-based animation Fast scroll β†’ few samples β†’ easing lost Scroll offset β†’ Progress only 3 frames sampled easing curve flattens

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:

  1. DevTools β†’ Performance β†’ start recording.
  2. Scroll through the animated section at a moderate pace.
  3. Stop recording. In the Main thread lane, look for long tasks (orange bars exceeding 50 ms).
  4. 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:

  1. 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.
  2. 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.
  3. Programmatic inspection β€” cross-reference currentTime against scrollY to 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.