CSS Scroll Reveal
Text sweeps in from the left as it enters the viewport using a scroll-driven clip-path animation, with zero JavaScript.
A CSS scroll reveal ties animation progress to how far an element has travelled into its scroll container, using animation-timeline: view(). There is no timer and no IntersectionObserver. Scroll halfway and the animation is halfway through, scroll back and it plays in reverse, because the scroll position is the clock.
The version below sweeps a heading in from the left by animating clip-path, then fades its tag and body copy on a slightly later range. Two more versions follow: one that reveals on the way in and fades on the way out, and one that drives a progress bar from a named timeline rather than an implicit one.
Sweep in on entry
Typography first.
Great design starts with type. Every layout decision, from spacing to hierarchy to rhythm, flows from the words on the page. CSS gives you full control without a single line of JavaScript.
No JavaScript overhead.
Scroll-driven animations run on the compositor thread. They don't block the main thread, don't require IntersectionObserver, and can't be slowed by heavy scripts. Pure CSS wins here.
Shipped in every browser.
Scroll-driven animations landed in Chrome 115, Firefox 110, and Safari 15.4. With progressive enhancement, you can use them today, because non-supporting browsers simply see the final state.
HTML
<div class="reveal-frame">
<div class="scroll-hint">↓ scroll to reveal</div>
<div class="reveal-section">
<span class="reveal-tag">Design</span>
<h2 class="reveal-heading">Typography first.</h2>
<p class="reveal-text">Great design starts with type. Every layout decision, from spacing to hierarchy to rhythm, flows from the words on the page. CSS gives you full control without a single line of JavaScript.</p>
</div>
<div class="reveal-section">
<span class="reveal-tag">Performance</span>
<h2 class="reveal-heading">No JavaScript overhead.</h2>
<p class="reveal-text">Scroll-driven animations run on the compositor thread. They don't block the main thread, don't require IntersectionObserver, and can't be slowed by heavy scripts. Pure CSS wins here.</p>
</div>
<div class="reveal-section">
<span class="reveal-tag">Modern CSS</span>
<h2 class="reveal-heading">Shipped in every browser.</h2>
<p class="reveal-text">Scroll-driven animations landed in Chrome 115, Firefox 110, and Safari 15.4. With progressive enhancement, you can use them today, because non-supporting browsers simply see the final state.</p>
</div>
</div>
CSS
/* view() measures against the nearest scroller, so the frame
needs a fixed height and its own overflow. Without those,
nothing ever "enters" and nothing animates. */
.reveal-frame {
width: 100%;
height: 320px;
overflow-y: auto;
scroll-snap-type: y proximity;
display: flex;
flex-direction: column;
gap: 0;
}
.reveal-section {
padding: 2.5rem 1.5rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
border-bottom: 1px solid #2a2a2d;
scroll-snap-align: start;
}
.reveal-section:last-child {
border-bottom: none;
}
.reveal-heading {
margin: 0;
font-size: 1.4rem;
font-weight: 800;
letter-spacing: -.03em;
color: #f0f0f0;
animation: reveal-clip linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 50%;
}
.reveal-text {
margin: 0;
font-size: .875rem;
color: #88888f;
line-height: 1.75;
max-width: 52ch;
animation: reveal-fade linear both;
animation-timeline: view(block);
animation-range: entry 5% entry 55%;
}
.reveal-tag {
display: inline-flex;
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .65rem;
letter-spacing: .08em;
text-transform: uppercase;
color: #b8ff57;
border: 1px solid rgba(184, 255, 87, .1);
background: rgba(184, 255, 87, .1);
padding: .2rem .6rem;
border-radius: 999px;
animation: reveal-fade linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 45%;
}
.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;
}
/* Clipping from the right edge is what makes it sweep in */
@keyframes reveal-clip {
from {
clip-path: inset(0 100% 0 0);
opacity: 0;
}
to {
clip-path: inset(0 0% 0 0);
opacity: 1;
}
}
@keyframes reveal-fade {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes hint-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(4px); }
}
Other ways to build it
In on the way down, out on the way up
Changing animation-range from entry to cover hands the keyframes the element's whole journey across the scrollport rather than just its arrival. A middle pair of stops holds the card at full opacity for the stretch where it is actually readable, and the first and last stops take it away at both edges. Scroll the panel in either direction and the same rule plays forwards or backwards.
HTML
<div class="reveal-io-frame">
<div class="scroll-hint">↓ scroll the panel</div>
<div class="reveal-card"><b>Invoice 2041</b><span>Paid on 12 March</span></div>
<div class="reveal-card"><b>Invoice 2042</b><span>Paid on 19 March</span></div>
<div class="reveal-card"><b>Invoice 2043</b><span>Awaiting approval</span></div>
<div class="reveal-card"><b>Invoice 2044</b><span>Paid on 2 April</span></div>
<div class="reveal-card"><b>Invoice 2045</b><span>Draft</span></div>
</div>
CSS
.reveal-card {
animation: reveal-inout linear both;
animation-timeline: view(block);
/* the element's whole crossing, not just its arrival */
animation-range: cover 0% cover 100%;
}
/* the flat middle is the part the reader can actually read */
@keyframes reveal-inout {
0% { opacity: 0; transform: scale(.88); }
35%, 65% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.88); }
}
A named timeline, which cannot be intercepted
view() and scroll() both resolve against whatever scroll container happens to be nearest, so a wrapper with overflow: hidden quietly steals them. Naming the timeline removes the guesswork. The scroller declares scroll-timeline-name, the bar asks for that exact name, and no ancestor in between can take it. This one uses scroll() rather than view(), because it tracks how far the panel has been scrolled instead of where one element sits.
Scroll this panel and the green bar pinned to its top fills in step with the scroll position.
The bar is a plain span. It has no width animation and no script behind it, only a scaleX that runs from 0 to 1 across the scroll range.
Because the timeline is referenced by name, the bar does not care how many wrappers sit between it and the scroller, or whether any of them clip their overflow.
A reading progress indicator on a normal page is the same rule with scroll() pointed at the document instead of a panel.
The implicit form, scroll(nearest block), walks up the tree looking for the closest scroll container. That is convenient until a wrapper clips its overflow, at which point the search stops early and the animation never runs.
Naming the scroller costs one extra declaration and removes that whole class of bug. It also survives refactoring, since moving the bar into a different wrapper changes nothing.
The bar sits in the normal flow with position: sticky, so it scrolls with the content until it reaches the top edge and then stays pinned there.
Keep scrolling. The bar reaches full width exactly when the panel reaches its last line.
HTML
<div class="reveal-scroller">
<div class="reveal-progress"><span class="reveal-progress-fill"></span></div>
<div class="reveal-scroller-body">...</div>
</div>
CSS
.reveal-scroller {
height: 260px;
overflow-y: auto;
/* the name is in scope for every descendant of this element */
scroll-timeline-name: --reveal-track;
scroll-timeline-axis: block;
}
.reveal-progress {
position: sticky;
top: 0;
height: 4px;
background: #1c1c1e;
z-index: 1;
}
.reveal-progress-fill {
display: block;
height: 100%;
background: #b8ff57;
transform-origin: 0 50%;
animation: reveal-progress linear both;
animation-timeline: --reveal-track;
}
@keyframes reveal-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.reveal-card {
animation: none;
}
}
How it works
animation-timeline: view(block) links the animation to the element's visibility inside its scroll container. animation-range: entry 0% entry 50% runs it while the element travels from completely out of view to halfway into the visible area. clip-path: inset(0 100% 0 0) hides the text by clipping everything from the right edge inward, and animating that value to 0% uncovers it. Any scrollable container works, not only the page viewport.
animation-timeline swaps the animation's source of progress. On a normal animation the browser advances the keyframes with a clock and animation-duration decides how fast. Give it a view progress timeline and the duration is ignored entirely, replaced by the element's own passage through the scroller. That is why the shorthand here is written animation: reveal-clip linear both with no time in it.
animation-range selects which part of that passage counts. The named ranges are cover, entry, exit, entry-crossing and exit-crossing, each a phase of the element crossing the scrollport. entry 0% entry 50% starts the moment the element's leading edge appears and finishes when it is halfway in, so the reveal completes well before the reader is looking straight at it.
The both fill mode is not optional. Outside the declared range the animation is not applying, so without a fill the heading would sit at its normal, fully visible styles until the range began, then jump to the clipped start frame. both holds the from frame before the range and the to frame after it.
view() measures against the nearest scroll container, and this is where the pattern most often dies quietly. Any ancestor with overflow: hidden is itself a scroll container, so it captures the lookup, the element never enters or exits it, and the timeline is inactive. Nothing errors. The animation simply freezes on its fill frame and the section looks broken. A named timeline, shown in the second variant, points at a specific scroller by name and cannot be intercepted this way.
clip-path is a paint operation, so the clipped heading still occupies its full box while it is hidden. Nothing below it moves as the sweep runs, which is what separates this from a width animation. In a browser that does not support animation-timeline, the declaration is dropped, animation-duration falls back to its initial value of 0s, and both leaves the element on its final frame, so unsupported browsers get the finished state immediately rather than a blank section.
CSS properties used
animation-timelineview(block)builds a progress timeline from the element's position in its nearest scroll container along the block axis. It overridesanimation-durationentirely.animation-range- Picks which slice of the timeline the keyframes map onto, using phases such as
entry,exitandcoverwith a percentage on each end. animation-fill-modebothholds the first keyframe before the range and the last one after it. Leaving it off makes the element flash its unanimated styles.clip-pathinset(0 100% 0 0)clips the full width away from the right edge. Animating the inset to0%sweeps the content into view without changing its layout box.scroll-timeline-name- Names a scroll progress timeline on a scroller so descendants can reference it explicitly instead of guessing at the nearest one.
overflowautoorscrollis what makes an element a scroll container.hiddenalso makes one, which is the usual cause of a silently inactive timeline.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
animation | 4 | 5 | 5.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
scroll-snap | 69 | 68 | 11 | 79 |
@supports | 28 | 22 | 9 | 12 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
Can I Use has no feature entry for scroll-driven animations, so animation-timeline, animation-range, scroll-timeline-name and view-timeline-name are left out of the table rather than given numbers from memory. Test for them at runtime with @supports (animation-timeline: view()) and treat the animated state as an enhancement. Can I Use also records clip-path on HTML elements as partial in Chrome, Safari and Edge rather than fully supported, which is why it has no row here either. Basic shapes such as inset() are the well covered part of that property.
Accessibility notes
Scroll-linked movement is still movement. Wrap the whole pattern in a prefers-reduced-motion check and, when the reader has asked for less, drop the animation-timeline declarations so the content renders in its final state. That keeps every word readable and removes the parallax feel that triggers vestibular symptoms in some people.
Content hidden by clip-path or opacity: 0 is still in the accessibility tree and still focusable. A screen reader will read a section that has not visually arrived yet, and a keyboard user can tab into a link inside it. That is usually fine, since scroll position and reading order are different things, but it does mean the effect must never be the only thing standing between the reader and the content.
Keep the range short. Tying a reveal to cover 0% cover 100% means the element is only fully visible at one exact scroll position, which makes reading impossible for anyone who scrolls in large steps with a keyboard or a screen reader shortcut. Finishing inside entry leaves the element settled for the whole time it is actually on screen.
What you can build with it
- Long marketing pages. Each section arrives as it comes into view, which breaks a very tall page into readable beats without any script.
- Reading progress indicators. A bar driven by
scroll()on the document, showing how far through an article the reader has come. - Case study galleries. Images that settle into place as they enter, so a page of large screenshots does not feel like one static wall.
- Data storytelling. A chart whose bars grow as the reader scrolls through the paragraph explaining them, with the scroll position controlling the reveal.
- Section headings. A sweep or wipe on each heading as it appears, which is the version on this page and the least intrusive use of the technique.
Mistakes worth avoiding
- An ancestor with
overflow: hiddenbetween the element and the intended scroller. It becomes the nearest scroll container, the element never enters it, and the animation sits frozen on its fill frame with no console warning. - Leaving
bothoff the animation. The element renders normally until the range begins, then snaps to the start frame, which reads as a flash rather than a reveal. - Setting
animation-duration. It is ignored once a scroll timeline is attached, so time spent tuning it has no effect at all. - Animating a property that changes layout, such as
heightormargin, instead oftransform,opacityorclip-path. Every scroll frame then triggers a reflow of everything below. - Building the reveal so the final state is only reached at one scroll position. Any reader who jumps rather than scrolls smoothly lands on a half hidden section.
Frequently asked questions
Why is my scroll-driven animation stuck and not moving?
view() and scroll() resolve against the nearest scrollable ancestor, and any element with overflow: hidden counts as one. If a wrapper in between is clipping, the timeline attaches to it, finds no scrolling, and stays inactive. Give the real scroller a scroll-timeline-name and reference it by name.Does scroll reveal need JavaScript or IntersectionObserver?
animation-timeline: view() is handled by the browser's compositor, so it works with the stylesheet alone and keeps running while the main thread is busy. IntersectionObserver was the way to do this before the property existed.What happens in a browser that does not support animation-timeline?
animation-duration stays at its initial 0s. Combined with a both fill mode, the element lands on its last keyframe straight away, so readers on older browsers see the finished layout rather than an empty page.What is the difference between view() and scroll()?
scroll() tracks how far a scroll container has been scrolled, from top to bottom, which suits a reading progress bar. view() tracks one specific element's journey across that container, which suits a per element reveal. The variants on this page use one of each.Can I animate an element out again as it leaves?
animation-range to cover 0% cover 100% and write keyframes with a middle stop, so the element rises at 0%, holds in the middle, and falls at 100%. The exit variant above does exactly that.