JavaScript & Motion
Fade and slide elements in as they scroll into view — with IntersectionObserver instead of a scroll-event listener, revealing each item once and bowing out gracefully for anyone who prefers reduced motion.
Live Result
Scroll inside the frame — each card fades and lifts into place as it enters the viewport, and stays put once revealed.
<section class="reveal-list">
<article class="reveal card">
<h3>Strategy first</h3>
<p>We start with your story, not a template.</p>
</article>
<article class="reveal card">
<h3>Designed to fit</h3>
<p>Every layout is built around your content.</p>
</article>
<article class="reveal card">
<h3>Built to last</h3>
<p>Hand-coded, fast, and easy to maintain.</p>
</article>
</section> .reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity .6s ease, transform .6s ease;
will-change: opacity, transform;
}
/* Stagger: nudge each item slightly later for a cascade */
.reveal:nth-child(2) { transition-delay: .08s; }
.reveal:nth-child(3) { transition-delay: .16s; }
/* The class the observer adds when the item scrolls into view */
.reveal.is-visible {
opacity: 1;
transform: none;
}
/* Never hide content from people who asked for less motion */
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
} const reveals = document.querySelectorAll('.reveal');
// Bail to "always visible" if motion is reduced or IO is unsupported.
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduceMotion || !('IntersectionObserver' in window)) {
reveals.forEach((el) => el.classList.add('is-visible'));
} else {
const io = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); // reveal once, then stop watching
});
}, {
threshold: 0.15, // fire when ~15% of the item is showing
rootMargin: '0px 0px -10%' // trigger a little before it hits the bottom
});
reveals.forEach((el) => io.observe(el));
} The old way to do this was a scroll event listener that measured every element's position on every frame. It works, but it runs your code dozens of times a second on the main thread — janky on long pages and a drain on phones. IntersectionObserver flips the model: you tell the browser which elements you care about, and it notifies you only when their visibility actually changes, off the main thread.
The CSS holds the resting state — invisible and nudged down 24px — and a single .is-visible class releases it to its natural position. Keeping the animation entirely in CSS means the JavaScript's only job is to add one class at the right moment, so the GPU handles the smooth part.
Two details make it production-ready. First, observer.unobserve() after each reveal: the element animates in once and the browser stops tracking it, so you're not holding observers open for content that's already shown. Second, the rootMargin of -10% fires the reveal slightly before an item reaches the very bottom edge, so it's finished animating by the time it's comfortably in view rather than starting late.
Most important is the prefers-reduced-motion guard. Scroll animations can trigger nausea and vertigo for some people, and the OS-level setting is how they ask sites to ease off. Here, if that setting is on — or if IntersectionObserver isn't available at all — every item is simply shown immediately. The content is never hidden behind an animation that might not play, which keeps the page accessible by default.