Scroll Reveal
Reveal elements as they enter the viewport using CSS scroll-driven animations with an IntersectionObserver fallback.
Interactive demo
Scroll down ↓
Reveal item 1
This element fades up as it enters the viewport. The modern version uses
animation-timeline: view(); older browsers fall back to IntersectionObserver.
Reveal item 2
This element fades up as it enters the viewport. The modern version uses
animation-timeline: view(); older browsers fall back to IntersectionObserver.
Reveal item 3
This element fades up as it enters the viewport. The modern version uses
animation-timeline: view(); older browsers fall back to IntersectionObserver.
Reveal item 4
This element fades up as it enters the viewport. The modern version uses
animation-timeline: view(); older browsers fall back to IntersectionObserver.
Reveal item 5
This element fades up as it enters the viewport. The modern version uses
animation-timeline: view(); older browsers fall back to IntersectionObserver.
Reveal item 6
This element fades up as it enters the viewport. The modern version uses
animation-timeline: view(); older browsers fall back to IntersectionObserver.
What it does
Elements fade and slide into view as they enter the viewport, creating a sense of progressive discovery while scrolling.
When to use it
- Landing page sections that you want to introduce gradually.
- Lists, cards or galleries where staggered entrance adds rhythm.
- Long-form content to keep the reader engaged.
When to avoid it
- Above-the-fold content that must be visible immediately.
- Critical UI controls that could be missed if hidden initially.
- Pages where users expect instant full content, such as documentation.
- When reduced motion is enabled unless content is shown instantly.
How it works
- Modern path — use CSS
animation-timeline: view()andanimation-rangeto drive the reveal. - Fallback path — if
animation-timelineis unsupported, use IntersectionObserver to toggle anis-visibleclass. - Animate properties — interpolate opacity, translateY and optionally filter blur.
- Stagger — delay sibling elements with transition-delay or custom properties.
- Reduced motion — show elements at full opacity with no transform.
Source code
<main class="scroll-container">
<section class="reveal-section" data-reveal>
<h2>Section one</h2>
<p>This element reveals as it enters the viewport.</p>
</section>
<section class="reveal-section" data-reveal>
<h2>Section two</h2>
<p>Progressive enhancement lets older browsers fall back gracefully.</p>
</section>
<section class="reveal-section" data-reveal>
<h2>Section three</h2>
<p>CSS scroll-driven animations keep the work off the main thread.</p>
</section>
</main> :root {
color-scheme: dark;
--bg: #0a0a0b;
--surface: #16161a;
--border: rgba(255, 255, 255, 0.1);
--text: #f4f4f5;
--muted: #a1a1aa;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
.scroll-container {
display: grid;
gap: 4rem;
padding: 80vh 1.5rem 80vh;
}
.reveal-section {
max-width: 640px;
margin-inline: auto;
padding: 2rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 1rem;
/* Modern path */
animation: reveal-enter both;
animation-timeline: view();
animation-range: entry 10% cover 30%;
}
@keyframes reveal-enter {
from {
opacity: 0;
transform: translateY(30px);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
/* Fallback class set by JS when animation-timeline is unsupported */
.reveal-section.is-hidden {
opacity: 0;
transform: translateY(30px);
filter: blur(4px);
transition: opacity 500ms ease-out,
transform 500ms ease-out,
filter 500ms ease-out;
}
.reveal-section.is-visible {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
@media (prefers-reduced-motion: reduce) {
.reveal-section {
animation: none;
opacity: 1;
transform: none;
filter: none;
}
} function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function supportsScrollTimeline(): boolean {
return CSS.supports("animation-timeline", "view()");
}
function initScrollReveal(): void {
if (prefersReducedMotion()) return;
if (supportsScrollTimeline()) return;
const items = document.querySelectorAll<HTMLElement>("[data-reveal]");
items.forEach((item) => item.classList.add("is-hidden"));
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" }
);
items.forEach((item) => observer.observe(item));
}
initScrollReveal();
export {}; Implementation recipe
Scroll Reveal Implementation Recipe
Goal
Reveal elements as they scroll into the viewport using CSS scroll-driven animations, with a JavaScript fallback for older browsers.
Requirements
- Elements hidden or offset before entering the viewport.
- Smooth entrance animation driven by scroll position.
- Fallback for browsers without
animation-timeline: view(). - Reduced-motion support.
Files to create
index.htmlstyles.cssscroll-reveal.ts
Step 1: Markup
Add a data-reveal attribute to each element you want to animate.
Step 2: Modern CSS animation
Use animation-timeline: view() to bind the animation to the element's visibility in the viewport. Define an animation-range to control when the animation starts and ends.
.reveal-section {
animation: reveal-enter both;
animation-timeline: view();
animation-range: entry 10% cover 30%;
}
@keyframes reveal-enter {
from {
opacity: 0;
transform: translateY(30px);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
Step 3: Fallback CSS
Add classes that will be toggled by IntersectionObserver when scroll timelines are unavailable.
.reveal-section.is-hidden {
opacity: 0;
transform: translateY(30px);
filter: blur(4px);
transition: opacity 500ms ease-out,
transform 500ms ease-out,
filter 500ms ease-out;
}
.reveal-section.is-visible {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
Step 4: Feature detection
Check support with CSS.supports("animation-timeline", "view()"). If supported, do nothing; the CSS handles everything.
Step 5: IntersectionObserver fallback
If unsupported, add the hidden class to every item and observe them. When an item intersects, add is-visible and stop observing it.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
}, { threshold: 0.15, rootMargin: "0px 0px -40px 0px" });
Step 6: Reduced motion
If prefers-reduced-motion is active, skip the observer and make elements visible immediately.
Common pitfalls
- Hiding focusable elements with
opacity: 0can make them unreachable. - Blur filters can be expensive on large areas.
- Forgetting the fallback leaves older browsers with invisible content.
Verification checklist
- Elements reveal smoothly while scrolling.
- The effect works in browsers that support
animation-timeline: view(). - Unsupported browsers use the IntersectionObserver fallback.
- Reduced motion shows content immediately.
- Focusable elements become visible quickly enough to be usable.
- Ensure content is readable and interactive even before the reveal completes.
- Do not hide focusable elements with opacity: 0 for long; they should be visible quickly.
- Respect prefers-reduced-motion by removing the reveal animation entirely.
- Keep the DOM order logical so screen readers announce content in the right sequence.
- Prefer animation-timeline over JavaScript scroll listeners.
- Use IntersectionObserver instead of scroll event handlers for the fallback.
- Animate only transform and opacity to stay on the compositor.
- Avoid blur filters on large areas; they can be GPU expensive.
- Throttle any remaining scroll work with requestAnimationFrame.
| API / Feature | Support | Fallback strategy |
|---|---|---|
animation-timeline: view() | Chrome 115+, Edge 115+, Safari 18+ | IntersectionObserver toggles a visible class. |
IntersectionObserver | All modern browsers | Show all elements immediately. |
prefers-reduced-motion | All modern browsers | Instant visibility with no motion. |