How to Polyfill scroll-timeline for Safari

Safari 17.4 (March 2024) added native support for CSS Scroll-Driven Animations, but all Safari 15 and 16 releases β€” and any iOS browser on those WebKit versions β€” require a JavaScript fallback. This page details a production-grade strategy: strict feature detection, a requestAnimationFrame-based progress mapper that writes a single CSS custom property, SPA memory-leak prevention via cleanup callbacks, and view transition conflict handling. For the foundational understanding of the native scroll() and view() timeline functions, see Understanding the CSS Scroll-Timeline API.

Polyfill decision flow A flowchart showing: browser loads page, feature detection checks CSS.supports animation-timeline scroll(), if yes run native scroll-timeline, if no check prefers-reduced-motion, if reduce apply static fallback, otherwise load rAF polyfill and write --scroll-progress custom property. Page loads CSS.supports('animation-timeline','scroll()')? Native timeline βœ“ prefers-reduced-motion = reduce? Load rAF polyfill β†’ --scroll-progress yes no no-preference

When to use this approach

Use the requestAnimationFrame + CSS custom property polyfill when:

  • Your analytics show meaningful Safari < 17.4 traffic (iOS users on older OS versions are common).
  • The animation is sufficiently meaningful that a static fallback would harm UX, not just aesthetics.
  • You cannot accept the file-size cost of a full WAAPI-level polyfill (the pattern below is under 1 KB minified).
  • You need the fallback to work with the same CSS that drives the native animation (single source of truth via --scroll-progress).

Prefer a static fallback instead when:

  • The animation is purely decorative and @supports not (animation-timeline: scroll()) can safely show the element at its final state.
  • Your Safari < 17.4 share is under 2% and the engineering cost of maintaining a polyfill is not justified.
  • The element already has a clear, complete static reading state that needs no animation to be understood.

The progressive enhancement patterns in @supports gating should always be your first line of defense β€” only reach for a rAF polyfill when the static fallback is genuinely insufficient.

Implementation

Step 1 β€” Feature detection

Test both CSS.supports and the ScrollTimeline constructor. Some third-party polyfills inject window.ScrollTimeline without setting the CSS property, so testing both avoids double-loading:

const supportsScrollTimeline =
  CSS.supports('animation-timeline', 'scroll()') ||
  typeof window.ScrollTimeline !== 'undefined';

Gate the polyfill load behind both feature detection and a prefers-reduced-motion check. Users who have requested reduced motion should receive a static layout β€” no animation at all β€” so the rAF loop must not start for them:

if (!supportsScrollTimeline) {
  if (window.matchMedia('(prefers-reduced-motion: no-preference)').matches) {
    import('./scroll-timeline-polyfill.js').then(({ initPolyfill }) => {
      initPolyfill();
    });
  }
  // Reduced-motion users: static fallback only, no loop
}

Dynamic import() keeps the polyfill out of the critical rendering path. The browser parses and compiles it only when needed, preserving FCP and LCP metrics. The fallback strategies guide covers how to combine this with @supports CSS guards for a layered approach.

Step 2 β€” rAF-synced progress mapping

Map the document’s scroll progress to a single CSS custom property --scroll-progress on the scroll container. All animated CSS reads from that variable β€” the polyfill only ever makes one style write per frame and only when the value has changed meaningfully:

const scrollContainer = document.documentElement;
let rafId = null;
let lastProgress = -1;

function updateProgress() {
  const scrollTop = scrollContainer.scrollTop;
  const scrollHeight =
    scrollContainer.scrollHeight - scrollContainer.clientHeight;
  const rawProgress = scrollHeight > 0 ? scrollTop / scrollHeight : 0;

  // Skip writes when the value has not changed by more than 0.1%
  // β€” avoids redundant style recalcs on sub-pixel scroll deltas
  if (Math.abs(rawProgress - lastProgress) > 0.001) {
    lastProgress = rawProgress;
    scrollContainer.style.setProperty(
      '--scroll-progress',
      rawProgress.toFixed(4)
    );
  }

  rafId = requestAnimationFrame(updateProgress);
}

// Start the loop only on first scroll; avoid running on idle pages
scrollContainer.addEventListener('scroll', () => {
  if (!rafId) rafId = requestAnimationFrame(updateProgress);
}, { passive: true });

// Stop cleanly after momentum deceleration completes
scrollContainer.addEventListener('scrollend', () => {
  cancelAnimationFrame(rafId);
  rafId = null;
  lastProgress = -1;
});

The CSS that consumes the polyfill uses the same variable as a native @property-declared fallback would. Native browsers use their compositor path; Safari < 17.4 uses the custom property:

@supports not (animation-timeline: scroll()) {
  .scroll-driven {
    opacity: calc(1 - var(--scroll-progress, 0));
    transform: translateY(calc(var(--scroll-progress, 0) * -100px));
    will-change: transform, opacity;
  }
}

will-change: transform, opacity promotes the element to a compositor layer. Even in the polyfill path, the actual interpolation from the custom property can run on the compositor, keeping the main thread clear.

Step 3 β€” Framework integration with cleanup

In any component-based framework, attach the polyfill inside the mount lifecycle and always clean up on unmount. The cleanup function is non-negotiable: without cancelAnimationFrame, the rAF loop outlives the component and accumulates orphaned frames across every route transition in a single-page application.

React hook:

import { useEffect } from 'react';

export function useScrollTimelinePolyfill(containerRef) {
  useEffect(() => {
    const container = containerRef.current;
    if (!container || supportsScrollTimeline) return;

    let rafId = null;
    let lastProgress = -1;

    function tick() {
      const scrollTop = container.scrollTop;
      const maxScroll = container.scrollHeight - container.clientHeight;
      const progress = maxScroll > 0 ? scrollTop / maxScroll : 0;
      if (Math.abs(progress - lastProgress) > 0.001) {
        lastProgress = progress;
        container.style.setProperty('--scroll-progress', progress.toFixed(4));
      }
      rafId = requestAnimationFrame(tick);
    }

    const onScroll = () => { if (!rafId) rafId = requestAnimationFrame(tick); };
    const onScrollEnd = () => { cancelAnimationFrame(rafId); rafId = null; };

    container.addEventListener('scroll', onScroll, { passive: true });
    container.addEventListener('scrollend', onScrollEnd);

    // Critical: cancel on unmount β€” prevents SPA navigation memory leak
    return () => {
      cancelAnimationFrame(rafId);
      container.removeEventListener('scroll', onScroll);
      container.removeEventListener('scrollend', onScrollEnd);
      container.style.removeProperty('--scroll-progress');
    };
  }, [containerRef]);
}

Add a ResizeObserver to invalidate cached bounds when dynamic content changes the container height β€” for example, when lazy-loaded images settle:

const resizeObserver = new ResizeObserver(() => {
  lastProgress = -1; // force recalculation on next scroll event
});
resizeObserver.observe(container);
// In cleanup: resizeObserver.disconnect();

Step 4 β€” View transition conflict handling

When the View Transitions API fires, it snapshots the current DOM state. If the rAF loop is writing --scroll-progress during that snapshot, conflicting transform values can corrupt the exit or entry animation. Pause the loop during the transition and defer reattachment until the browser is idle:

let isTransitioning = false;

async function handleRouteTransition() {
  if (!document.startViewTransition) {
    return updateDOM(); // no view transitions: just update
  }

  isTransitioning = true;
  cancelAnimationFrame(rafId);
  rafId = null;

  try {
    await document.startViewTransition(() => updateDOM()).finished;
  } finally {
    // Defer reattachment until after the transition completes
    requestIdleCallback(() => {
      isTransitioning = false;
      lastProgress = -1;
    });
  }
}

The isTransitioning flag prevents the scroll event listener from restarting the rAF loop during the snapshot phase. Check it at the top of the scroll handler:

container.addEventListener('scroll', () => {
  if (!rafId && !isTransitioning) rafId = requestAnimationFrame(tick);
}, { passive: true });

Verification

After deploying, confirm the polyfill behaves correctly in Safari Web Inspector:

  1. Timelines β†’ Layout & Rendering β€” record a scroll session. A well-written rAF polyfill triggers zero Layout events from the polyfill itself. Only the CSS transform composite step should appear. If you see Layout events, you have a property write triggering box-model recalculation β€” check that only transform and opacity are animated.
  2. JavaScript & Events β€” confirm rAF callbacks stay under 4 ms per frame. Anything above that competes with the 16 ms frame budget.
  3. Layers β†’ Show Compositing Borders β€” verify will-change: transform, opacity elements have a green promoted-layer badge.
  4. Elements β†’ Computed β€” inspect --scroll-progress directly. It should update to four decimal places on each scroll tick and stop updating after scrollend fires.
  5. Lighthouse (mobile, 4Γ— CPU throttle) β€” confirm no CLS regression from custom property writes, and that INP stays under 200 ms on polyfilled pages.

Edge cases and gotchas

iOS momentum scrolling and scrollend scrollend fires after the momentum deceleration phase completes, not at the moment the user lifts their finger. During deceleration, scrollTop continues updating normally, so the rAF loop stays accurate. The key risk is forgetting to cancel the loop: without the scrollend handler, the loop fires on every rAF tick even while the page is at rest, wasting ~0.3 ms per frame.

scrollHeight reading during layout shifts If the page has a layout shift (CLS event) β€” for example, a late-loading banner shifts content down β€” scrollHeight changes mid-session. The ResizeObserver on the scroll container handles this by resetting lastProgress to -1 on the next resize, forcing a recalculation before the next write. Without this, the progress value drifts and the animation overshoots.

Custom scroll containers vs document.documentElement The polyfill above targets document.documentElement (the root scroller). If your page has a custom scroll container (an overflow-y: auto div, a modal, or a split-pane layout), you must attach the polyfill to that element’s scrollTop instead. A common mistake is reading from window.scrollY while the actual scroller is a child element β€” window.scrollY stays zero, --scroll-progress never updates.

Conflict with third-party polyfill libraries If you load a full WAAPI/ScrollTimeline polyfill (such as the one from @web-animations/web-animations-js) alongside this rAF approach, both may attempt to control the same animated element. Guard the rAF polyfill with the typeof window.ScrollTimeline check in Step 1 to short-circuit if a heavier polyfill has already injected the constructor.

prefers-reduced-motion toggled at runtime Safari respects OS-level motion settings, but those can change while the page is open. If the user enables reduced motion in System Preferences mid-session, the rAF loop should stop. Listen for changes with:

window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
  if (e.matches) {
    cancelAnimationFrame(rafId);
    rafId = null;
    scrollContainer.style.removeProperty('--scroll-progress');
  }
});

Browser-specific notes

Safari 17.4 and later No polyfill needed. CSS.supports('animation-timeline', 'scroll()') returns true and the native compositor path runs. Confirm in Safari Web Inspector under Elements β†’ Computed that animation-timeline shows a ScrollTimeline object rather than auto.

Safari 15 and 16 (the target case) These versions do not support animation-timeline, ScrollTimeline, or @scroll-timeline. The rAF polyfill is the only viable approach short of shipping a full WAAPI polyfill. The scrollend event is available from Safari 16.4. For Safari 15, replace the scrollend handler with a debounced scroll handler using a 150 ms timeout.

Chrome 115+ The native API runs on the compositor. The feature detection check passes and the polyfill never loads. No action required.

Firefox (flag disabled by default) Firefox supports scroll-driven animations behind layout.css.scroll-driven-animations.enabled but not by default as of Firefox 126. Treat Firefox the same as Safari < 17.4 for polyfill purposes β€” the feature detection check fails and the rAF path activates. Firefox does support scrollend from version 109.

Deployment checklist


Related

↑ Understanding the CSS Scroll-Timeline API