Munchable's landing page reveals its content blocks as they scroll into view. Nothing unusual, except that there is no IntersectionObserver, no library, no hydration gap and nothing on the main thread. It is twelve lines of CSS.
Getting to those twelve lines meant fixing three separate problems that all fail quietly, which is the worst way for a visual effect to fail: the page still renders, nobody logs an error, and the thing you thought you shipped is simply not happening.
The page is at munchable.app. Scroll it, then turn on your operating system's reduce-motion setting and scroll it again.
Problem one: the guard has to be the whole rule
Here is the reveal:
@keyframes rise-in {
from { opacity: 0; translate: 0 18px; }
to { opacity: 1; translate: 0 0; }
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.sec-head, .purpose h2, .purpose p, .frow, .faq-item, .plan, .cond-card {
animation: rise-in linear both;
animation-timeline: view();
animation-range: entry 5% cover 24%;
}
}
}
The @supports wrapper looks like ordinary progressive enhancement, and it is doing more work than that.
The animation starts at opacity: 0. In a browser that does not understand view(), the animation-timeline declaration is dropped but the animation shorthand is not, so the element would take a normal document timeline, run once on load, and mostly be fine. Mostly. The failure mode you are one specification quirk away from is content permanently stuck at zero opacity because it is waiting for a progress signal that will never arrive.
That is a full page of invisible text, and it is exactly what a crawler would see. Wrapping the whole thing in @supports means the rule does not exist at all where the mechanism does not exist, and content that is never animated is content that is simply visible. Plain HTML is the fallback, which is the right fallback.
This is the specific advantage over the JavaScript version, by the way. The classic IntersectionObserver reveal sets opacity: 0 in CSS and clears it from a script. If the script fails to load, throws early, or is blocked, the CSS has already hidden the content and nothing is coming to unhide it. Here the hiding and the revealing are the same declaration, so they cannot become separated.
Problem two: the accessibility opt-out missed it entirely
Near the top of the stylesheet, as in a great many stylesheets, sits the blanket opt-out:
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
This works by flattening duration to nothing. Everything finishes instantly, nothing moves, one rule covers the whole site.
A scroll-driven animation ignores duration entirely. Its progress is a function of scroll position, not of elapsed time. Setting animation-duration: 0.001ms on it changes precisely nothing, and the reveal sails straight through the net that was supposed to catch it.
Which is why the reduced-motion media query is spelled out again, inside the rule, as no-preference. Not as a belt-and-braces duplicate, but because the blanket rule genuinely does not reach this.
If you have that * rule in your stylesheet and you have started using animation-timeline, this is worth ten seconds of checking. The symptom is invisible to the people writing the CSS, because everyone testing it has reduce-motion off.
Problem three: the animation was killing the hover states
The reveal originally animated transform: translateY(...). The pricing cards and condition cards lift on hover, also with transform:
.plan { transition: transform 0.18s ease, box-shadow 0.18s ease; }
.plan:hover { transform: translateY(-3px); box-shadow: var(--shadow-lg); }
An animation running with both keeps its filled value after it finishes, and an animation's value wins over a normal declaration for the same property. So once a card had risen into view, its transform was owned by the animation forever, and the hover lift did nothing. Not intermittently. On every card that had ever scrolled past, which is all of them.
The fix is one word. The reveal moves with the independent translate property:
@keyframes rise-in {
from { opacity: 0; translate: 0 18px; }
to { opacity: 1; translate: 0 0; }
}
translate, rotate and scale as standalone properties exist partly for this: they compose with transform instead of fighting it for the same slot. The reveal owns translate, the hover owns transform, and the two stop caring about each other.
Worth knowing even if you never use scroll timelines, because any animation-fill-mode: both on transform has this problem with any :hover on transform.
The bits that do loop, and the delay trick
Not everything on the page is a one-shot reveal. There are small warm-coloured crumbs drifting behind the hero and the closing section, on an infinite alternating float. Decoration, absolutely, and the sort of thing worth having an opinion about, because looping motion in the corner of a page is a real cost to some readers. Ours is slow, tiny and caught by the blanket rule above, which does reach it because it is a time-based animation.
The implementation detail I like is the desynchronising:
.sd-crumb { animation: sd-float 6s ease-in-out infinite alternate; }
.sd-crumb.c2 { animation-delay: -2s; }
.sd-crumb.c3 { animation-delay: -4s; }
.sd-crumb.c4 { animation-delay: -1s; }
A negative delay starts an animation already part way through. With a positive delay the crumbs would sit perfectly still for a couple of seconds after load and then start moving in a staggered wave, which draws the eye at the exact moment you want somebody reading a headline. With negative delays they are already mid-drift on the first frame and permanently out of phase with each other, which is what makes four identical elements look like weather rather than like four identical elements.
Same trick, no JavaScript, no per-element random seed.
Hover states get their own opt-out
The blanket rule handles durations, but a reduced-motion user should still get feedback from a hover, just not a moving one. So the interactive elements say what they do instead of moving:
@media (prefers-reduced-motion: reduce) {
.store-soon, .store-soon::after, .store-soon::before {
transition: opacity 0.16s ease;
}
.store-soon:hover, .store-soon:focus-visible { transform: none; }
}
Motion is replaced by a fade, not removed. "Reduce motion" is a request about movement, not a request for a dead interface, and an element that visibly responds to focus matters more to some of the people who set that flag, not less.
Note :focus-visible beside :hover throughout. Everything reachable by a pointer on this page is reachable by a keyboard with the same affordance, which costs one selector at the time and is a genuinely annoying retrofit later.
What it adds up to
Zero bytes of JavaScript for the entire reveal system. No hydration flash, nothing deferred behind a bundle, no observer that has to be torn down, and the whole thing degrades to plain visible HTML in any browser that does not support it. The rest of the page's JavaScript budget goes where it earns its keep.
The trade is that the failure modes are subtle enough to need comments in the stylesheet explaining why each guard exists, because every one of them looks removable. That comment block is now longer than the CSS it guards, and I would write it again.
Go and scroll munchable.app, then turn reduce-motion on in your OS and reload. The content arrives instantly and the crumbs stop drifting, and the page loses nothing you needed.
Top comments (0)