[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-css-scroll-driven-animations-replace-scroll-script":3},{"content":4,"lastModified":5},"\u003Cp>Two years ago I wrote a file called \u003Ccode>reveal.js\u003C\u002Fcode>. Forty-odd lines: an \u003Ccode>IntersectionObserver\u003C\u002Fcode> for the crossings, a \u003Ccode>requestAnimationFrame\u003C\u002Fcode> loop for everything between them, a cache of \u003Ccode>getBoundingClientRect()\u003C\u002Fcode> values, and a resize listener whose only job was to throw that cache away. It faded cards in on a landing page. It worked, mostly. On a slow phone the fade trailed the thumb, which is the jank it was written to avoid.\u003C\u002Fp>\n\n\u003Cp>I deleted it last week. The page now ships a dozen lines of CSS and no listeners.\u003C\u002Fp>\n\n\u003Cp>\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1523437113738-bbd3cc89fb19?w=1200&amp;q=80\" alt=\"CSS rules with padding declarations on a dark editor screen, photographed at an angle\" loading=\"lazy\">\u003C\u002Fp>\n\n\u003Ch2>Pick a timeline, not a duration\u003C\u002Fh2>\n\n\u003Cp>CSS scroll-driven animations keep your \u003Ccode>@keyframes\u003C\u002Fcode> as they are and change what moves the playhead. Scroll position replaces the clock. Two functions cover almost everything you'd ship.\u003C\u002Fp>\n\n\u003Cp>\u003Ccode>scroll()\u003C\u002Fcode> follows how far a scroll container has been scrolled, which is what a reading progress bar needs. \u003Ccode>view()\u003C\u002Fcode> follows one element's journey through its scrollport, which is what a card reveal needs.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-css\">@keyframes grow-progress {\n  from { transform: scaleX(0); }\n  to   { transform: scaleX(1); }\n}\n\n.progress-bar {\n  position: fixed;\n  inset: 0 0 auto 0;\n  height: 4px;\n  background: #2563eb;\n  transform-origin: left center;\n  animation: grow-progress linear;\n  animation-timeline: scroll(root block);\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>There's no duration in that rule. That isn't a typo, and it's the first thing that breaks people. Chrome's guide is blunt: once a scroll timeline is attached, a value in seconds means nothing, so \u003Ccode>animation-duration\u003C\u002Fcode> has to be \u003Ccode>auto\u003C\u002Fcode>, or omitted so it defaults to \u003Ccode>auto\u003C\u002Fcode>. Type it out of muscle memory and the animation quietly stops behaving.\u003C\u002Fp>\n\n\u003Ch2>The shorthand will eat your timeline\u003C\u002Fh2>\n\n\u003Cp>Second gotcha, and this one cost me an afternoon. \u003Ccode>animation-timeline\u003C\u002Fcode> gets included in the \u003Ccode>animation\u003C\u002Fcode> shorthand as a reset-only value. You can't set it there, but writing the shorthand does reset it to \u003Ccode>auto\u003C\u002Fcode>. So order matters:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-css\">\u002F* animation snaps to the end frame immediately *\u002F\n.card {\n  animation-timeline: view();\n  animation: reveal-card linear both;\n}\n\n\u002F* works *\u002F\n.card {\n  animation: reveal-card linear both;\n  animation-timeline: view();\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The failure is silent. No console warning, nothing invalid to spot in DevTools. The animation falls back to the document timeline, and with the duration you never set, it resolves to zero and jumps straight to the last keyframe. It looks like the effect already ran. If a scroll-driven animation does nothing, check this before you check anything else.\u003C\u002Fp>\n\n\u003Ch2>Blank cards in Firefox\u003C\u002Fh2>\n\n\u003Cp>My first version had \u003Ccode>.card { opacity: 0; }\u003C\u002Fcode> in the base rule, with the animation bringing it up to 1. Every screenshot looked correct, because Chromium has shipped this since 115 in July 2023 and Safari caught up in 26 in September 2025. Then I opened the page in Firefox and got a column of nothing.\u003C\u002Fp>\n\n\u003Cp>Firefox still ships scroll-driven animations behind a preference (\u003Ccode>layout.css.scroll-driven-animations.enabled\u003C\u002Fcode>, off by default in the current stable), so \u003Ccode>@supports\u003C\u002Fcode> reports false there and no user agent sniffing is needed. Which also meant my hidden base state was all that was left on screen.\u003C\u002Fp>\n\n\u003Cp>The rule I use now: write the base CSS as the finished, visible state, and put all the motion inside two guards.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-css\">@keyframes reveal-card {\n  from { opacity: 0; transform: translateY(2rem); }\n  to   { opacity: 1; transform: none; }\n}\n\n.card { \u002F* nothing animated here, it's simply visible *\u002F }\n\n@supports (animation-timeline: view()) {\n  @media (prefers-reduced-motion: no-preference) {\n    .card {\n      animation: reveal-card linear both;\n      animation-timeline: view();\n      animation-range: entry 0% cover 40%;\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Fill mode \u003Ccode>both\u003C\u002Fcode> isn't optional. Without it the \u003Ccode>from\u003C\u002Fcode> keyframe never applies before the range starts, so the card flashes at full opacity and then fades in, which looks like a bug because it is one.\u003C\u002Fp>\n\n\u003Cp>\u003Ccode>animation-range\u003C\u002Fcode> is doing more work than it looks. A view timeline defaults to the element's entire cover range, so a reveal plays forward as the card climbs the screen and backwards as it leaves. To have it finished by the time the card is a third of the way up, that range value is the fix. You can also put ranges inside keyframes, which I use for list items that fade back out at the bottom:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-css\">@keyframes in-and-out {\n  entry 0%   { opacity: 0; transform: translateY(30%); }\n  entry 100% { opacity: 1; transform: none; }\n  exit 0%    { opacity: 1; transform: none; }\n  exit 100%  { opacity: 0; transform: translateY(-30%); }\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Firefox hasn't implemented \u003Ccode>animation-range\u003C\u002Fcode> or \u003Ccode>timeline-scope\u003C\u002Fcode> either, so a range-dependent effect is a Chromium and Safari effect today. Global support sits around 87%.\u003C\u002Fp>\n\n\u003Ch2>Named timelines, and the wrong scroller\u003C\u002Fh2>\n\n\u003Cp>Anonymous timelines are enough until the element you're animating isn't a descendant of the thing that scrolls. Named timelines handle that, and lookup only walks up the scroll ancestors, which is why \u003Ccode>timeline-scope\u003C\u002Fcode> exists: it hoists the name to a shared ancestor so a sibling can reach it. I've needed it once, on a sticky sidebar tracking the article body beside it.\u003C\u002Fp>\n\n\u003Cp>The related trap is quieter. Bare \u003Ccode>scroll()\u003C\u002Fcode> means \u003Ccode>scroll(nearest block)\u003C\u002Fcode>, and \"nearest\" is whatever ancestor happens to be a scroll container. A wrapper with \u003Ccode>overflow: hidden\u003C\u002Fcode> counts. So does a horizontal carousel you forgot about. Your progress bar then fills over the wrong scroll range, and the CSS looks perfectly reasonable while being wrong. I write \u003Ccode>scroll(root block)\u003C\u002Fcode> even when I'm sure there's nothing in between.\u003C\u002Fp>\n\n\u003Ch2>Keep IntersectionObserver for the rest\u003C\u002Fh2>\n\n\u003Cp>These animations scrub a property from a scroll position. They don't fire callbacks, so lazy-loading an image, fetching page two, or pinning a section is still observer work. And stick to \u003Ccode>transform\u003C\u002Fcode>, \u003Ccode>opacity\u003C\u002Fcode>, and \u003Ccode>filter\u003C\u002Fcode>. Animate \u003Ccode>width\u003C\u002Fcode> or \u003Ccode>height\u003C\u002Fcode> and the browser relayouts every frame, which throws away the reason the timeline lives on the compositor.\u003C\u002Fp>\n\n\u003Cp>Debugging is better than I expected: the Animations panel lists the animation with a scroll timeline where the clock usually is, and the playhead scrubs like any other animation. If it doesn't show up at all, the timeline failed to resolve.\u003C\u002Fp>\n\n\u003Cp>\u003Ccode>reveal.js\u003C\u002Fcode> is gone, along with the cache and the resize listener. I kept two things in \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa>: the \u003Ccode>@supports\u003C\u002Fcode> block above and the ranges I actually use, because I re-derive them from scratch every single time.\u003C\u002Fp>\n\n\u003Cp>This is the same story as \u003Ca href=\"\u002Fposts\u002Fview-transitions-api-replace-animation-library\u002F\">the transition library I deleted last year\u003C\u002Fa> and \u003Ca href=\"\u002Fposts\u002Freplaced-js-libraries-html-invoker-commands\u002F\">the click handlers HTML took over\u003C\u002Fa>. The platform keeps absorbing jobs we used to rent from npm. I'm not complaining.\u003C\u002Fp>\n","2026-09-18",1789991458760]