CSS View Timeline Stagger List Items — Native animation-timeline: view()
CSS View Timeline Stagger List Items · Scroll · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<section class="vsl-intro"><h1>A List That Staggers Without Orchestration</h1><p>Each row below fades and slides in on its own independent <code>animation-timeline: view()</code> — every item has a slightly different animation-range, so the list reads as staggered even though nothing is coordinating them.</p></section>
<ul class="vsl-list">
<li class="vsl-item"><span class="vsl-index">01</span><div><h3>Draft the brief</h3><p>Define scope, audience, and success criteria before any design work begins.</p></div></li>
<li class="vsl-item"><span class="vsl-index">02</span><div><h3>Sketch the flows</h3><p>Rough wireframes to validate structure before visual polish.</p></div></li>
<li class="vsl-item"><span class="vsl-index">03</span><div><h3>Design the system</h3><p>Typography, color, spacing — a small reusable toolkit, not one-off screens.</p></div></li>
<li class="vsl-item"><span class="vsl-index">04</span><div><h3>Prototype and test</h3><p>Put it in front of five real users before writing a line of production code.</p></div></li>
<li class="vsl-item"><span class="vsl-index">05</span><div><h3>Build and ship</h3><p>Incremental releases behind a flag, measured against the original brief.</p></div></li>
<li class="vsl-item"><span class="vsl-index">06</span><div><h3>Measure and iterate</h3><p>Close the loop — the brief was a hypothesis, not a promise.</p></div></li>
</ul>
<section class="vsl-outro"><p>Every row above entered on its own schedule — no JavaScript ever computed a stagger delay.</p></section>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a11;color:#f1eff9}
code{background:rgba(8,145,178,.16);color:#7dd3ec;padding:2px 6px;border-radius:5px;font-size:.9em;font-family:ui-monospace,Consolas,monospace}
.vsl-intro,.vsl-outro{min-height:54vh;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:12px;padding:24px;max-width:580px;margin:0 auto}
.vsl-intro h1{font-size:clamp(28px,5.2vw,46px);letter-spacing:-.02em}
.vsl-intro p,.vsl-outro p{color:#a29fbf;font-size:16px;line-height:1.75}
.vsl-list{list-style:none;max-width:620px;margin:0 auto;padding:8vh 24px 22vh;display:flex;flex-direction:column;gap:14vh}
.vsl-item{
display:flex;gap:18px;align-items:flex-start;
background:linear-gradient(155deg,#12101d,#0b0a13);
border:1px solid #201d2e;border-radius:16px;padding:22px 24px;
view-timeline-name:--row-in;
view-timeline-axis:block;
animation:vsl-rise linear both;
animation-timeline:--row-in;
}
@keyframes vsl-rise{
from{ opacity:0; transform:translateY(46px) scale(.97) }
to{ opacity:1; transform:translateY(0) scale(1) }
}
/* Each item gets a slightly different animation-range so, even though every
row is driven by an entirely independent view() timeline, the reveal
reads as a deliberate stagger rather than six identical mechanical pops. */
.vsl-item:nth-child(1){ animation-range:entry 0% cover 30% }
.vsl-item:nth-child(2){ animation-range:entry 4% cover 36% }
.vsl-item:nth-child(3){ animation-range:entry 8% cover 42% }
.vsl-item:nth-child(4){ animation-range:entry 2% cover 32% }
.vsl-item:nth-child(5){ animation-range:entry 6% cover 40% }
.vsl-item:nth-child(6){ animation-range:entry 0% cover 34% }
.vsl-index{font-size:13px;font-weight:800;color:#22d3ee;letter-spacing:.06em;padding-top:3px;flex-shrink:0}
.vsl-item h3{font-size:19px;letter-spacing:-.01em}
.vsl-item p{color:#9b97b6;font-size:14.5px;line-height:1.65;margin-top:6px}
@supports not (animation-timeline: view()){
.vsl-item{opacity:1;transform:none;animation:none}
}// Every row above fades and slides in via CSS animation-timeline: view() --
// each .vsl-item declares its own named view-timeline, and each gets its
// own slightly different animation-range so the reveal reads as staggered
// with zero JavaScript orchestration or runtime code at all.Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
CSS View Timeline Stagger List Items — A Convincing Stagger With No Orchestration Code

A staggered list reveal is normally built with JavaScript computing a transition-delay or animation-delay per item, usually as index * 80ms, tied to an IntersectionObserver watching the whole list. This snippet produces the same staggered feel from a completely different mechanism: every row has its own fully independent animation-timeline: view(), and a slightly different animation-range per item does the work a hand-written delay normally would.
Independent timelines, not a shared delay sequence
Unlike a JS stagger, where every item's delay is relative to when the *list* entered view, each .vsl-item here has view-timeline-name: --row-in and is timed purely by *its own* transit through the viewport — a row further down the page naturally animates later simply because the user has to scroll further to reach it. There is no shared "list entered" moment being fanned out across items at all.
animation-range as the stagger mechanism
Because every row's timeline is independent, giving them all the identical animation-range would make each row's reveal look mechanically identical, just offset by scroll position — visually fine, but not distinctly "staggered." Nudging animation-range's entry and cover percentages slightly per item (for example entry 0% cover 30% versus entry 8% cover 42%) varies how early each row starts and how long it takes to settle, producing the same kind of irregular, hand-tuned rhythm a JS stagger achieves with delay values, but expressed entirely as CSS ranges instead of timing offsets.
Why this differs from CSS View Timeline Card Flip In
That snippet varies *keyframe direction* per card (alternating left/right hinges) while keeping animation-range uniform. This snippet keeps the keyframes identical across every row and instead varies *animation-range* per row — two different ways of using per-element view timelines to avoid a mechanically uniform reveal.
Browser support
Chromium-based browsers (Chrome, Edge, Opera, Brave) support animation-timeline: view() today; Firefox and Safari support is still landing. The @supports not (animation-timeline: view()) block removes the animation entirely, so unsupported browsers see a fully visible, static list rather than rows stuck invisible.
Customizing it
Add more variation to the per-item animation-range values for a more pronounced stagger, or make the variation systematic (entry calc(var(--i) * 2%)) if you have many list items and don't want to hand-tune each one. Pair it with CSS View Timeline Card Flip In directly below a hero for a full scroll-driven page.
Step by step
How to Use
- 1Paste the HTML, CSS, and JSAn intro, a six-row list, and an outro render — no CDN needed.
- 2Scroll down slowlyEach row fades and slides in independently as it enters the viewport, reading as a deliberate stagger.
- 3Compare the timing between rowsNotice each row starts and settles on a very slightly different animation-range.
- 4Scroll back upRows animate in reverse as they re-exit downward (animation both).
- 5Tune the stagger feelWiden or narrow the differences between each nth-child’s animation-range values.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Each item has its own fully independent animation-timeline: view() tracking only that item’s own transit through the viewport. A row further down the page naturally animates later purely because reaching it requires more scrolling — there is no shared list-level trigger being fanned out with delays at all.
If every row used an identical animation-range, each would reveal in an identical manner relative to its own entry — fine, but mechanically uniform. Nudging the entry and cover percentages slightly per item varies how early each row starts and how long it takes to settle, producing an irregular, hand-tuned rhythm similar to what a JavaScript stagger delay would achieve.
Card Flip In keeps animation-range uniform across cards and instead varies the keyframe rule (alternating rotation direction) per card. This snippet keeps the keyframes identical for every row and instead varies animation-range per row — two different ways of using per-element view timelines to avoid a mechanically uniform reveal.
The @supports not (animation-timeline: view()) block removes the animation and transform entirely, so unsupported browsers see a fully visible, static list rather than rows stuck invisible or mid-slide.
Yes — for a list with many items, define animation-range using a CSS custom property set inline per item (for example style="--i: 3") and reference it with calc(), such as animation-range: entry calc(var(--i) * 1.5%) cover calc(30% + var(--i) * 2%), instead of hand-writing an nth-child rule for every row.