JavaScript Animations Β· Astro Tech Blog

JavaScript Animations

For animations that CSS can’t handle (physics, complex paths, dynamic values), JavaScript provides requestAnimationFrame and timing-based animation.

The Animation Loop

function animate() {
  // Update state
  // Render

  requestAnimationFrame(animate); // continue
}

// Start
requestAnimationFrame(animate);

requestAnimationFrame

requestAnimationFrame schedules code to run before the next browser repaint (β‰ˆ60fps):

let start = performance.now();

function animate(time) {
  const elapsed = time - start; // time in ms since start

  // Calculate progress (0 to 1)
  const progress = Math.min(elapsed / duration, 1);

  // Apply easing
  const eased = easeInOut(progress);

  // Update element position
  element.style.transform = `translateX(${200 * eased}px)`;

  if (progress < 1) {
    requestAnimationFrame(animate);
  }
}

requestAnimationFrame(animate);
Demo: requestAnimationFrame Animation
HTML
<style>
#raf-box { width: 60px; height: 60px; background: #6366f1; border-radius: 8px; position: relative; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.75rem; text-align: center; }
#raf-track { width: 100%; height: 80px; background: #f1f5f9; border-radius: 8px; position: relative; overflow: hidden; margin-top: 8px; }
</style>
<button id='raf-start'>Start Animation</button>
<div id='raf-track'>
<div id='raf-box'>Run</div>
</div>
<pre id='raf-out' style='background:#f1f5f9;padding:12px;border-radius:6px;'></pre>
JavaScript
const box = document.getElementById('raf-box');
const out = document.getElementById('raf-out');

document.getElementById('raf-start').onclick = function() {
this.disabled = true;
box.style.left = '0px';
const track = document.getElementById('raf-track');
const targetX = track.clientWidth - 60;
const duration = 2000;
const startTime = performance.now();

function animate(time) {
const elapsed = time - startTime;
const progress = Math.min(elapsed / duration, 1);

// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const x = eased * targetX;

box.style.left = x + 'px';
box.textContent = Math.round(progress * 100) + '%';
out.textContent = 'Progress: ' + (progress * 100).toFixed(1) + '% | x: ' + x.toFixed(0) + 'px';

if (progress < 1) {
requestAnimationFrame(animate);
} else {
out.textContent += '\\nβœ… Animation complete!';
document.getElementById('raf-start').disabled = false;
}
}

requestAnimationFrame(animate);
};
Live Output Window

Easing Functions

function linear(t) { return t; }
function easeInQuad(t) { return t * t; }
function easeOutQuad(t) { return t * (2 - t); }
function easeInOutQuad(t) {
  return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function easeOutElastic(t) {
  return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * Math.PI * 2 / 0.3) + 1;
}
function easeOutBounce(t) {
  if (t < 1 / 2.75) return 7.5625 * t * t;
  if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
  if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
  return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
}
Demo: Easing Function Compare
HTML
<style>
.ball { width: 20px; height: 20px; background: #6366f1; border-radius: 50%; position: absolute; }
.track { height: 40px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 4px; position: relative; margin: 2px 0; overflow: hidden; }
.track-label { position: absolute; left: 8px; top: 10px; font-size: 0.7rem; color: #94a3b8; z-index: 1; font-family: monospace; }
</style>
<div style='display:flex;gap:8px;margin-bottom:8px;'>
<button id='easing-start'>Run All</button>
<button id='easing-reset'>Reset</button>
</div>
<div id='easing-container'></div>
<pre id='easing-out' style='background:#f1f5f9;padding:12px;border-radius:6px;'></pre>
JavaScript
const container = document.getElementById('easing-container');
const out = document.getElementById('easing-out');

const easings = [
{ name: 'linear', fn: t => t },
{ name: 'easeOutQuad', fn: t => t * (2 - t) },
{ name: 'easeOutCubic', fn: t => 1 - Math.pow(1 - t, 3) },
{ name: 'easeOutElastic', fn: t => Math.pow(2, -10 * t) * Math.sin((t - 0.075) * Math.PI * 2 / 0.3) + 1 },
{ name: 'easeOutBounce', fn: t => { if (t < 1/2.75) return 7.5625*t*t; if (t < 2/2.75) return 7.5625*(t-=1.5/2.75)*t+.75; if (t < 2.5/2.75) return 7.5625*(t-=2.25/2.75)*t+.9375; return 7.5625*(t-=2.625/2.75)*t+.984375; } }
];

// Create tracks
easings.forEach((e, i) => {
const track = document.createElement('div');
track.className = 'track';
track.id = 'track-' + i;
const label = document.createElement('span');
label.className = 'track-label';
label.textContent = e.name;
track.appendChild(label);
const ball = document.createElement('div');
ball.className = 'ball';
ball.id = 'ball-' + i;
track.appendChild(ball);
container.appendChild(track);
});

document.getElementById('easing-start').onclick = function() {
this.disabled = true;
out.textContent = 'Running...';
const duration = 2000;
const startTime = performance.now();
let anyRunning = true;

function animate(time) {
const elapsed = time - startTime;
const progress = Math.min(elapsed / duration, 1);

easings.forEach((e, i) => {
const ball = document.getElementById('ball-' + i);
const track = document.getElementById('track-' + i);
const maxX = track.clientWidth - 20;
const eased = e.fn(progress);
ball.style.left = (eased * maxX) + 'px';
});

out.textContent = 'Progress: ' + (progress * 100).toFixed(0) + '%';

if (progress < 1) {
requestAnimationFrame(animate);
} else {
out.textContent = 'βœ… All done!';
document.getElementById('easing-start').disabled = false;
}
}

requestAnimationFrame(animate);
};

document.getElementById('easing-reset').onclick = function() {
easings.forEach((e, i) => {
document.getElementById('ball-' + i).style.left = '0px';
});
out.textContent = 'Reset';
};
Live Output Window

The Animation Function Pattern

function animateElement(element, targetProps, duration, easing) {
  const start = performance.now();
  const startStyles = {};

  // Capture starting values
  for (const prop in targetProps) {
    const computed = getComputedStyle(element);
    startStyles[prop] = parseFloat(computed[prop]) || 0;
  }

  function frame(time) {
    const elapsed = time - start;
    const progress = Math.min(elapsed / duration, 1);
    const eased = easing(progress);

    for (const prop in targetProps) {
      const from = startStyles[prop];
      const to = targetProps[prop];
      const current = from + (to - from) * eased;
      element.style[prop] = current + 'px';
    }

    if (progress < 1) requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
}

// Usage
animateElement(
  box,
  { left: 300, top: 200 },
  1000,
  easeOutCubic
);

FPS Counter

Monitor animation performance:

let frameCount = 0;
let lastFpsUpdate = performance.now();

function measureFps() {
  frameCount++;
  const now = performance.now();

  if (now - lastFpsUpdate >= 1000) {
    console.log(`FPS: ${frameCount}`);
    frameCount = 0;
    lastFpsUpdate = now;
  }

  requestAnimationFrame(measureFps);
}

requestAnimationFrame vs setInterval

FeaturerequestAnimationFramesetInterval
FiresBefore next paintEvery N ms
FPSMatches refresh rate (60/120)Fixed interval
Pauses on tab switchβœ… Yes❌ No
Battery friendlyβœ… Yes❌ No
PrecisionHigher (vsync)Lower

Always prefer requestAnimationFrame for visual animations.

Key Takeaways

  • requestAnimationFrame is the correct tool for JS-driven animations
  • Pass a timing function (easing) to control acceleration
  • Calculate progress from elapsed time, not from frame count
  • Use performance.now() for high-resolution timing
  • requestAnimationFrame auto-pauses when tab is hidden
  • For simple animations, prefer CSS transitions/keyframes (GPU-accelerated)
  • For complex logic (physics, dynamic paths), use JavaScript animations