|$ curl https://forge-ai.dev/api/markdown?path=docs/js/animation
$cat docs/javascript-—-animation-&-interaction.md
updated This week·45 min read·published

JavaScript — Animation & Interaction

JavaScriptAnimationrAFScrollInteractiveAdvanced🎯Free Tools
Introduction

Animation transforms static interfaces into engaging, responsive experiences. JavaScript gives you full programmatic control over motion — something CSS alone cannot always provide. With JS you can react to user input in real time, build physics-based transitions, drive canvas or SVG scenes, and orchestrate complex sequenced animations that respond to data.

CSS animations and transitions are ideal for simple state changes — hover effects, loading spinners, accordion toggles. They run on the compositor thread and can leverage GPU acceleration with minimal JS. But when you need frame-by-frame control, dynamic values tied to scroll position, physics simulations, or sequenced multi-element choreography, JavaScript is the tool of choice.

The performance golden rule: animate only transform and opacity whenever possible. These two properties are handled by the GPU compositor and never trigger layout or paint. Animating top, left, width, or height forces the browser to recalculate layout every frame — a recipe for jank. The target is 60 frames per second: each frame has a budget of roughly 16.67 milliseconds. Exceed that and the user perceives stutter.

ApproachBest ForPerformanceControl
CSS TransitionsSimple state changes, hover/focus effectsCompositor-thread, GPU-acceleratedLimited — start/end only
CSS @keyframesLooping decorative animations, loading statesCompositor-thread, GPU-acceleratedMedium — keyframe sequences
Web Animations APIProgrammatic CSS-like animations with JS controlCompositor-thread, optimizedHigh — play/pause/reverse
requestAnimationFrameCustom animation loops, scroll-driven, physicsMain-thread, frame-syncedFull — every pixel, every frame
Canvas / WebGLParticle systems, games, data visualizationGPU-accelerated bitmapFull — pixel-level control
requestAnimationFrame (rAF)

requestAnimationFrame is the foundation of JavaScript animation. It tells the browser you want to perform an animation and requests that the browser call your callback function before the next repaint. The callback receives a high-resolution timestamp, enabling smooth, frame-synchronized motion.

Why not setInterval? Because setInterval fires at fixed intervals regardless of whether the browser is ready to paint. It can fire twice between frames or skip frames entirely. It also continues running in background tabs, wasting CPU. requestAnimationFrame is synchronized with the display refresh rate, pauses in background tabs, and receives a precise timestamp for delta-time calculations.

raf-basic.js
JavaScript
1// Basic rAF syntax
2const id = requestAnimationFrame((timestamp) => {
3 console.log('Next frame at:', timestamp);
4});
5
6// Cancel if needed before it fires
7cancelAnimationFrame(id);
8
9// Chaining — call rAF inside the callback for continuous animation
10function animate(timestamp) {
11 // Update state based on timestamp
12 element.style.transform = `translateX(${timestamp % 1000 / 10}px)`;
13
14 // Schedule next frame
15 requestAnimationFrame(animate);
16}
17
18// Start the loop
19requestAnimationFrame(animate);
raf-vs-interval.js
JavaScript
1// Why rAF beats setInterval — frame-synced vs. arbitrary timing
2
3// BAD — setInterval drifts and wastes frames
4setInterval(() => {
5 updatePosition(); // May fire 2x per frame, or 0x if tab is hidden
6}, 16);
7
8// GOOD — synchronized to display refresh
9function loop() {
10 updatePosition();
11 requestAnimationFrame(loop);
12}
13requestAnimationFrame(loop);
14
15// rAF advantages:
16// 1. Synced to monitor refresh (60Hz, 120Hz, 144Hz, etc.)
17// 2. Pauses in background tabs — saves battery
18// 3. Receives DOMHighResTimeStamp — precise delta calculation
19// 4. Batched with layout/paint — no wasted frames
preview
Building an Animation Loop

A robust animation system separates concerns: state management, physics/easing calculations, and rendering. The key to frame-rate-independent animation is delta time — the elapsed time between frames. Instead of incrementing position by a fixed amount each frame, multiply speed by the time elapsed. This ensures animations run at the same visual speed on a 30fps device and a 144fps display.

animation-loop.js
JavaScript
1class AnimationLoop {
2 constructor(update, render) {
3 this.update = update;
4 this.render = render;
5 this.lastTime = 0;
6 this.running = false;
7 this.id = null;
8 }
9
10 start() {
11 this.running = true;
12 this.lastTime = performance.now();
13 this.id = requestAnimationFrame(this.tick.bind(this));
14 }
15
16 stop() {
17 this.running = false;
18 cancelAnimationFrame(this.id);
19 }
20
21 tick(now) {
22 if (!this.running) return;
23
24 const delta = (now - this.lastTime) / 1000; // seconds
25 this.lastTime = now;
26
27 this.update(delta);
28 this.render();
29
30 this.id = requestAnimationFrame(this.tick.bind(this));
31 }
32}
33
34// Usage
35const ball = document.getElementById('ball');
36let x = 0;
37
38const anim = new AnimationLoop(
39 (dt) => { x += 200 * dt; }, // update: 200px/sec
40 () => { ball.style.transform = `translateX(${x}px)`; } // render
41);
42
43anim.start();

Easing Functions

Easing functions map elapsed time (0 to 1) to a progress curve, creating natural-feeling motion. Linear motion looks robotic. Real objects accelerate, decelerate, and overshoot. Easing is the difference between a UI that feels alive and one that feels mechanical.

easings.js
JavaScript
1// Common easing functions — all take t (0→1) and return eased value (0→1)
2const easings = {
3 linear: (t) => t,
4 easeInQuad: (t) => t * t,
5 easeOutQuad: (t) => t * (2 - t),
6 easeInOutQuad:(t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
7
8 easeInCubic: (t) => t * t * t,
9 easeOutCubic: (t) => (--t) * t * t + 1,
10 easeInOutCubic:(t) => t < 0.5 ? 4*t*t*t : (t-1)*(2*t-2)*(2*t-2)+1,
11
12 easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
13 easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
14 easeInOutSine:(t) => -(Math.cos(Math.PI * t) - 1) / 2,
15
16 easeOutElastic:(t) => {
17 const c4 = (2 * Math.PI) / 3;
18 return t === 0 ? 0 : t === 1 ? 1
19 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
20 },
21
22 easeOutBounce:(t) => {
23 const n1 = 7.5625, d1 = 2.75;
24 if (t < 1/d1) return n1*t*t;
25 if (t < 2/d1) return n1*(t-=1.5/d1)*t+0.75;
26 if (t < 2.5/d1) return n1*(t-=2.25/d1)*t+0.9375;
27 return n1*(t-=2.625/d1)*t+0.984375;
28 }
29};
30
31// Animating with easing — interpolate from start to end
32function animateValue(element, prop, start, end, duration, easing) {
33 const startTime = performance.now();
34 function tick(now) {
35 const elapsed = now - startTime;
36 const t = Math.min(elapsed / duration, 1);
37 const eased = easing(t);
38 const value = start + (end - start) * eased;
39 element.style[prop] = value + 'px';
40 if (t < 1) requestAnimationFrame(tick);
41 }
42 requestAnimationFrame(tick);
43}
preview
Element.animate() — Web Animations API

The Web Animations API bridges CSS animations and JavaScript control. element.animate() creates an Animation object that you can play, pause, reverse, cancel, and seek — giving you the power of CSS keyframes with the flexibility of programmatic control. It runs off the main thread in most browsers, making it both powerful and performant.

web-animations-api.js
JavaScript
1// Basic element.animate() — keyframes and options
2const element = document.querySelector('.box');
3
4const animation = element.animate(
5 // Keyframes — array of CSS property snapshots
6 [
7 { transform: 'translateX(0px)', opacity: 0 },
8 { transform: 'translateX(100px)', opacity: 1, offset: 0.7 },
9 { transform: 'translateX(120px)', opacity: 1 },
10 ],
11 // Options
12 {
13 duration: 800,
14 easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
15 fill: 'forwards', // keep final state after animation ends
16 iterations: 1,
17 direction: 'normal', // normal | reverse | alternate
18 delay: 0,
19 }
20);
21
22// Control methods
23animation.pause();
24animation.play();
25animation.reverse();
26animation.cancel();
27animation.finish(); // jump to end
28animation.commitStyles(); // persist current computed styles
29
30// Playback rate
31animation.playbackRate = 2; // double speed
32animation.playbackRate = 0; // frozen
33
34// Promise-based events
35animation.finished.then(() => console.log('done'));
36animation.onfinish = () => console.log('finished');
37animation.oncancel = () => console.log('cancelled');
chaining-animations.js
JavaScript
1// Chaining animations — sequence multiple effects
2async function sequenceAnimations(element) {
3 await element.animate([
4 { transform: 'scale(0.8)', opacity: 0 },
5 { transform: 'scale(1)', opacity: 1 },
6 ], { duration: 300, fill: 'forwards' }).finished;
7
8 await element.animate([
9 { transform: 'translateY(0px)' },
10 { transform: 'translateY(-10px)' },
11 { transform: 'translateY(0px)' },
12 ], { duration: 400, easing: 'ease-out' }).finished;
13
14 await element.animate([
15 { boxShadow: '0 0 0px rgba(0,255,65,0)' },
16 { boxShadow: '0 0 20px rgba(0,255,65,0.5)' },
17 { boxShadow: '0 0 0px rgba(0,255,65,0)' },
18 ], { duration: 600 }).finished;
19}
20
21// Or run in parallel with Promise.all
22async function parallelAnimations(elements) {
23 const animations = elements.map((el, i) =>
24 el.animate([
25 { transform: 'translateY(20px)', opacity: 0 },
26 { transform: 'translateY(0)', opacity: 1 },
27 ], { duration: 400, delay: i * 100 })
28 );
29 await Promise.all(animations.map(a => a.finished));
30}
preview
Scroll-Triggered Animations

Scroll-triggered animations reveal elements as the user scrolls down the page, creating a sense of progression and discovery. The IntersectionObserver API is the performant way to detect when elements enter the viewport — it avoids expensive scroll event listeners and runs off the main thread.

scroll-reveal.js
JavaScript
1// IntersectionObserver — scroll reveal
2const observer = new IntersectionObserver(
3 (entries) => {
4 entries.forEach((entry) => {
5 if (entry.isIntersecting) {
6 entry.target.classList.add('revealed');
7 observer.unobserve(entry.target); // animate once
8 }
9 });
10 },
11 {
12 threshold: 0.15, // trigger when 15% visible
13 rootMargin: '0px 0px -50px 0px' // trigger 50px before entering
14 }
15);
16
17// Observe all elements with the reveal class
18document.querySelectorAll('.reveal').forEach(el => {
19 observer.observe(el);
20});
stagger-reveal.js
JavaScript
1// Staggered reveals for lists/grids
2const staggerObserver = new IntersectionObserver((entries) => {
3 entries.forEach((entry) => {
4 if (entry.isIntersecting) {
5 const children = entry.target.children;
6 Array.from(children).forEach((child, i) => {
7 child.style.transitionDelay = `${i * 100}ms`;
8 child.classList.add('stagger-in');
9 });
10 staggerObserver.unobserve(entry.target);
11 }
12 });
13}, { threshold: 0.1 });
14
15document.querySelectorAll('.stagger-container').forEach(container => {
16 staggerObserver.observe(container);
17});
preview
Parallax Scrolling

Parallax creates a depth illusion by moving background layers at different speeds as the user scrolls. The simplest CSS approach is background-attachment: fixed, but it lacks control and doesn't work reliably on mobile. JavaScript parallax reads the scroll position and applies a translateY transform, which is GPU-accelerated and works consistently across devices.

parallax.js
JavaScript
1// JS parallax — translate based on scroll position
2const hero = document.querySelector('.hero');
3const bg = document.querySelector('.hero-bg');
4
5function updateParallax() {
6 const scrollY = window.scrollY;
7 const heroHeight = hero.offsetHeight;
8
9 if (scrollY < heroHeight) {
10 // Background moves slower — creates depth illusion
11 const offset = scrollY * 0.4; // 40% of scroll speed
12 bg.style.transform = `translateY(${offset}px)`;
13 }
14}
15
16// Throttled with rAF
17let ticking = false;
18window.addEventListener('scroll', () => {
19 if (!ticking) {
20 requestAnimationFrame(() => {
21 updateParallax();
22 ticking = false;
23 });
24 ticking = true;
25 }
26}, { passive: true });
preview
Drag & Drop

Drag-and-drop interaction requires tracking pointer position across multiple events: mousedown/touchstart to initiate, mousemove/touchmove to update position, and mouseup/touchend to release. The pattern calculates the delta between the initial click and current pointer, then applies a transform.

draggable.js
JavaScript
1// Universal drag handler — mouse + touch
2function makeDraggable(element) {
3 let startX, startY, offsetX = 0, offsetY = 0, isDragging = false;
4
5 function onStart(e) {
6 isDragging = true;
7 const point = e.touches ? e.touches[0] : e;
8 startX = point.clientX - offsetX;
9 startY = point.clientY - offsetY;
10 element.style.cursor = 'grabbing';
11 element.style.zIndex = 1000;
12 e.preventDefault();
13 }
14
15 function onMove(e) {
16 if (!isDragging) return;
17 const point = e.touches ? e.touches[0] : e;
18 offsetX = point.clientX - startX;
19 offsetY = point.clientY - startY;
20 element.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
21 e.preventDefault();
22 }
23
24 function onEnd() {
25 isDragging = false;
26 element.style.cursor = 'grab';
27 element.style.zIndex = '';
28 }
29
30 element.addEventListener('mousedown', onStart);
31 document.addEventListener('mousemove', onMove);
32 document.addEventListener('mouseup', onEnd);
33
34 element.addEventListener('touchstart', onStart, { passive: false });
35 document.addEventListener('touchmove', onMove, { passive: false });
36 document.addEventListener('touchend', onEnd);
37
38 element.style.cursor = 'grab';
39}
bounded-drag.js
JavaScript
1// Boundary-constrained drag
2function makeBounded(element, container) {
3 let startX, startY, offsetX = 0, offsetY = 0, isDragging = false;
4
5 function onStart(e) {
6 isDragging = true;
7 const point = e.touches ? e.touches[0] : e;
8 startX = point.clientX - offsetX;
9 startY = point.clientY - offsetY;
10 e.preventDefault();
11 }
12
13 function onMove(e) {
14 if (!isDragging) return;
15 const point = e.touches ? e.touches[0] : e;
16 let newX = point.clientX - startX;
17 let newY = point.clientY - startY;
18
19 // Clamp to container bounds
20 const maxX = container.offsetWidth - element.offsetWidth;
21 const maxY = container.offsetHeight - element.offsetHeight;
22 newX = Math.max(0, Math.min(newX, maxX));
23 newY = Math.max(0, Math.min(newY, maxY));
24
25 offsetX = newX;
26 offsetY = newY;
27 element.style.transform = `translate(${newX}px, ${newY}px)`;
28 e.preventDefault();
29 }
30
31 function onEnd() { isDragging = false; }
32
33 element.addEventListener('mousedown', onStart);
34 document.addEventListener('mousemove', onMove);
35 document.addEventListener('mouseup', onEnd);
36 element.addEventListener('touchstart', onStart, { passive: false });
37 document.addEventListener('touchmove', onMove, { passive: false });
38 document.addEventListener('touchend', onEnd);
39}
preview
Gesture Handling

Touch devices require gesture detection — swipe, pinch-to-zoom, rotation — beyond simple drag. Swipe detection compares the start and end touch positions, measuring both direction and velocity. Pinch-to-zoom tracks the distance between two touch points over time. These raw touch events are the building blocks for all touch interactions.

swipe-detector.js
JavaScript
1// Swipe detection — direction + velocity
2class SwipeDetector {
3 constructor(element, options = {}) {
4 this.element = element;
5 this.threshold = options.threshold || 50; // min px distance
6 this.velocity = options.velocity || 0.3; // min px/ms
7 this.onSwipe = options.onSwipe || (() => {});
8
9 this.startX = 0;
10 this.startY = 0;
11 this.startTime = 0;
12
13 element.addEventListener('touchstart', (e) => {
14 const touch = e.touches[0];
15 this.startX = touch.clientX;
16 this.startY = touch.clientY;
17 this.startTime = Date.now();
18 }, { passive: true });
19
20 element.addEventListener('touchend', (e) => {
21 const touch = e.changedTouches[0];
22 const dx = touch.clientX - this.startX;
23 const dy = touch.clientY - this.startY;
24 const dt = Date.now() - this.startTime;
25
26 const absDx = Math.abs(dx);
27 const absDy = Math.abs(dy);
28
29 if (Math.max(absDx, absDy) < this.threshold) return;
30 if (dt > 0 && Math.max(absDx, absDy) / dt < this.velocity) return;
31
32 let direction;
33 if (absDx > absDy) {
34 direction = dx > 0 ? 'right' : 'left';
35 } else {
36 direction = dy > 0 ? 'down' : 'up';
37 }
38
39 this.onSwipe({ direction, dx, dy, velocity: absDx / dt });
40 }, { passive: true });
41 }
42}
43
44// Usage
45new SwipeDetector(document.querySelector('.carousel'), {
46 threshold: 80,
47 onSwipe: ({ direction }) => {
48 if (direction === 'left') nextSlide();
49 if (direction === 'right') prevSlide();
50 }
51});
pinch-zoom.js
JavaScript
1// Pinch-to-zoom — two-finger distance tracking
2class PinchZoom {
3 constructor(element, options = {}) {
4 this.element = element;
5 this.scale = 1;
6 this.minScale = options.min || 0.5;
7 this.maxScale = options.max || 3;
8 this.onPinch = options.onPinch || (() => {});
9
10 this.initialDistance = 0;
11 this.initialScale = 1;
12
13 element.addEventListener('touchstart', (e) => {
14 if (e.touches.length === 2) {
15 this.initialDistance = this.getDistance(e.touches);
16 this.initialScale = this.scale;
17 }
18 }, { passive: true });
19
20 element.addEventListener('touchmove', (e) => {
21 if (e.touches.length === 2) {
22 e.preventDefault();
23 const dist = this.getDistance(e.touches);
24 const ratio = dist / this.initialDistance;
25 this.scale = Math.min(this.maxScale,
26 Math.max(this.minScale, this.initialScale * ratio));
27 this.onPinch(this.scale);
28 }
29 }, { passive: false });
30 }
31
32 getDistance(touches) {
33 const dx = touches[0].clientX - touches[1].clientX;
34 const dy = touches[0].clientY - touches[1].clientY;
35 return Math.sqrt(dx * dx + dy * dy);
36 }
37}
Physics-Based Animation

Spring animations feel natural because they model real-world physics — mass, stiffness, and damping. Unlike easing functions that follow a fixed curve, springs react to velocity and displacement, producing organic motion that overshoots, bounces, and settles naturally. This is the technique behind iOS rubber-banding, Material Design motion, and physics-based UI libraries.

spring.js
JavaScript
1// Spring simulation — position, velocity, stiffness, damping
2class Spring {
3 constructor(options = {}) {
4 this.position = options.from || 0;
5 this.target = options.to || 1;
6 this.velocity = 0;
7 this.stiffness = options.stiffness || 180; // spring constant
8 this.damping = options.damping || 12; // friction
9 this.mass = options.mass || 1;
10 this.precision = options.precision || 0.01;
11 this.settled = false;
12 }
13
14 // Advance simulation by dt seconds
15 update(dt) {
16 if (this.settled) return this.position;
17
18 const displacement = this.position - this.target;
19 const springForce = -this.stiffness * displacement;
20 const dampingForce = -this.damping * this.velocity;
21 const acceleration = (springForce + dampingForce) / this.mass;
22
23 this.velocity += acceleration * dt;
24 this.position += this.velocity * dt;
25
26 // Check if settled
27 if (Math.abs(this.velocity) < this.precision &&
28 Math.abs(this.position - this.target) < this.precision) {
29 this.position = this.target;
30 this.velocity = 0;
31 this.settled = true;
32 }
33
34 return this.position;
35 }
36
37 // Change target with current velocity preserved
38 setTarget(target) {
39 this.target = target;
40 this.settled = false;
41 }
42}
43
44// Usage — animate element with spring physics
45const spring = new Spring({ from: 0, to: 300, stiffness: 120, damping: 14 });
46const el = document.querySelector('.spring-box');
47let lastTime = performance.now();
48
49function tick(now) {
50 const dt = Math.min((now - lastTime) / 1000, 0.064); // cap at ~16fps min
51 lastTime = now;
52 spring.update(dt);
53 el.style.transform = `translateX(${spring.position}px)`;
54 if (!spring.settled) requestAnimationFrame(tick);
55}
56requestAnimationFrame(tick);
preview
Canvas Animation

The HTML5 Canvas API provides a pixel-level drawing surface. Unlike DOM animation, canvas redraws the entire scene each frame — which makes it ideal for particle systems, games, and data visualizations where you're updating hundreds or thousands of elements. The 2D context offers shapes, gradients, paths, and image manipulation. For heavier work, OffscreenCanvas moves rendering to a Web Worker.

canvas-particles.js
JavaScript
1// Canvas animation loop — clear + draw + rAF
2const canvas = document.getElementById('canvas');
3const ctx = canvas.getContext('2d');
4
5const particles = [];
6const GRAVITY = 0.2;
7
8function createParticle(x, y) {
9 return {
10 x, y,
11 vx: (Math.random() - 0.5) * 8,
12 vy: (Math.random() - 0.5) * 8 - 3,
13 radius: Math.random() * 3 + 1,
14 life: 1,
15 decay: Math.random() * 0.02 + 0.005,
16 color: `hsl(${Math.random() * 60 + 100}, 100%, 60%)`
17 };
18}
19
20function animate() {
21 // Clear with slight trail effect
22 ctx.fillStyle = 'rgba(10, 10, 10, 0.15)';
23 ctx.fillRect(0, 0, canvas.width, canvas.height);
24
25 for (let i = particles.length - 1; i >= 0; i--) {
26 const p = particles[i];
27 p.vy += GRAVITY;
28 p.x += p.vx;
29 p.y += p.vy;
30 p.life -= p.decay;
31
32 if (p.life <= 0) {
33 particles.splice(i, 1);
34 continue;
35 }
36
37 ctx.globalAlpha = p.life;
38 ctx.beginPath();
39 ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
40 ctx.fillStyle = p.color;
41 ctx.fill();
42 }
43
44 ctx.globalAlpha = 1;
45 requestAnimationFrame(animate);
46}
47
48canvas.addEventListener('click', (e) => {
49 const rect = canvas.getBoundingClientRect();
50 for (let i = 0; i < 30; i++) {
51 particles.push(createParticle(e.clientX - rect.left, e.clientY - rect.top));
52 }
53});
54
55animate();
preview
SVG Animation with JS

SVG paths can be animated by manipulating stroke-dasharray and stroke-dashoffset. When the dash offset equals the total path length, the stroke is invisible. Animating it to zero draws the path. This creates the popular "line drawing" effect. JS can drive this with precise timing, chaining multiple paths, or responding to scroll position.

svg-draw.js
JavaScript
1// SVG path drawing animation — stroke-dashoffset technique
2const path = document.querySelector('.draw-path');
3const length = path.getTotalLength(); // total path length in px
4
5// Set initial state — fully hidden
6path.style.strokeDasharray = length;
7path.style.strokeDashoffset = length;
8
9// Animate to fully drawn
10function drawPath(duration = 2000) {
11 const start = performance.now();
12
13 function tick(now) {
14 const t = Math.min((now - start) / duration, 1);
15 const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
16 path.style.strokeDashoffset = length * (1 - eased);
17
18 if (t < 1) requestAnimationFrame(tick);
19 }
20
21 requestAnimationFrame(tick);
22}
23
24// Trigger on scroll
25const observer = new IntersectionObserver((entries) => {
26 entries.forEach((entry) => {
27 if (entry.isIntersecting) {
28 drawPath();
29 observer.unobserve(entry.target);
30 }
31 });
32}, { threshold: 0.3 });
33
34observer.observe(path);
preview
Performance Optimization

Smooth animation at 60fps requires avoiding layout recalculation, minimizing paint, and keeping the main thread free. The browser rendering pipeline is: JavaScript → Style → Layout → Paint → Composite. Animating transform and opacity skips Layout and Paint entirely — the GPU handles it during Composite.

PropertyTriggers LayoutTriggers PaintGPU-Accelerated
transformNoNoYes
opacityNoNoYes
top / leftYesYesNo
width / heightYesYesNo
margin / paddingYesYesNo
box-shadowNoYesSometimes
perf-optimize.js
JavaScript
1// Layout thrashing — reading forces pending writes to flush
2// BAD
3elements.forEach(el => {
4 const height = el.offsetHeight; // forces layout
5 el.style.height = (height * 2) + 'px'; // write
6});
7
8// GOOD — batch reads, then batch writes
9const heights = elements.map(el => el.offsetHeight); // read all
10elements.forEach((el, i) => {
11 el.style.height = (heights[i] * 2) + 'px'; // write all
12});
13
14// will-change — hint to browser to promote to compositor layer
15element.style.willChange = 'transform';
16element.style.transform = 'translateX(100px)';
17// Clean up after animation
18element.style.willChange = 'auto';
19
20// Contain — isolate layout calculations
21.card {
22 contain: layout style paint; // restrict layout scope
23}
24
25// OffscreenCanvas — move heavy rendering to a Worker
26const offscreen = canvas.transferControlToContext();
27// Now draw from a Web Worker thread — main thread stays free

best practice

Use the Chrome DevTools Performance panel to record animation frames. Look for the green "Recalculate Style" and red "Layout" bars in the main thread. If you see them during your animation, you're triggering expensive layout/paint. Aim for the main thread to only show green "Paint" and "Composite" bars.
Reduced Motion

Some users experience motion sickness, vestibular disorders, or have their OS set to minimize animations. The prefers-reduced-motion media query and its JavaScript equivalent let you respect this preference. Animations should be replaced with instant transitions, static indicators, or very subtle motion.

reduced-motion.js
JavaScript
1// CSS — respect reduced motion preference
2// @media (prefers-reduced-motion: reduce) {
3// *, *::before, *::after {
4// animation-duration: 0.01ms !important;
5// animation-iteration-count: 1 !important;
6// transition-duration: 0.01ms !important;
7// }
8// }
9
10// JavaScript — check reduced motion preference
11const prefersReducedMotion = window.matchMedia(
12 '(prefers-reduced-motion: reduce)'
13);
14
15function shouldAnimate() {
16 return !prefersReducedMotion.matches;
17}
18
19// Usage — conditionally run animations
20if (shouldAnimate()) {
21 element.animate([
22 { transform: 'translateY(20px)', opacity: 0 },
23 { transform: 'translateY(0)', opacity: 1 },
24 ], { duration: 600 });
25} else {
26 // Instant reveal — no motion
27 element.style.opacity = '1';
28}
29
30// Listen for changes (user toggles setting while page is open)
31prefersReducedMotion.addEventListener('change', (e) => {
32 if (e.matches) {
33 // Stop all running animations
34 document.getAnimations().forEach(a => a.cancel());
35 console.log('Reduced motion enabled — animations stopped');
36 }
37});

warning

Always respect prefers-reduced-motion. Ignoring it can cause nausea, dizziness, and anxiety in users with vestibular disorders. Replace motion with opacity fades, static highlights, or instant state changes. Never use !importantto override the user's system preference.
Common Mistakes
common-mistakes.js
JavaScript
1// MISTAKE 1: Using setInterval for animation
2// BAD — drifts, wastes frames, runs in background tabs
3setInterval(() => {
4 element.style.left = x + 'px';
5 x += 2;
6}, 16);
7
8// FIX — use requestAnimationFrame
9let x = 0;
10function animate() {
11 x += 2;
12 element.style.transform = `translateX(${x}px)`;
13 requestAnimationFrame(animate);
14}
15requestAnimationFrame(animate);
16
17
18// MISTAKE 2: Animating layout properties
19// BAD — triggers layout recalculation every frame
20element.style.top = y + 'px';
21element.style.left = x + 'px';
22element.style.width = w + 'px';
23element.style.height = h + 'px';
24
25// FIX — use transform
26element.style.transform = `translate(${x}px, ${y}px) scale(${s})`;
27
28
29// MISTAKE 3: Not cleaning up animation frames
30// BAD — animation continues after component unmount
31function Mount() {
32 function animate() {
33 update();
34 requestAnimationFrame(animate); // never stops!
35 }
36 animate();
37}
38
39// FIX — store ID and cancel
40function Mount() {
41 let id;
42 function animate() {
43 update();
44 id = requestAnimationFrame(animate);
45 }
46 animate();
47 return () => cancelAnimationFrame(id); // cleanup
48}
49
50
51// MISTAKE 4: Reading layout properties during animation
52// BAD — forces synchronous layout (layout thrashing)
53function animate() {
54 const rect = element.getBoundingClientRect(); // forces layout
55 element.style.transform = `translateX(${rect.left + 10}px)`;
56 requestAnimationFrame(animate);
57}
58
59// FIX — cache layout reads or use transform-only updates
60let cachedLeft = 0;
61element.addEventListener('mouseenter', () => {
62 cachedLeft = element.getBoundingClientRect().left;
63});
64function animate() {
65 element.style.transform = `translateX(${cachedLeft + 10}px)`;
66 requestAnimationFrame(animate);
67}
Using setInterval for animationUse requestAnimationFrame — synced to display refresh, pauses in background tabs
Animating top/left/width/heightUse transform and opacity — GPU-composited, no layout/paint
Not storing/cancelling rAF IDsAlways cancelAnimationFrame on unmount or when stopping animation
Reading layout properties during animation loopCache reads outside the loop, or use IntersectionObserver
Ignoring prefers-reduced-motionCheck matchMedia and provide static alternatives
Creating new DOM nodes every framePool and reuse elements, or use canvas for hundreds of objects
Best Practices
PracticeWhy
Always use rAFSynced to display, pauses in background, receives precise timestamp
Animate transform + opacity onlyGPU-composited — skips layout and paint, runs on compositor thread
Use delta timeFrame-rate-independent — same speed on 30fps and 144fps displays
Batch reads before writesAvoids layout thrashing — reading a property forces pending writes to flush
Cancel frames on cleanupPrevents memory leaks and ghost animations after elements are removed
Respect prefers-reduced-motionAccessibility — some users experience motion sickness or vestibular disorders
Use will-change sparinglyPromotes to compositor layer but costs memory — remove after animation
Prefer Web Animations APIOff-main-thread, programmatic control, better than manual rAF for CSS-like animations
Use canvas for 100+ objectsDOM nodes are expensive — canvas bitmap rendering handles particle systems efficiently
Add contain on animated elementsRestricts layout scope — browser doesn't need to recalculate sibling elements
animation-lifecycle.js
JavaScript
1// Clean animation lifecycle — setup + teardown pattern
2function createAnimatedComponent(element) {
3 const ctrl = new AbortController();
4 const { signal } = ctrl;
5 let rafId = null;
6
7 // Setup
8 element.style.willChange = 'transform';
9
10 let position = 0;
11 let lastTime = performance.now();
12
13 function tick(now) {
14 const dt = (now - lastTime) / 1000;
15 lastTime = now;
16 position += 100 * dt;
17 element.style.transform = `translateX(${position}px)`;
18 rafId = requestAnimationFrame(tick);
19 }
20
21 rafId = requestAnimationFrame(tick);
22
23 // Teardown — returns cleanup function
24 return () => {
25 cancelAnimationFrame(rafId);
26 element.style.willChange = 'auto';
27 ctrl.abort();
28 };
29}
30
31// React-style usage
32const cleanup = createAnimatedComponent(document.getElementById('box'));
33
34// Cleanup on unmount
35window.addEventListener('beforeunload', cleanup);
$Blueprint — Engineering Documentation·Section ID: JS-ANIMATION·Revision: 1.0