Definition
This is the technique to reach for when you want an element to glide into view (fade/slide) the moment it enters the screen as you scroll — but with CSS alone, no JavaScript.
The heart of it is two CSS properties. animation-timeline: view() means "drive this animation's progress not by time, but by how far this element has entered the screen (viewport — the browser display area actually visible to the user)." animation-range: entry 0% entry 60% means "run the animation from the point where the element just begins to enter the screen (entry 0%) to the point where it is about 60% in." Together, these map opacity (transparency) and transform (position/size transformation) keyframes (keyframes — definitions of the start/middle/end states of an animation) to the range over which the element enters the viewport.
Why it matters
The problem: to build the same "appear on scroll" effect the old way, you have to write code that detects entry with IntersectionObserver (a JS API that detects whether an element has entered the screen) and attaches a class via classList. Then you have to hand-manage hydration timing (the process where a framework like React attaches events to server-rendered HTML to make it actually work) after server rendering, effect cleanup, and even the reduced-motion code to disable animation for users who want it.
Using the CSS view-timeline shrinks this logic to a few lines of declarative CSS. Put it behind @supports (a conditional block that checks whether the browser supports a given CSS feature), and in browsers that don't support it, the element is displayed as-is immediately with no animation (content never disappears), while only supporting browsers get the subtle entrance effect.
How it works
- Assign a timeline and range to the element you want to reveal.
- Wrap it in
@supports (animation-timeline: view())so unsupporting browsers ignore this rule and just leave the element visible. - For users who don't want animation, disable it with
animation: noneinside@media (prefers-reduced-motion: reduce)(a media query that detects users who have turned on "reduce motion" in their OS). - For things like a page-wide progress bar, use the
scroll()timeline based on the entire document scroll rather than element entry — keeping its role separate fromview()(per-element).
Practical application
- Attach it only to individual content blocks like list items (
<li>) or stat panels. Applied to a whole-page container, the entry judgment becomes ambiguous. - Keep classes separate from continuously repeating demo loop animations. Mixing a "scroll-based" and a "time-based" timeline on one element causes conflicts.
- Animate only
transformandopacity. Animating layout properties likewidthortopcauses layout thrash (a phenomenon where forced layout recomputation repeats and degrades performance), recalculating layout every frame.
Trade-offs
- Support in Safari/Chromium is trending wider, but it isn't universal across all browsers — so the
@supportswrapper is effectively mandatory. - Setting the progress range too long makes the animation continue almost until scrolling ends, which feels awkward — so it's usual to cap it around 60% of entry (entry 60%).
When not to use
- To fully hide above-fold text that matters for search. Lowering the starting opacity to around 0.15 is fine, but fully hiding it like
display: nonerisks the content being treated as absent.
Common mistakes
- Applying
overflow: hiddento a position-fixing parent (sticky), so the fixing behavior and the entrance effect both break at once. - Attaching a class with a repeating loop keyframe and the reveal (view-reveal) class to the same element, so the two animations overwrite each other.
Related concepts
- scroll-driven-narrative-layout — the higher-level layout pattern that unfolds the screen narratively as you scroll
- cls-skeleton-layout-reservation — the technique of reserving space before appearance so the layout doesn't shift (CLS)