⏱️ Lesson 18: Timers & Animation
JavaScript doesn't just respond to clicks — it can schedule code to run later, repeat at intervals, and sync with the browser's rendering engine. In this lesson you'll learn the timing APIs that power countdowns, auto-saves, live clocks, and smooth animations.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Delay code execution with
setTimeoutand cancel it withclearTimeout - Run code at regular intervals with
setIntervaland stop it withclearInterval - Build practical features like countdowns, debounce, and auto-save
- Create smooth animations with
requestAnimationFrame - Trigger CSS transitions from JavaScript by toggling classes
- Understand the event loop and how timers actually work under the hood
Estimated Time: 50 minutes
Project: Build a Pomodoro timer with animated progress
📑 In This Lesson
setTimeout — Run Once, Later
setTimeout schedules a function to run once after a specified delay (in milliseconds). It returns an ID you can use to cancel the timer before it fires.
// Run a function after 2 seconds (2000ms)
setTimeout(() => {
console.log("This runs after 2 seconds");
}, 2000);
// With a named function
function showWelcome() {
console.log("Welcome to the app!");
}
setTimeout(showWelcome, 3000); // 3 seconds
Canceling a Timeout
setTimeout returns a numeric ID. Pass it to clearTimeout to cancel the scheduled function before it runs.
// Schedule a timeout
const timerId = setTimeout(() => {
alert("Time's up!");
}, 5000);
// Cancel it before it fires
const cancelBtn = document.querySelector("#cancel");
cancelBtn.addEventListener("click", () => {
clearTimeout(timerId);
console.log("Timer canceled!");
});
Passing Arguments
// Extra arguments are passed to the callback
setTimeout((name, role) => {
console.log(`Hello ${name}, you are a ${role}`);
}, 1000, "Ray", "developer");
// Equivalent to:
setTimeout(() => {
greet("Ray", "developer");
}, 1000);
⚠️ Common Mistake: Calling Instead of Passing
// ❌ WRONG — this calls the function immediately!
setTimeout(showWelcome(), 2000);
// ✅ RIGHT — pass the function reference
setTimeout(showWelcome, 2000);
// ✅ Also right — wrap in an arrow function
setTimeout(() => showWelcome(), 2000);
Don't add parentheses () when passing a function to setTimeout. Parentheses call the function right away; you want to pass a reference for setTimeout to call later.
setInterval — Run Repeatedly
setInterval runs a function repeatedly at a fixed interval. Like setTimeout, it returns an ID you use with clearInterval to stop it.
// Update a clock every second
const clockDisplay = document.querySelector("#clock");
const intervalId = setInterval(() => {
const now = new Date();
clockDisplay.textContent = now.toLocaleTimeString();
}, 1000);
// Stop the clock
document.querySelector("#stop-clock").addEventListener("click", () => {
clearInterval(intervalId);
});
Building a Countdown
function startCountdown(seconds, display) {
let remaining = seconds;
display.textContent = remaining;
const intervalId = setInterval(() => {
remaining--;
display.textContent = remaining;
if (remaining <= 0) {
clearInterval(intervalId);
display.textContent = "Done!";
}
}, 1000);
return intervalId; // Return so caller can cancel if needed
}
const display = document.querySelector("#countdown");
const timerId = startCountdown(10, display);
setTimeout vs. setInterval
| Feature | setTimeout | setInterval |
|---|---|---|
| How often | Once | Repeatedly |
| Cancel with | clearTimeout(id) | clearInterval(id) |
| Use for | Delays, debounce, one-shot | Clocks, polling, animations |
| Drift risk | N/A | Yes — intervals can drift over time |
Recursive setTimeout — A Better Interval
setInterval fires every N milliseconds regardless of how long the callback takes. If the callback is slow (e.g., a network request), calls can stack up. A recursive setTimeout waits for each execution to finish before scheduling the next.
// setInterval — fires every 2s even if the callback is slow
setInterval(async () => {
await fetchData(); // Takes 1.5s
updateUI(); // Next call fires 0.5s after this finishes
}, 2000);
// Recursive setTimeout — waits for completion, then schedules
async function poll() {
await fetchData(); // Takes 1.5s
updateUI();
setTimeout(poll, 2000); // 2s AFTER this finishes
}
poll(); // Start the cycle
Practical Timer Patterns
Timers aren't just for countdowns. Here are three real-world patterns you'll use constantly.
Debounce — Wait Until the User Stops
Debouncing delays a function until the user stops doing something (like typing). Each new event resets the timer. This is perfect for search-as-you-type, where you don't want to fire a request on every single keystroke.
function debounce(fn, delay) {
let timerId;
return (...args) => {
clearTimeout(timerId);
timerId = setTimeout(() => fn(...args), delay);
};
}
// Usage: search only after user stops typing for 300ms
const searchInput = document.querySelector("#search");
const handleSearch = debounce((event) => {
console.log("Searching for:", event.target.value);
// fetch(`/api/search?q=${event.target.value}`)...
}, 300);
searchInput.addEventListener("input", handleSearch);
Throttle — Limit How Often It Runs
Throttling ensures a function runs at most once per time window, no matter how often the event fires. Great for scroll and resize handlers.
function throttle(fn, limit) {
let waiting = false;
return (...args) => {
if (!waiting) {
fn(...args);
waiting = true;
setTimeout(() => { waiting = false; }, limit);
}
};
}
// Usage: handle scroll at most every 200ms
window.addEventListener("scroll", throttle(() => {
console.log("Scroll position:", window.scrollY);
}, 200));
Auto-Save with Debounce
const editor = document.querySelector("#editor");
const statusEl = document.querySelector("#save-status");
const autoSave = debounce((content) => {
// Save to localStorage (or send to server)
localStorage.setItem("draft", content);
statusEl.textContent = "Saved!";
setTimeout(() => { statusEl.textContent = ""; }, 2000);
}, 1000);
editor.addEventListener("input", (event) => {
statusEl.textContent = "Saving...";
autoSave(event.target.value);
});
Execute function"] end subgraph Throttle T1["Event fires"] --> T2["Execute function"] T2 --> T3["Lock for N ms"] T3 --> T4["Events ignored"] T4 --> T5["Unlock"] T5 --> T6["Next event executes"] end style D1 fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style D3 fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style D6 fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style T1 fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style T2 fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style T4 fill:#fef2f2,stroke:#ef4444,stroke-width:2px,color:#1e293b style T6 fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style D2 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style D4 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style D5 fill:#f8fafc,stroke:#94a3b8,stroke-width:2px,color:#1e293b style T3 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style T5 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b
💡 When to Debounce vs. Throttle
- Debounce when you only care about the final value: search input, form auto-save, window resize calculations
- Throttle when you need periodic updates during continuous activity: scroll position tracking, mouse move effects, rate-limited API calls
How Timers Really Work: The Event Loop
JavaScript is single-threaded — it can only do one thing at a time. So how do timers work without freezing the page? The answer is the event loop.
console.log("1: Start");
setTimeout(() => {
console.log("2: Timeout callback");
}, 0); // 0ms delay!
console.log("3: End");
// Output:
// 1: Start
// 3: End
// 2: Timeout callback ← runs AFTER current code finishes
Even with a delay of 0, the timeout callback doesn't run immediately. It's placed in a task queue and only executes after the current code (the "call stack") finishes. This is the event loop in action.
(current code)"] --> B{"Stack empty?"} B -->|No| A B -->|Yes| C["Event Loop
checks queues"] C --> D["Task Queue
(setTimeout, setInterval,
click handlers)"] D --> E["Move next task
to Call Stack"] E --> A style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style B fill:#f3e8ff,stroke:#a855f7,stroke-width:2px,color:#1e293b style C fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style D fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style E fill:#fce7f3,stroke:#ec4899,stroke-width:2px,color:#1e293b
⚠️ Timer Delays Are Minimums, Not Guarantees
setTimeout(fn, 500) means "run this no sooner than 500ms from now." If the call stack is busy (heavy computation, long-running loop), the callback will wait. The browser also enforces a minimum delay of about 4ms for nested timers. For precise timing (animations, games), use requestAnimationFrame instead.
Why This Matters
// ❌ This will freeze the page — blocking the call stack
for (let i = 0; i < 1000000000; i++) {
// Heavy computation — nothing else can run
}
// ✅ Break heavy work into chunks with setTimeout
function processChunk(data, index, chunkSize) {
const end = Math.min(index + chunkSize, data.length);
for (let i = index; i < end; i++) {
// Process item
}
if (end < data.length) {
// Schedule next chunk — lets other events run between chunks
setTimeout(() => processChunk(data, end, chunkSize), 0);
}
}
requestAnimationFrame — Smooth Animation
requestAnimationFrame (rAF) tells the browser: "Call this function right before the next screen repaint." It runs at the display's refresh rate — usually 60 times per second (60 fps), giving you ~16.7ms per frame. This is the right tool for visual animations.
Basic Animation Loop
const box = document.querySelector(".animated-box");
let position = 0;
function animate() {
position += 2; // Move 2px per frame
box.style.transform = `translateX(${position}px)`;
if (position < 400) {
requestAnimationFrame(animate); // Schedule next frame
}
}
requestAnimationFrame(animate); // Start the animation
Using the Timestamp
rAF passes a high-resolution timestamp to your callback. Use it for frame-rate-independent animation — so the motion looks the same whether the browser runs at 30 fps or 144 fps.
const box = document.querySelector(".animated-box");
const duration = 2000; // 2 seconds
let startTime = null;
function animate(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
// Progress from 0 to 1 over the duration
const progress = Math.min(elapsed / duration, 1);
// Move from 0 to 400px
box.style.transform = `translateX(${progress * 400}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
Canceling an Animation
let animationId;
function animate() {
// ... animation logic ...
animationId = requestAnimationFrame(animate);
}
// Start
animationId = requestAnimationFrame(animate);
// Stop
cancelAnimationFrame(animationId);
Why Not setInterval for Animations?
| Factor | setInterval | requestAnimationFrame |
|---|---|---|
| Syncs with display | No — can cause jank | Yes — smooth frames |
| Background tabs | Keeps running (wastes resources) | Pauses automatically |
| Frame rate | Fixed (you pick ms) | Matches monitor (60/120/144 fps) |
| Battery impact | Higher | Lower (pauses when hidden) |
| Best for | Non-visual timers, polling | Visual animations |
💡 Easing Functions
Linear motion (constant speed) looks robotic. Easing functions make animations feel natural by varying the speed over time.
// Linear: constant speed
const linear = t => t;
// Ease out: fast start, slow finish
const easeOut = t => 1 - Math.pow(1 - t, 3);
// Ease in-out: slow start and end, fast middle
const easeInOut = t => t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
// Usage with rAF
const progress = elapsed / duration;
const eased = easeOut(progress);
box.style.transform = `translateX(${eased * 400}px)`;
CSS Transitions via JavaScript
For many animations, you don't need requestAnimationFrame at all. CSS transitions handle the animation automatically — JavaScript just triggers them by changing a property or toggling a class.
The Pattern: CSS Defines the Animation, JS Triggers It
<style>
.box {
width: 100px;
height: 100px;
background: #3b82f6;
transform: translateX(0);
opacity: 1;
/* CSS transition — the browser animates these properties */
transition: transform 0.5s ease, opacity 0.3s ease;
}
.box.slide-right {
transform: translateX(300px);
}
.box.fade-out {
opacity: 0;
}
</style>
const box = document.querySelector(".box");
const slideBtn = document.querySelector("#slide");
const fadeBtn = document.querySelector("#fade");
// Toggle the class — CSS handles the animation
slideBtn.addEventListener("click", () => {
box.classList.toggle("slide-right");
});
fadeBtn.addEventListener("click", () => {
box.classList.toggle("fade-out");
});
Listening for Transition End
The transitionend event fires when a CSS transition finishes. Use it to chain actions or clean up.
const notification = document.querySelector(".notification");
// Fade out, then remove from DOM
function dismissNotification(el) {
el.classList.add("fade-out");
el.addEventListener("transitionend", () => {
el.remove();
}, { once: true }); // Auto-remove the listener
}
dismissNotification(notification);
Triggering Transitions on Dynamic Elements
New elements start with their final styles — there's no "before" state for the browser to transition from. You need to force a layout calculation (a "reflow") between setting the initial state and the final state.
const toast = document.createElement("div");
toast.classList.add("toast"); // Has transition: opacity 0.3s
toast.style.opacity = "0"; // Start invisible
toast.textContent = "Saved!";
document.body.append(toast);
// Force reflow — the browser now "knows" opacity is 0
toast.offsetHeight; // Reading any layout property triggers reflow
// NOW set the final state — the transition runs
toast.style.opacity = "1";
// Auto-dismiss after 3 seconds
setTimeout(() => {
toast.style.opacity = "0";
toast.addEventListener("transitionend", () => toast.remove(), { once: true });
}, 3000);
✅ CSS vs. JavaScript Animation — Rules of Thumb
- Use CSS transitions for simple state changes: hover effects, show/hide, slide in/out, color changes
- Use CSS @keyframes for looping or complex multi-step animations that don't depend on user input
- Use requestAnimationFrame for animations that depend on dynamic values (mouse position, game physics, canvas drawing)
- Prefer CSS when possible — the browser can optimize CSS animations on the GPU, and they don't block the main thread
Hands-on Exercise
🏋️ Exercise: Pomodoro Timer with Animated Progress
Objective: Build a Pomodoro-style countdown timer with start, pause, and reset controls, a circular progress indicator, and a notification when time is up.
<!-- Save this as pomodoro.html and open in a browser -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Pomodoro Timer</title>
<style>
* { box-sizing: border-box; margin: 0; }
body {
font-family: system-ui, sans-serif; background: #0f172a;
color: #e2e8f0; display: flex; justify-content: center;
align-items: center; min-height: 100vh;
}
.timer-container { text-align: center; }
h1 { margin-bottom: 2rem; font-size: 1.5rem; color: #94a3b8; }
.timer-ring {
width: 280px; height: 280px; margin: 0 auto 2rem;
position: relative;
}
.timer-ring svg { transform: rotate(-90deg); }
.timer-ring circle {
fill: none; stroke-width: 8;
}
.bg-ring { stroke: #1e293b; }
.progress-ring {
stroke: #6366f1;
stroke-linecap: round;
transition: stroke-dashoffset 0.5s ease;
}
.timer-display {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
font-size: 3.5rem; font-weight: 700;
font-variant-numeric: tabular-nums;
}
.timer-label {
position: absolute; bottom: 55px; left: 50%;
transform: translateX(-50%);
font-size: 0.9rem; color: #94a3b8;
}
.controls { display: flex; gap: 1rem; justify-content: center; margin-bottom: 1.5rem; }
.controls button {
padding: 0.6rem 1.5rem; border: 2px solid #334155;
border-radius: 8px; background: #1e293b; color: #e2e8f0;
font-size: 1rem; cursor: pointer; transition: all 0.2s;
}
.controls button:hover { border-color: #6366f1; background: #334155; }
.controls button.active { background: #6366f1; border-color: #6366f1; }
.presets { display: flex; gap: 0.5rem; justify-content: center; }
.presets button {
padding: 0.4rem 1rem; border: 1px solid #334155; border-radius: 6px;
background: transparent; color: #94a3b8; cursor: pointer;
font-size: 0.85rem; transition: all 0.2s;
}
.presets button:hover { color: #e2e8f0; border-color: #6366f1; }
.presets button.selected { background: #334155; color: #e2e8f0; border-color: #6366f1; }
.done-message {
font-size: 1.2rem; color: #22c55e; font-weight: 600;
opacity: 0; transition: opacity 0.5s;
}
.done-message.visible { opacity: 1; }
</style>
</head>
<body>
<div class="timer-container">
<h1>🍅 Pomodoro Timer</h1>
<div class="timer-ring">
<svg width="280" height="280">
<circle class="bg-ring" cx="140" cy="140" r="130"></circle>
<circle class="progress-ring" id="progress-ring"
cx="140" cy="140" r="130"
stroke-dasharray="816.81"
stroke-dashoffset="0"></circle>
</svg>
<div class="timer-display" id="timer-display">25:00</div>
<div class="timer-label" id="timer-label">Focus Time</div>
</div>
<div class="controls">
<button id="start-btn">▶ Start</button>
<button id="pause-btn">⏸ Pause</button>
<button id="reset-btn">↺ Reset</button>
</div>
<div class="presets">
<button class="preset-btn selected" data-minutes="25">25 min</button>
<button class="preset-btn" data-minutes="15">15 min</button>
<button class="preset-btn" data-minutes="5">5 min</button>
<button class="preset-btn" data-minutes="1">1 min</button>
</div>
<p class="done-message" id="done-message">🎉 Time's up! Great work!</p>
</div>
<script>
// TODO: Complete each task
// The circumference of the circle (2 * π * r where r = 130)
const CIRCUMFERENCE = 2 * Math.PI * 130; // ~816.81
// 1. Get DOM references:
// - #progress-ring, #timer-display, #timer-label, #done-message
// - #start-btn, #pause-btn, #reset-btn
// - All .preset-btn buttons
// 2. Set up state variables:
// - totalSeconds (default 25 * 60)
// - remainingSeconds (starts equal to totalSeconds)
// - intervalId (null when not running)
// - isRunning (boolean)
// 3. Write updateDisplay(seconds):
// - Convert seconds to MM:SS format
// - Update #timer-display text
// - Calculate progress (0 to 1) and update
// stroke-dashoffset on #progress-ring
// Formula: CIRCUMFERENCE * (1 - remaining / total)
// 4. Write start():
// - If already running, return
// - Set isRunning = true, hide done message
// - Use setInterval (1000ms) to decrement remainingSeconds
// - Call updateDisplay each tick
// - When remainingSeconds hits 0, clearInterval,
// show done message, mark not running
// 5. Write pause() and reset():
// - pause: clearInterval, set isRunning = false
// - reset: pause, set remainingSeconds = totalSeconds,
// updateDisplay, hide done message
// 6. Hook up buttons:
// - Start → start()
// - Pause → pause()
// - Reset → reset()
// 7. Hook up preset buttons:
// - On click: set totalSeconds and remainingSeconds
// - Reset display, highlight selected preset
// - Pause if running
// 8. Bonus: Change the ring color to red when < 30 seconds remain
// and to green when timer completes
</script>
</body>
</html>
💡 Hint
3: To format time: String(minutes).padStart(2, "0") ensures "05" instead of "5". For the ring, stroke-dashoffset controls how much of the circle is hidden — 0 = full, CIRCUMFERENCE = empty.
4: Remember to store the interval ID so you can clear it later. Decrement first, then check if it's 0, then update display.
7: Use event delegation on the presets container. Read data-minutes from the clicked button. Remove selected from all presets, add it to the clicked one.
8: Inside updateDisplay, check remainingSeconds < 30 and change progressRing.style.stroke.
✅ Solution
const CIRCUMFERENCE = 2 * Math.PI * 130;
// 1. DOM references
const progressRing = document.querySelector("#progress-ring");
const timerDisplay = document.querySelector("#timer-display");
const timerLabel = document.querySelector("#timer-label");
const doneMessage = document.querySelector("#done-message");
const startBtn = document.querySelector("#start-btn");
const pauseBtn = document.querySelector("#pause-btn");
const resetBtn = document.querySelector("#reset-btn");
const presetBtns = document.querySelectorAll(".preset-btn");
// 2. State
let totalSeconds = 25 * 60;
let remainingSeconds = totalSeconds;
let intervalId = null;
let isRunning = false;
// 3. Update the display
function updateDisplay(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
timerDisplay.textContent =
`${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
// Update ring progress
const progress = 1 - (seconds / totalSeconds);
const offset = CIRCUMFERENCE * progress;
progressRing.style.strokeDashoffset = offset;
// 8. Bonus: color changes
if (seconds === 0) {
progressRing.style.stroke = "#22c55e";
} else if (seconds < 30) {
progressRing.style.stroke = "#ef4444";
} else {
progressRing.style.stroke = "#6366f1";
}
}
// 4. Start the timer
function start() {
if (isRunning) return;
if (remainingSeconds <= 0) return;
isRunning = true;
doneMessage.classList.remove("visible");
intervalId = setInterval(() => {
remainingSeconds--;
updateDisplay(remainingSeconds);
if (remainingSeconds <= 0) {
clearInterval(intervalId);
intervalId = null;
isRunning = false;
doneMessage.classList.add("visible");
}
}, 1000);
}
// 5. Pause and reset
function pause() {
clearInterval(intervalId);
intervalId = null;
isRunning = false;
}
function reset() {
pause();
remainingSeconds = totalSeconds;
updateDisplay(remainingSeconds);
doneMessage.classList.remove("visible");
}
// 6. Button handlers
startBtn.addEventListener("click", start);
pauseBtn.addEventListener("click", pause);
resetBtn.addEventListener("click", reset);
// 7. Preset buttons
presetBtns.forEach(btn => {
btn.addEventListener("click", () => {
presetBtns.forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
totalSeconds = Number(btn.dataset.minutes) * 60;
remainingSeconds = totalSeconds;
pause();
updateDisplay(remainingSeconds);
doneMessage.classList.remove("visible");
});
});
// Initial display
updateDisplay(remainingSeconds);
🎯 Quick Quiz
Question 1: What does setTimeout return?
Question 2: What will this code output?
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
Question 3: What's the difference between setInterval and recursive setTimeout?
Question 4: Why use requestAnimationFrame instead of setInterval for visual animations?
Question 5: What is debouncing?
Summary
🎉 Key Takeaways
setTimeout(fn, ms)runs a function once after a delay; cancel withclearTimeout(id)setInterval(fn, ms)runs a function repeatedly; cancel withclearInterval(id)- Recursive
setTimeoutis safer thansetIntervalfor async work — it waits for completion before scheduling - Debounce waits for events to stop; Throttle limits frequency — both use
setTimeoutinternally - Timer delays are minimums, not guarantees — the event loop processes callbacks only when the call stack is empty
- Even
setTimeout(fn, 0)waits for current code to finish (the call stack must be empty) requestAnimationFramesyncs with the display for smooth animations and pauses in background tabs- Use the timestamp parameter from rAF for frame-rate-independent animation
- CSS transitions are the best choice for simple state-change animations — JS just toggles a class
- Use
transitionendto run code after a CSS transition completes
📚 Additional Resources
- MDN — setTimeout()
- MDN — setInterval()
- MDN — requestAnimationFrame()
- MDN — Using CSS Transitions
- javascript.info — setTimeout & setInterval
- javascript.info — Event Loop
- easings.net — Easing Function Cheat Sheet
🚀 What's Next?
Your pages can now respond to input and move on screen. But what happens when the user leaves? All that data disappears. In the next lesson, you'll learn to persist state with Local Storage — saving user preferences, form drafts, and app data so it survives page refreshes and browser restarts.