Two greps, one against the repository and one against the live stylesheet.

# in the source grep -rc 'reducedMotion\|prefers-reduced-motion' components | grep -v ':0' # components/FrothCameo.tsx:1 # components/HostController.tsx:1 # components/PlayerLobby.tsx:1 # components/lab/EffectsLab.tsx:1 # components/vfx/ParticleCanvas.tsx:1 # components/player/PlayerShell.tsx:1 # in what the browser actually gets curl -s https://pub-trivia.app/_next/static/chunks/2ox5qqp3hzrjs.css | grep -c 'prefers-reduced-motion' # 0 

Six implementations, all of them JavaScript, none of them CSS. That split is defensible for most of this site, because almost nothing here animates in CSS. It stops being defensible on exactly one page, and this is a post about finding the three elements where it matters.

What the stylesheet contains

Two stylesheets now, 107,598 bytes together. Their entire animation surface:

@keyframes 4 enter, exit, pulse, spin animation: 2 var(--animate-spin), var(--animate-pulse) prefers-reduced-motion 0 forced-colors 0 @media print 0 

Four keyframes, two of which (enter and exit) come from the animation plugin and are driven by the popover and dialog primitives rather than by a page. The other two are Tailwind's spin and pulse.

Now count how many elements across all 79 public pages use them. I fetched every URL in the sitemap and grepped the markup:

animate-spin 0 occurrences, 0 pages animate-pulse 3 occurrences, 1 page 

The spinner keyframe ships to all 79 pages and no public page uses it. It is on the sign-in form and the dashboard, which are behind a session and not in the sitemap, so the keyframe rides along with the stylesheet for pages that will never reference it. That is a rounding error in bytes and it is worth knowing which of your keyframes are decoration on the delivery side.

The pulse is the interesting one, and all three of its uses are on the homepage:

<p class="animate-pulse ...">Waiting for the first team at The Red Lion</p> <p class="animate-pulse ...">Waiting for the first question…</p> <span class="h-2 w-2 animate-pulse rounded-full bg-muted-foreground/50"></span> 

Those are inside the two demos on the page: a big-screen view waiting for teams, a phone waiting for the quiz to start, and the little dot next to "Waiting for the others". A pulse is the correct idiom for "nothing is wrong, something is coming". It is also a two-second infinite opacity animation, three of them at once, in the part of the page a visitor looks at longest.

And there is no prefers-reduced-motion rule anywhere in the stylesheet, so those three pulse for everybody, whatever their operating system says.

The fix is three lines, and I will take it:

@media (prefers-reduced-motion: reduce) { .animate-pulse { animation: none; } } 

There is a small piece of history here. This project did have exactly one prefers-reduced-motion block in CSS, for about two hours on 4 November, inside 41 lines that arrived with a landing-page experiment and left with it. The rule covered a marquee that no longer exists. The gap it was accidentally covering is the one above.

Where the JavaScript side does work

The five non-canvas implementations are all the same line, wrapping a subtree:

<LazyMotion features={domAnimation}> <MotionConfig reducedMotion="user"> {/* ... */} 

reducedMotion="user" is framer-motion's own handling: it reads the media query and, when the preference is set, skips transform and layout animations while keeping opacity ones, on the reasoning that a fade is not what makes people ill. The sixth implementation is the particle canvas, which reads the query itself on every call and simply refuses to spawn anything.

The five wrappers are on the mascot cameo, the player shell, the host controller, the player lobby and the effects lab. Trace that onto the homepage and you get an uneven answer:

Homepage subtree Motion provider Honours the preference
The two mascot cameos FrothCameo Yes
The phone demos PlayerShell Yes
The big-screen demo HostDemo No
The three pulses CSS No

HostDemo opens a LazyMotion and never opens a MotionConfig:

import { LazyMotion, domAnimation, useInView } from 'framer-motion' // ... <LazyMotion features={domAnimation}> 

So the leaderboard rows still slide, the podium still rises and the stage still transitions for a visitor who has asked their machine for less of that. Nothing in the code says this was decided; the components that got a MotionConfig are the ones that were written after the mascot, and the host demo was written before. That is what an undocumented default looks like six weeks later.

The structural fix is obvious once it is in a table: put MotionConfig at the top of the tree instead of at five leaves. reducedMotion="user" is the behaviour you want everywhere, so it belongs in the root layout, and then no new component has to remember.

Two things that will trip up an audit of this

One: three files import the light proxy under the heavy name.

// components/AppDemo.tsx import { LazyMotion, domAnimation, m as motion, useInView } from "framer-motion" 

m is the version with no features bundled in, which is the whole point of pairing it with LazyMotion. Aliasing it to motion keeps the JSX reading as <motion.div> and makes a grep for motion\. report four files when only one of them is actually the eager import. That one is the footer wordmark:

// components/FooterWordmark.tsx import { motion, useScroll, useTransform } from "framer-motion" 

Which is why 76 of our 79 pages ship an animation library: a decorative, aria-hidden, desktop-only wordmark that fades in behind the footer to five per cent opacity. The three pages that do not ship it are the legal pages, which use a compact footer. I have written about that trade before and it is still the right call to measure rather than guess, but the alias is a reminder that an import census has to read the import, not the call site.

Two: the m invariant is enforced by a comment.

/** * Uses `m` rather than `motion`, so whatever renders a mascot must sit inside a * <LazyMotion> (PlayerGame and the lab already do). */ 

That is the top of the shared mascot parts file. Render a mascot outside a provider and you get a runtime failure, in production, in a component tree that type-checks perfectly. Nine files open a LazyMotion to satisfy that rule (a tenth file matches a grep for it, and that tenth is this comment), and on the homepage they nest two deep, because the demo wrapper opens one and the player shell inside it opens another. Nesting is harmless and the duplication is the symptom: a requirement that every call site must satisfy, with no way to state it in the type system, becomes a requirement every call site satisfies redundantly.

A provider at the root would fix this one too, which is the same conclusion as the section above, arrived at from the other end.

Check it yourself

All of this is observable from outside:

# three pulses, all on one page, none of them stopped by CSS curl -s https://pub-trivia.app/ | grep -o 'animate-pulse' | wc -l # 3 curl -s https://pub-trivia.app/faq | grep -o 'animate-pulse' | wc -l # 0 # the stylesheets, and what is not in them curl -s https://pub-trivia.app/ | grep -o '/_next/static/chunks/[a-z0-9]*\.css' | sort -u 

Then the part a grep cannot show you. Turn on reduced motion in your operating system (macOS: Accessibility, Display, Reduce motion. Windows: Accessibility, Visual effects, Animation effects. GNOME: Accessibility, Reduce animation) and open the homepage.

The pint stops bobbing and stops blinking. The phone demo still advances but stops springing. The big screen keeps every one of its transitions. And all three "Waiting" lines keep pulsing, on a two-second loop, forever.

Two of those four behaviours are correct. I would rather publish the table than the half of it that looks good.