18 — Bringing It to Life
✨ Chapter 18 — Bringing It to Life
Every chapter so far has been pure CSS. This one adds a small, deliberate amount of real JavaScript — just enough to make two effects work that CSS genuinely cannot do alone: detecting when an element scrolls into view, and reacting to the page's scroll position. Everything else about how these effects look is still CSS; JavaScript's only job is to flip a class on and off at the right moment.
Part 1 — The .reveal CSS
Before writing any JavaScript, we define the two CSS states the observer will switch between. Elements start hidden and translated down; the observer adds .visible to animate them into place.
/* ── Scroll reveal ───────────────────────────────── */
.reveal {
opacity: 0;
transform: translateY(24px); /* starts 24 px below final position */
transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
/* Accessibility: skip animation for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1; /* immediately visible */
transform: none;
transition: none;
}
}
/* Nav shadow when scrolled */
.site-header.scrolled {
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
}
@media (prefers-reduced-motion: reduce) block makes all .reveal elements immediately visible — no fade, no slide. Always include this when adding scroll animations.
Part 2 — IntersectionObserver (the reveal)
The old approach to scroll animations was a scroll event listener that ran on every pixel of scroll and called getBoundingClientRect() — expensive and janky. IntersectionObserver is the modern replacement: it tells the browser "notify me when this element crosses the viewport threshold" and fires asynchronously on the main thread only when needed.
.reveal elementtrue — add the .visible class to trigger the CSS transition.// ── Scroll reveal ──────────────────────────────────
const revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
revealObserver.unobserve(entry.target); // fire once only
}
});
},
{ threshold: 0.15 } // trigger when 15% visible
);
document.querySelectorAll('.reveal').forEach(el => {
revealObserver.observe(el);
});
The observer fires asynchronously — it doesn't block the main thread on every scroll event, unlike a window.addEventListener('scroll', …) approach.
Part 3 — Nav scroll shadow
The sticky nav gains a box-shadow once the user scrolls past 20 px. A scroll event listener toggles the .scrolled class; the CSS transition on box-shadow (already on .site-header) makes it fade in smoothly.
// ── Nav scroll shadow ──────────────────────────────
const header = document.querySelector('.site-header');
window.addEventListener('scroll', () => {
header.classList.toggle('scrolled', window.scrollY > 20);
}, { passive: true }); // passive: true → browser can scroll without waiting
classList.toggle(className, condition) is a one-liner: adds the class when condition is true, removes it when false. No if/else needed.
preventDefault(), so it doesn't need to wait for the listener to finish before scrolling. This eliminates the small lag that scroll listeners can introduce on mobile.
Live demo 1 — scroll reveal
Cards start hidden. Scroll down inside the box to watch them fade and rise into view. The status dots below track which cards have been revealed. Click Reset to run it again.
↓ scroll down to reveal cards
Brand Identity
Logos, colour systems, and visual language that last.
Web Design
Sites that perform as beautifully as they look.
Motion
Animation that adds meaning, not just movement.
Strategy
Decisions grounded in research, not guesswork.
Live demo 2 — nav scroll shadow
Scroll inside the box — the sticky nav gains a shadow as soon as you leave the top.
Step 13 — complete
.reveal— hidden state:opacity: 0+translateY(24px)✓.reveal.visible— animated into place ✓prefers-reduced-motion— skips animation for users who need it ✓IntersectionObserver— adds.visibleat 15% threshold, fires once ✓.site-header.scrolled— nav shadow toggled by scroll position ✓{ passive: true }— scroll listener doesn't block the browser ✓