CSS Scroll-Triggered Reveal
Elements fade and slide up as they enter the viewport using the CSS scroll-driven animation-timeline: view() property.
A CSS scroll-triggered reveal fades and lifts each item as it enters the scroll container, driven by animation-timeline: view(). The staggered cascade is not scheduled anywhere. No item has a delay and no rule counts children, because each one runs on its own position in the scroller and they enter one after another.
That difference matters more than it sounds. A delay based stagger fires once, always at the same speed, and does nothing when the reader scrolls back up. A position based one reverses, tracks a fast flick as accurately as a slow drag, and lands each item at exactly the right point no matter how the reader got there. Two further versions follow: one that alternates the direction items arrive from, and one that runs sideways on view(inline).
A list that reveals as you scroll
HTML
<div class="streveal-frame">
<div class="scroll-hint">↓ scroll to reveal</div>
<div class="reveal-item">
<div class="reveal-icon reveal-icon-teal">⚡</div>
<div class="reveal-body">
<div class="reveal-title">Scroll-driven animations</div>
<div class="reveal-desc">Link animation progress directly to scroll position, with no JavaScript and no IntersectionObserver.</div>
</div>
</div>
<div class="reveal-item">
<div class="reveal-icon reveal-icon-coral">🎨</div>
<div class="reveal-body">
<div class="reveal-title">CSS custom properties</div>
<div class="reveal-desc">Dynamic theming, animations, and calculations powered entirely by CSS variables.</div>
</div>
</div>
<div class="reveal-item">
<div class="reveal-icon reveal-icon-violet">📦</div>
<div class="reveal-body">
<div class="reveal-title">Container queries</div>
<div class="reveal-desc">Responsive components that adapt to their container's size, not the viewport.</div>
</div>
</div>
<div class="reveal-item">
<div class="reveal-icon reveal-icon-sky">🔗</div>
<div class="reveal-body">
<div class="reveal-title">CSS nesting</div>
<div class="reveal-desc">Write scoped, composable styles inside each other, using native Sass-style syntax.</div>
</div>
</div>
<div class="reveal-item">
<div class="reveal-icon reveal-icon-orange">📐</div>
<div class="reveal-body">
<div class="reveal-title">Subgrid</div>
<div class="reveal-desc">Nested grids that align to their parent's tracks, making complex layouts trivial.</div>
</div>
</div>
<div class="reveal-item">
<div class="reveal-icon reveal-icon-yellow">✨</div>
<div class="reveal-body">
<div class="reveal-title">@starting-style</div>
<div class="reveal-desc">Animate elements from their initial state when they first appear in the DOM.</div>
</div>
</div>
</div>
CSS
/* view() measures the item against its nearest scroller, so
the frame needs a fixed height and its own overflow. */
.streveal-frame {
width: 100%;
height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0;
}
/* No delays and no nth-child rules: each item runs on its own
position in the scroller, which is what staggers them. */
.reveal-item {
padding: 1.5rem;
border-bottom: 1px solid #2a2a2d;
display: flex;
gap: 1rem;
align-items: flex-start;
animation: streveal-up linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 45%;
}
.reveal-item:last-child {
border-bottom: none;
}
.reveal-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
}
.reveal-icon-teal { background: rgba(87, 217, 163, .12); color: #57d9a3; }
.reveal-icon-coral { background: rgba(255, 107, 107, .12); color: #ff6b6b; }
.reveal-icon-violet { background: rgba(192, 132, 252, .12); color: #c084fc; }
.reveal-icon-sky { background: rgba(56, 189, 248, .12); color: #38bdf8; }
.reveal-icon-orange { background: rgba(255, 144, 64, .12); color: #ff9040; }
.reveal-icon-yellow { background: rgba(255, 212, 68, .12); color: #ffd444; }
.reveal-body {
flex: 1;
}
.reveal-title {
font-size: .875rem;
font-weight: 600;
color: #f0f0f0;
margin-bottom: .25rem;
}
.reveal-desc {
font-size: .8rem;
color: #88888f;
line-height: 1.6;
}
.scroll-hint {
text-align: center;
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .65rem;
color: #88888f;
letter-spacing: .08em;
padding: 1rem;
animation: hint-bounce 1.5s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes streveal-up {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes hint-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(4px); }
}
Other ways to build it
Alternating sides
Two keyframe rules and an :nth-child split are enough to have rows arrive from opposite sides. The timeline and the range stay identical on every item, so the stagger is still coming from position rather than from any schedule. Note that these are new keyframe names rather than edits to the one the main demo uses, since a shared keyframe would change both.
HTML
<div class="streveal-alt-frame">
<div class="scroll-hint">↓ scroll the panel</div>
<div class="streveal-alt"><b>Request received</b><span>09:02</span></div>
<div class="streveal-alt"><b>Queued for review</b><span>09:04</span></div>
<div class="streveal-alt"><b>Assigned to Priya</b><span>10:41</span></div>
<div class="streveal-alt"><b>Changes requested</b><span>13:15</span></div>
<div class="streveal-alt"><b>Resubmitted</b><span>16:20</span></div>
<div class="streveal-alt"><b>Approved</b><span>16:58</span></div>
</div>
CSS
.streveal-alt {
animation: streveal-in-left linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 45%;
}
.streveal-alt:nth-child(even) {
animation-name: streveal-in-right;
}
@keyframes streveal-in-left {
from { opacity: 0; transform: translateX(-40px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes streveal-in-right {
from { opacity: 0; transform: translateX(40px); }
to { opacity: 1; transform: translateX(0); }
}
Sideways, on the inline axis
view() takes an axis. view(inline) measures the element's travel left to right instead of top to bottom, so the same entry range now describes a card arriving from the side of a horizontal rail. Nothing else changes: the same fill mode, the same phases, the same one rule on every card. Drag the rail sideways, or scroll it with a trackpad, and the cards arrive in turn.
HTML
<div class="streveal-rail">
<div class="streveal-cardx"><b>Nairobi</b><span>+18%</span></div>
<div class="streveal-cardx"><b>Lisbon</b><span>+11%</span></div>
<div class="streveal-cardx"><b>Osaka</b><span>+7%</span></div>
<div class="streveal-cardx"><b>Bogota</b><span>+24%</span></div>
<div class="streveal-cardx"><b>Helsinki</b><span>+3%</span></div>
<div class="streveal-cardx"><b>Perth</b><span>+15%</span></div>
<div class="streveal-cardx"><b>Tallinn</b><span>+9%</span></div>
</div>
CSS
.streveal-rail {
display: flex;
gap: 1rem;
overflow-x: auto; /* the scroller, on the inline axis */
padding: 1rem;
}
.streveal-cardx {
flex: 0 0 auto;
width: 140px;
animation: streveal-in-side linear both;
/* inline, not block: measured left to right */
animation-timeline: view(inline);
animation-range: entry 0% entry 60%;
}
@keyframes streveal-in-side {
from { opacity: 0; transform: translateX(28px) scale(.9); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.streveal-cardx,
.streveal-alt {
animation: none;
}
}
How it works
animation-timeline: view() links animation progress to an element's position within its nearest scrollable ancestor. animation-range: entry 0% entry 45% plays the animation while the element moves from the moment it starts entering to 45% through that entry phase, so it has fully arrived before it reaches the middle of the panel. Each item animates on its own position, which is what produces the cascade.
The stagger is emergent. In a script driven version, each item gets a transition-delay a fraction of a second longer than the last, and that fixed schedule is what makes the effect look wrong when the reader scrolls quickly: items keep animating after they have already gone past. Here the position in the scroller is the only input, so an item that is already halfway up the panel is already halfway through its animation, whatever route the reader took to get there.
animation-range takes two values, each a phase name and a percentage. The phases describe where the element is relative to the scrollport: entry covers the stretch from its leading edge appearing to its trailing edge fully arriving, exit is the mirror on the way out, and cover is both plus everything between. Finishing at entry 45% means the item is settled well before it reaches the middle, which is the point at which someone is likely to read it.
The animation shorthand carries no duration, and that is deliberate. Attaching a scroll timeline makes animation-duration irrelevant, so the value is left out and both is added instead. The fill mode is what holds the first keyframe while the item is still below the fold. Drop it and every item renders at full opacity until its range begins, then jumps to the start frame, which reads as a flicker.
view() resolves against the nearest scroll container, and an ancestor with overflow: hidden counts as one, which is the usual reason a reveal appears frozen with nothing in the console. CSS scroll reveal covers that failure in detail along with the named timeline that avoids it.
Because the animated properties here are transform and opacity, and the timeline itself is a browser level construct, the whole effect can run on the compositor without touching the main thread. That is the practical gap against IntersectionObserver, where the callback, the class change and the transition all queue behind whatever JavaScript is already running. Time driven motion has the opposite trade off, as in CSS marquee text, where the animation runs on a clock and ignores the reader entirely.
CSS properties used
animation-timelineview(block)derives progress from the element's travel across its scroll container along the block axis.view(inline)measures the other way, for a horizontal scroller.animation-range- Selects the slice of that travel the keyframes map onto, written as two phase and percentage pairs such as
entry 0% entry 45%. animation-fill-modebothholds the first frame before the range starts and the last frame after it ends. Without it, items flash at full opacity before animating.transformtranslateYfor the lift. It runs on the compositor and never triggers layout, so a long list stays smooth.opacity- The fade. Like transform, it is a compositor property, which is why the two are paired in almost every reveal.
overflowautoon the panel is what makes it a scroll container in the first place. It is also what an unwantedhiddenfurther up will imitate.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
animation | 4 | 5 | 5.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
opacity | 4 | 2 | 3.1 | 12 |
flexbox | 21 | 28 | 6.1 | 12 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
Can I Use has no feature entry covering scroll-driven animations, so animation-timeline and animation-range are not given version numbers here. Detect them at runtime with @supports (animation-timeline: view()) and treat the movement as an enhancement. The properties in the table are the ones the keyframes actually animate, and they are supported everywhere. Where the timeline is unavailable the declaration is dropped, animation-duration stays at its initial 0s, and the both fill mode leaves every item on its final frame, so the list renders complete.
Accessibility notes
Wrap the whole effect in prefers-reduced-motion and remove the animations when a reader has asked for less. Do not merely shorten them. A long list where every row slides is one of the strongest triggers there is for motion sensitivity, and the list is perfectly readable without any of it.
Items that have not visually arrived are still in the accessibility tree and still tabbable. A screen reader reads the whole list immediately, and a keyboard user can reach a link inside an item that is at zero opacity, which will scroll it into view without the reader ever seeing the animation. That is acceptable, but it does mean the reveal must be decoration and never a gate on content.
Keep the range inside entry. Tying a reveal to cover leaves items at partial opacity for most of the time they are on screen, which is unreadable for anyone who scrolls in page sized jumps with a keyboard or a screen reader shortcut. Finishing at entry 45% means an item is fully opaque for the entire stretch a reader is likely to be looking at it.
What you can build with it
- Feature lists. Rows of capabilities on a product page, each arriving as it comes into view, which is the demo above.
- Timelines and changelogs. Entries that appear in order as the reader works down, where the sequence is the content and the reveal reinforces it.
- Long form articles. Pull quotes and figures that settle in as they arrive, breaking up a wall of text without any script.
- Horizontal card rails. The
view(inline)variant below, where cards reveal as a rail scrolls sideways rather than as the page moves down. - Statistics rows. Numbers that lift into place as a section arrives, giving the reader a beat before the figures land.
Mistakes worth avoiding
- Adding
animation-delayto stagger the items. The timeline already staggers them by position, and a delay on a scroll timeline shifts the range rather than adding a pause, so the effect drifts out of alignment with the scroll. - Leaving
bothoff the animation. Every item renders normally until its range begins and then jumps to the start frame, which looks like a flicker rather than a reveal. - An
overflow: hiddenwrapper between the items and the scroller. It becomes the scroll container, nothing ever enters it, and the animation freezes with no error. - Animating
height,marginortop. Each scroll frame then relayouts everything below the item, and a list of a dozen rows is enough to make the scroll feel heavy. - Running the range across
cover. Items spend most of their visible life at partial opacity, so anyone scrolling in large steps sees a list of half faded rows.
Frequently asked questions
How do I stagger a scroll reveal without nth-child?
animation-timeline: view() and each one runs on its own position in the scroller, so they arrive one after another. Adding a delay on top would fight the timeline rather than help it.What is the difference between animation-range entry and cover?
entry covers only the arrival, from the leading edge appearing to the trailing edge fully in. cover covers the element's entire crossing, arrival and departure included. Reveals normally want entry, so the item is settled for the whole time it is readable.Is a scroll-triggered reveal better than IntersectionObserver?
IntersectionObserver is still the right tool when entering the viewport has to trigger something other than an animation, such as a fetch.Can a reveal run on a horizontal scroller?
animation-timeline: view(inline). The phases mean the same thing measured left to right, so entry 0% entry 45% reveals a card as it arrives from the side. The second variant on this page does that.Why do all my items animate at once?
overflow: hidden capturing the timeline, in which case nothing animates at all rather than everything at once.