Performance · June 2026 · 7 min read

Zero-JS SVG scroll
animations.

Since v1.1.0, svg-scroll-draw automatically hands off eligible animations to the browser's native CSS compositor using animation-timeline: view(). Zero per-frame JavaScript. No scroll listeners. 60fps even under heavy CPU load.

JS per frame

0 bytes

Scroll listeners

none

Browser support

Chrome/FF 115+

Opt-out

native: false

The CSS primitive

What is animation-timeline: view()?

Scroll-Driven Animations is a CSS spec that lets you tie a CSS @keyframes animation to the scroll position of an element in the viewport — without any JavaScript. The browser handles it entirely on the compositor thread.

The key property is animation-timeline: view(). Combined with animation-range, you declare “play this animation as the element travels from X to Y in the viewport”. The browser scrubs the animation in sync with scroll — no rAF, no event listeners, no JavaScript execution per frame.

native-css-raw.css
/* Pure CSS scroll-driven animation */
@keyframes draw-path {
  from { stroke-dashoffset: var(--path-length); }
  to   { stroke-dashoffset: 0; }
}

path {
  stroke-dasharray:  var(--path-length);
  stroke-dashoffset: var(--path-length);

  animation-name:           draw-path;
  animation-timing-function: ease-out;
  animation-fill-mode:      both;
  animation-duration:       1s; /* ignored — scroll drives it */

  animation-timeline: view();   /* scroll-driven */
  animation-range: entry 20% exit 80%;
}

The catch: you have to know the path length upfront (to set --path-length), and Safari doesn't support it yet. That's where svg-scroll-draw comes in — it measures the length, injects the CSS, and falls back to the JS engine seamlessly on unsupported browsers.

Under the hood

How svg-scroll-draw activates the native path

When you call scrollDraw(), the engine first checks whether the config is “native-eligible”. If it is and the browser supports animation-timeline: view(), it generates a <style> tag and injects the CSS keyframe animation — then returns. The JS rAF loop never starts.

If the browser doesn't support it (Safari, older Chrome/FF), the engine falls back to the full JS path transparently. Your code doesn't change. The animation looks identical.

Native CSS path

  • Runs on compositor thread
  • Zero per-frame JavaScript
  • No scroll event listeners
  • No requestAnimationFrame
  • 60fps even under heavy CPU load
  • Instant response to scroll direction changes

JS fallback path

  • IntersectionObserver gating
  • requestAnimationFrame loop
  • Runs only while visible
  • Same visual result
  • Full instance API works
  • Activates automatically on Safari
engine.ts (simplified eligibility check)
function isNativeEligible(options) {
  if (!CSS.supports('animation-timeline', 'view()')) return false;
  if (options.native === false)      return false; // explicit opt-out
  if (options.stagger !== 0)         return false; // stagger needs JS timing
  if (options.onProgress || options.onStart || options.onComplete) return false; // callbacks need JS frames
  if (options.morphTo)               return false; // path interpolation = JS only
  if (options.velocityScale)         return false; // needs live velocity
  if (options.repeat || options.once) return false; // loop/once logic = JS
  if (typeof options.easing === 'function') return false; // custom fn = JS
  if (options.speed !== 1)           return false; // non-1 speed shifts the range
  if (options.strokeColor || options.strokeWidth || options.fillOpacity) return false;
  return true; // ✓ hand off to CSS
}

Eligibility

What triggers native vs JS?

The native path activates when all of these are true:

Browser supports animation-timeline: view()

Chrome 115+, Edge 115+, Firefox 110+. Safari falls back automatically.

No callbacks (onProgress, onStart, onComplete)

Callbacks require a JS frame to fire. Declaring any callback forces the JS engine.

No stagger

stagger offsets individual paths by time fractions — CSS can't express per-path delays within a single view() range.

No morphTo, velocityScale, repeat, or once

These features need frame-by-frame state management that CSS can't provide.

Named easing (not a custom function)

linear, ease-in, ease-out, ease-in-out map directly to CSS timing functions. Spring/bounce/elastic and custom functions require JS.

speed === 1 (default)

Non-1 speed values shift the effective trigger range. This is trivial in JS but requires a CSS calc() trick that isn't worth the complexity.

No animated colors or widths

strokeColor, strokeWidth, fillOpacity — all require per-frame interpolation.

The 90% case

The vast majority of real-world scroll-draw animations — a logo tracing in, a line chart appearing, a hero illustration drawing — use the default options. No callbacks, no stagger, default speed, named easing. These all hit the native path automatically on Chrome and Firefox.

In practice

Native vs JS — the code is identical

You don't write different code for the native path. The engine decides. Here are examples that hit native, and examples that stay on JS:

Native path activated ✓

logo.js
import { scrollDraw } from 'svg-scroll-draw';

// Simple logo reveal — compositor-driven on Chrome/FF
scrollDraw('#logo', {
  easing: 'ease-out',
  fade: true,
  trigger: { start: 'top 80%', end: 'top 20%' },
});
// → CSS animation-timeline: view() injected, no JS loop
Logo.tsx
import { ScrollDraw } from 'svg-scroll-draw/react';

// React component — also hits native path
function Logo() {
  return (
    <ScrollDraw easing="ease-in-out" fade once>
      <svg>…</svg>
    </ScrollDraw>
  );
}
// once:true forces the JS path (needs freeze logic)

JS path (by necessity)

chart.js
// onProgress callback → JS path (needs frame-by-frame)
scrollDraw('#chart', {
  easing: 'ease-out',
  onProgress: (alpha) => {
    counter.textContent = Math.round(alpha * 1000);
  },
});

// stagger → JS path (per-path offset timing)
scrollDraw('#diagram', {
  stagger: 0.15,
  easing: 'ease-out',
});

// spring easing → JS path (not a CSS timing function)
scrollDraw('#path', {
  easing: 'spring',
});

// Opt out explicitly
scrollDraw('#path', {
  native: false, // always use JS engine
});

Browser support

Where native runs today

BrowserVersionStatusNotes
Chrome115+native CSSFull support since Jul 2023
Edge115+native CSSSame engine as Chrome
Firefox110+native CSSFull support since Mar 2023
SafariJS fallbackNot yet supported — JS fallback active
Chrome Android115+native CSSFull mobile support
Firefox Android110+native CSSFull mobile support
Safari iOSJS fallbackNot yet — JS fallback active

Safari

Safari does not yet support animation-timeline: view(). svg-scroll-draw detects this at runtime and activates the JS engine automatically — the animation looks and behaves identically. No code changes, no Safari-specific branches.

Performance impact

Why the compositor matters

JavaScript animations run on the main thread. When your main thread is busy — parsing JS, running React reconciliation, handling events — the scroll animation stutters. The scroll event fires, the rAF fires, but the frame is delayed. That's why even well-optimised JS scroll animations can drop frames during page load or heavy interaction.

CSS animations using animation-timeline: view()run on the compositor thread — a separate thread that handles transforms and opacity. It doesn't wait for the main thread. Scroll input goes directly to the compositor, the animation updates, the frame is painted. Main thread can be fully occupied and it doesn't matter.

Heavy page load

JS

Main thread busy parsing JS → rAF fires late → visible stutter

Native CSS

Compositor thread independent → animation still smooth

React re-renders

JS

setState cascade → main thread blocked → animation stutters

Native CSS

Compositor unaffected by React work

Low-power mode

JS

rAF throttled → animation choppy at 30fps

Native CSS

Compositor-native → matches display refresh rate

Control

Opt-out and instance API

If you need to force the JS engine — for testing, for a polyfill, or because you're debugging — set native: false:

opt-out.js
scrollDraw('#logo', {
  easing: 'ease-out',
  native: false, // always use JS rAF engine
});

The full instance API — pause(), resume(), seek(), replay(), destroy() — works on both paths. The native path implements these by toggling animation-play-state and injecting inline offsets.

instance-api.js
const instance = scrollDraw('#logo', { easing: 'ease-out' });

instance.pause();              // pauses wherever it is
instance.resume();             // continues from same point
instance.seek(0.5);            // jump to 50% drawn
instance.replay();             // restart from 0
instance.destroy();            // remove all styles + observers
instance.getProgress();        // → 0–1

Summary

TL;DR

  • 01svg-scroll-draw automatically uses the native CSS compositor path on Chrome 115+ and Firefox 110+.
  • 02The native path runs zero JavaScript per frame — no scroll listeners, no rAF.
  • 03The JS fallback activates automatically on Safari and older browsers — same visual result.
  • 04Any config that needs JS (callbacks, stagger, spring easing, repeat, velocityScale) stays on the JS engine.
  • 05The vast majority of real-world logo reveals and path animations hit the native path.
  • 06You don't write different code. The engine decides.

Try it yourself

Open DevTools → Performance while scrolling a svg-scroll-draw animation. On Chrome, you'll see zero main-thread JS per frame.