BiscuitChickenpie
cd ~/feed
9305DESIGN27·07·2026
$cat ~/design-work/css-scroll-reveal-pure-css.md

Make Anything Scroll Into View with Pure CSS (No JavaScript).

Four lines of CSS. No JavaScript, no npm, no IntersectionObserver. Here's how to build scroll-reveal animations that run on the compositor thread and work in every major browser.

Make Anything Scroll Into View with Pure CSS (No JavaScript)

Your portfolio card slides up and fades in as the reader scrolls past it. Three months ago that meant installing a JavaScript library, setting up an IntersectionObserver, and hoping nothing broke on mobile. Now it's four lines of CSS.

By the end of this tutorial, you'll have a working scroll-reveal animation — elements that fade and translate up as they enter the viewport — using CSS's native animation-timeline: view(). No npm. No script tags. No IntersectionObserver. Chrome 115+, Edge 115+, and Safari 18+ all support this natively, covering about 84% of global browsers right now. It won't be perfect on that 16% yet. We'll cover the fallback.

Prerequisites

Comfortable with HTML and CSS — if you can style a card, you're ready. A text editor and Chrome or Edge open next to it. About 30 minutes.

Diagram showing a browser viewport frame with three card shapes rising upward from the bottom edge, with motion lines and labels — Mr. Chicken pointing at the cards

Step 1: Build the base HTML

Start with a simple page — a stack of cards with plenty of content to scroll through. Add enough cards that the page actually scrolls — five or six is good. Set min-height: 100vh on the body and space out your cards with margin-bottom: 20vh. You need room to scroll for the effect to be visible.

Step 2: Write the @keyframes animation

Define a standard animation — the kind you'd use for any fade-in. Use the translate property (not transform: translateY()). The newer shorthand is cleaner and plays nicer with compositor-thread rendering. Common mistake: animating margin-top or height instead of translate. Those trigger layout recalculation on every frame. translate and opacity are the only two properties guaranteed to run off the main thread.

Step 3: Bind it to the viewport with animation-timeline: view()

This is the part that used to require JavaScript. Set animation: fade-up linear both on your .card element, then add animation-timeline: view() and animation-range: entry 0% entry 30%. Three things are happening: the animation runs without a duration (scroll position replaces time), the browser drives it with the element's viewport position instead of a clock, and the animation plays as the element enters the viewport from 0% to 30% visible. Save and refresh. Scroll down. The cards fade up as they enter the frame.

Diagram showing a vertical scroll progress track with 0%, 30%, and 100% markers, and a card element at three opacity stages — transparent, half-visible, and solid

Step 4: Tune the animation-range

The entry 0% entry 30% range is your main dial. entry 0% is the moment the element's bottom edge crosses the viewport bottom. entry 30% is when 30% of the element is visible. If 30% feels too fast, push it to entry 50%. For a more cinematic feel, try entry 10% cover 30% — it delays the start slightly and finishes when the element covers 30% of the viewport. The entry phase isn't the only option: there's also cover, contain, exit, and exit-crossing. For most scroll-reveals, entry is all you need.

Step 5: Add a stagger

Right now all cards animate simultaneously, which flattens the effect when multiple cards are on screen. The clean approach: set animation-delay: calc(var(--i, 0) * 100ms) on .card, then put --i:0, --i:1, --i:2 as inline style attributes on each card. Now you have a waterfall without repeating the selector for every nth-child.

Step 6: Lock the state with animation-fill-mode: both

If you scroll fast, an element can enter the viewport mid-animation and jump. The both keyword in your animation shorthand handles this — it applies the from state before the animation starts (elements are invisible before they enter) and holds the to state after it ends. You already added it in Step 3. Check that it's there: animation: fade-up linear both.

Step 7: Add the fallback for the 16%

Firefox stable (as of mid-2026) is still rolling out full support. Wrap your scroll animation in @supports (animation-timeline: view()) { ... }. Without this, browsers that don't understand animation-timeline will still play the fade-up keyframe — but they'll play it immediately on load, not on scroll. The @supports block prevents that. Older browsers see your cards fully visible and static. That's a fine fallback.

Step 8: Compositor-safe properties only

Your animation runs smooth. Keep it that way. Only animate transform properties (translate, rotate, scale) and opacity. Avoid width, height, padding, margin — they trigger layout. Avoid background-color and border-color — they trigger paint. box-shadow triggers both. If your design calls for a color change on reveal, fake it: animate opacity on a pseudo-element with the target color overlaid. Looks the same, costs nothing.

If you get stuck

Cards not animating at all: check that you're in Chrome 115+ or Edge 115+. Firefox stable still needs a flag (layout.css.scroll-driven-animations.enabled in about:config). Cards animating but stuck halfway: you likely have overflow: hidden on a parent. view() tracks position relative to the nearest scroll ancestor — remove the overflow or use view(block) to scope it to the root scroller. Cards not visible on mobile Safari before scrolling: this is an animation-fill-mode issue combined with iOS's momentum scroll. Fix it by setting .card opacity: 1 by default and moving the opacity: 0 reset inside the @supports block — the hidden state is opt-in, visible is the fallback. Animation plays twice: you have two scroll containers. Explicitly set animation-timeline: view(block) to bind to the root scroller only.

Next tutorial: We'll use the same technique to build a reading-progress bar — the kind that fills across the top of the page as you scroll through an article. One element. Eight lines of CSS.

By Chickenpie
Share
// More from the feedAll entries

Done reading? There’s more where this came from.