Learning Goals
3 minBy the end of this lesson you will be able to:
- Make a change animate by adding a CSS
transition. - Trigger it from JavaScript with a single
classList.toggle(). - Delay code with
setTimeoutfor a message that fades away. - Respect
prefers-reduced-motionfor visitors who need less movement.
Warm-Up · Did Something Just Happen?
5 minLast lesson your form added items to a list. Here is the success message from that lesson.
message.textContent = 'Added 2 × nasi lemak.';Predict together
- If a visitor is looking at the input box, will they notice the message?
- They submit a second time with the same text. How do they know it worked twice?
Reveal
They probably will not. The text appears with no movement at all, and on the second submit nothing visibly changes — the same words are already there.
Motion solves both. A message that fades in draws the eye, and one that fades out and back proves something happened again.
New Concept · Let CSS Do the Moving
12 minA door with a soft closer does not need you to push it slowly — you just let go, and the mechanism handles the smooth part. CSS transitions are that closer. JavaScript lets go; CSS does the movement.
A transition in one line
.card {
background: #EEF2FF;
transition: background 0.3s ease;
}
.card.selected {
background: #C7D2FE;
}transition says: whenever background changes, take 0.3 seconds. Now the JavaScript is unchanged from Lesson 15:
card.classList.toggle('selected');One line, and the colour glides instead of jumping. You wrote no animation code at all.
Animate in JavaScript
Loops, timers, maths on every frame.
Easy to make janky.
Design decisions buried in logic.
Animate in CSS
One transition line.
Smooth, because the browser optimises it.
Designers can change it without touching your code.
What to animate
Some properties are cheap for the browser to animate, and some force it to re-do the whole page layout.
- Cheap and smooth:
opacity,transform(move, scale, rotate). - Expensive:
width,height,top,margin— these shift everything around them.
Prefer transform: translateY(-8px) over changing top. Same look, far smoother.
Fading something in
.toast {
opacity: 0;
transform: translateY(-8px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.visible {
opacity: 1;
transform: translateY(0);
}setTimeout — doing something later
toast.classList.add('visible');
setTimeout(() => {
toast.classList.remove('visible');
}, 2000); // 2000 milliseconds = 2 secondssetTimeout takes a callback and a delay in milliseconds. It is the same hand-over-a-function idea as addEventListener in Lesson 16 — no brackets on the function you pass.
The code after it does not wait. The rest of your script carries on and the callback fires later. That is your first taste of asynchronous JavaScript, which Section C is entirely about.
Not everybody wants motion
Some people feel unwell from movement on screen, and set a system preference asking for less of it. Honour it in one CSS block:
@media (prefers-reduced-motion: reduce) {
.toast,
.card {
transition: none;
}
}Everything still works — it simply arrives instantly. This is a real accessibility requirement, not a nicety.
Why it matters
Motion tells the visitor what just happened and where to look. Because it lives in CSS and is triggered by a class, this exact technique carries straight into React — where you will toggle the same classes from state.
Worked Example · A Toast That Fades
12 minAdd a fading confirmation to the order form from Lesson 17. Work in form.html, styles.css and app.js.
Step 1 — The two states in CSS
#message {
opacity: 0;
transform: translateY(-8px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
#message.visible {
opacity: 1;
transform: translateY(0);
}The message is invisible by default. Nothing in JavaScript yet — reload and the message never shows, which is expected.
Step 2 — Show it on success
function showMessage(text) {
message.textContent = text;
message.classList.add('visible');
}Call showMessage(`Added ${qty} × ${dish}.`) where you previously set textContent directly. Submit the form — the message now glides into place.
Step 3 — Hide it again after two seconds
function showMessage(text) {
message.textContent = text;
message.classList.add('visible');
setTimeout(() => {
message.classList.remove('visible');
}, 2000);
}It fades in, waits, fades out. Two class changes and one timer — no animation code anywhere.
Step 4 — Fix the rapid-submit bug
Submit twice quickly. The first timer still fires and hides your second message early. Remember the previous timer and cancel it:
let hideTimer = null; // outside: it must survive
function showMessage(text) {
message.textContent = text;
message.classList.add('visible');
clearTimeout(hideTimer); // cancel any timer still pending
hideTimer = setTimeout(() => {
message.classList.remove('visible');
}, 2000);
}setTimeout hands back an id, and clearTimeout cancels it. Note where hideTimer lives — outside the function, for exactly the reason the counter lived outside the listener in Lesson 16.
Step 5 — Animate new list items too
#order li {
opacity: 0;
transition: opacity 0.3s ease;
}
#order li.shown {
opacity: 1;
}const li = document.createElement('li');
li.textContent = `${qty} × ${dish}`;
orderList.append(li);
requestAnimationFrame(() => li.classList.add('shown'));What changed? Adding shown on the very same line as append would give no fade — the browser never saw the starting state, so there is nothing to transition from.
requestAnimationFrame waits until just before the next repaint, so the element exists at opacity: 0 first and then changes. This catches out a great many developers, so it is worth remembering.
Try It Yourself
13 minKeep working on your order form.
Task 1 — A hover lift
Give each list item a
transition: transform 0.2s ease;and a:hoverrule that lifts it withtransform: translateY(-3px);.No JavaScript at all — CSS can handle hover by itself.
Task 2 — Shake on error
Make the message shake when validation fails. Add a
.errorclass that turns it red, and remove it after 500ms withsetTimeoutso it can fire again next time.Use your
clearTimeoutguard so rapid submits behave.Task 3 — A collapsing panel
Add a “Show order” button that toggles a
.openclass on the list, fading it in and out.Then change the button's own text between “Show order” and “Hide order” using
classList.contains()to decide which.
🔥 Mini-Challenge · The Animation That Never Plays
8 minAaina's toast appears instantly and then sticks forever. Find three mistakes.
/* aaina-toast.css — buggy */
#toast {
opacity: 0;
}
#toast.visible {
opacity: 1;
transition: opacity 0.3s ease;
}// aaina-toast.js — buggy
const toast = document.querySelector('#toast');
const button = document.querySelector('#save');
button.addEventListener('click', () => {
toast.classList.add('visible');
setTimeout(toast.classList.remove('visible'), 2000);
});It works if: the toast fades in over 0.3s, stays for 2 seconds, then fades out — every time the button is pressed.
Reveal the answer
Mistake 1 — the transition is on the wrong rule.
It sits on #toast.visible, which only applies while visible. Fading out removes that class, taking the transition with it — so the disappearance is instant. Put transition on the base rule so it applies in both directions.
Mistake 2 — the timeout callback is called immediately.
setTimeout(toast.classList.remove('visible'), 2000) runs remove straight away and passes its return value (undefined) to the timer. It is the Lesson 16 brackets trap in a new place. Wrap it in an arrow function.
Mistake 3 — no reduced-motion fallback.
Nothing honours prefers-reduced-motion. Add the media query so visitors who ask for less movement still get the message, without the fade.
/* aaina-toast.css — fixed */
#toast {
opacity: 0;
transition: opacity 0.3s ease; /* on the base rule: both directions */
}
#toast.visible {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
#toast { transition: none; }
}// aaina-toast.js — fixed
let hideTimer = null;
button.addEventListener('click', () => {
toast.classList.add('visible');
clearTimeout(hideTimer);
hideTimer = setTimeout(() => { // a function, not a call
toast.classList.remove('visible');
}, 2000);
});The first bug is the one to remember: a transition must live where it applies to both states. Put it on the base rule and the element animates in and out.
Recap
3 min- CSS
transitiondoes the movement; JavaScript only toggles a class. - Put
transitionon the base rule so it applies both ways. - Animate
opacityandtransform— they are smooth. Avoidwidth,height,top. setTimeout(fn, ms)runs a callback later — no brackets onfn.- Code after
setTimeoutdoes not wait. That is asynchronous behaviour, and Section C's subject. - Keep the timer id outside, and
clearTimeoutit before starting a new one. - A class added in the same breath as
appendwill not animate — userequestAnimationFrame. - Always honour
prefers-reduced-motion.
New words
- Transition — a CSS rule making a property change over time.
- setTimeout / clearTimeout — run a callback later, or cancel it.
- prefers-reduced-motion — the visitor's request for less movement.
End of Section B
You can now select any part of a page, change it, respond to the visitor and make it feel smooth. Section C leaves your own computer entirely — fetching real data from other servers.
📦 Homework
4 min to brief · ~20 min to doRequired
- Take your
signup.htmlfrom last lesson and add motion: a fading success toast, a red flash on validation errors, and new list items that fade in. - Add a
prefers-reduced-motionblock, and screenshot the form mid-fade. Bring both to next lesson.
Optional stretch
- Try
transition: all 0.3s ease;. It looks convenient — find out why experienced developers avoid it. - Look up CSS
@keyframes. In one sentence, when would you need it instead of a transition?