Performance · June 2026 · 7 min read
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
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.
/* 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
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
JS fallback path
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
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
In practice
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:
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 loopimport { 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)// 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
| Browser | Version | Status | Notes |
|---|---|---|---|
| Chrome | 115+ | native CSS | Full support since Jul 2023 |
| Edge | 115+ | native CSS | Same engine as Chrome |
| Firefox | 110+ | native CSS | Full support since Mar 2023 |
| Safari | — | JS fallback | Not yet supported — JS fallback active |
| Chrome Android | 115+ | native CSS | Full mobile support |
| Firefox Android | 110+ | native CSS | Full mobile support |
| Safari iOS | — | JS fallback | Not yet — JS fallback active |
Safari
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
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
If you need to force the JS engine — for testing, for a polyfill, or because you're debugging — set native: false:
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.
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–1Summary
Try it yourself
Open DevTools → Performance while scrolling a svg-scroll-draw animation. On Chrome, you'll see zero main-thread JS per frame.