Every public page of our quiz app ends with the word "PubTrivia" set enormous behind the footer, sliding up as you scroll and fading in to five per cent opacity. It is aria-hidden. It is hidden entirely below the md breakpoint. It is 48 lines of code, and until this week it was the most expensive thing on the page.
The two framer-motion importers a visitor can reach
Six components in the app import framer-motion. Four are behind a session. The two public ones are a landing page demo and that wordmark, and they import the library in opposite ways.
The 853 line demo, the thing with moving parts and a job to do, uses the lazy API:
import { LazyMotion, domAnimation, m as motion, useInView } from "framer-motion" That is the bundle-conscious path. m is the stripped component, domAnimation is the feature bundle, and LazyMotion loads it as a separate concern instead of pulling the full runtime through the import graph.
The 48 line decoration uses the eager one:
import { motion, useScroll, useTransform } from "framer-motion" And then does this, which is the part I like least about my own code:
useEffect(() => { let el: HTMLElement | null = wordmarkRef.current while (el && el.tagName !== "FOOTER") el = el.parentElement footerRef.current = el }, []) const { scrollYProgress } = useScroll({ target: footerRef, offset: ["start end", "end end"] }) const y = useTransform(scrollYProgress, [0, 1], ["60%", "0%"]) const opacity = useTransform(scrollYProgress, [0, 0.3, 1], [0, 0.02, 0.05]) It walks up the DOM looking for its own <footer> ancestor, so it can measure scroll progress against a parent it was never handed. Then it drives two motion values to move the text 60% of its own height and fade it to a maximum opacity of 0.05.
What that measures as, on a real page, on a phone
I loaded a guide page at 390 by 844 with mobile emulation on and read the resource timings:
- 18 JavaScript files, 357,611 bytes over the wire after compression
- two of those files contain the motion runtime: 33,191 bytes and 29,097 bytes transferred, 91,780 and 93,516 bytes of source, carrying 62 and 30 occurrences of
motionValuebetween them - the wordmark wrapper's computed
displayisnone
So about 62 KB of the 357 KB a phone downloads for that page is carrying a scroll-driven animation runtime, and the only public element that needs it on that page is set to display: none at that width. I want to be careful not to overclaim: those two chunks are not pure framer-motion, they are the chunks the library ended up in, so 62 KB is an upper bound on its share rather than its exact weight. The direction is not in doubt though.
At 1512px wide on pub-trivia.app/features the same two chunks are present, the page pulls 22 static assets and 427,536 bytes, and if you scroll to the bottom and inspect the inner <span> its computed opacity reads 0.05. That is the whole payoff, and it is deliberately almost invisible: a watermark, not a headline.
Why it is this way, which is not a good reason
The wordmark was written months before the demo, by someone reaching for the import that the framer-motion docs put first. The demo was written later, by someone who had just read the bundle guide. Nothing reconciled the two, because nothing in a build pipeline tells you that a decorative component chose the heavy door.
The uncomfortable part is the ordering. The component that earns its animation budget is careful with it. The component that cannot justify any budget at all is the one that pays full price, on every public page, including the ones where it never renders.
Three fixes, in increasing order of how much I like them
Switch the import. m plus a LazyMotion wrapper, exactly as the sibling file already does. Smallest diff, keeps the behaviour identical.
Render nothing below md. Today the element is in the HTML on every phone and then hidden by CSS. The component is a client component already, so gating the mount is a change to one conditional, and the visual result at mobile widths is the same nothing it is now.
Do it in CSS. A translate plus an opacity ramp tied to scroll position is what scroll-driven animations were added to CSS for:
@keyframes wordmark-rise { from { transform: translateY(60%); opacity: 0; } to { transform: translateY(0); opacity: 0.05; } } footer .wordmark { animation: wordmark-rise linear both; animation-timeline: view(); } No runtime, no DOM walk, and it degrades to a static 5% opacity watermark in browsers without animation-timeline, which is a perfectly good outcome for a watermark. The reason it has not landed yet is that view() support is not universal and the fallback needs a look in each browser we care about, which is a morning of work rather than a line.
One thing I would not do is reach for prefers-reduced-motion as the fix. It is worth adding, and this codebase has zero references to it today, but a media query that stops the animation for a minority is not an answer to shipping an animation runtime to everybody.
Watch it yourself
- pub-trivia.app/about on a desktop-width window: scroll to the very bottom and the wordmark rises behind the footer links. If you cannot see it, that is the point, it tops out at five per cent.
- pub-trivia.app/guides/how-to-host-a-pub-quiz in device emulation at 390px: the
<span aria-hidden="true">containing "PubTrivia" is still in the markup,getComputedStylereportsdisplay: none, and the Network tab still lists both motion chunks. - pub-trivia.app/features is the page I took the desktop numbers from. Filter the Network tab to JS and sort by size: the framework chunk leads at 136,440 bytes, the larger of the two motion chunks is next at 33,191, and the other sits fourth at 29,097.
The general version of this: search your repo for imports of your animation library, then ask of each hit whether a user would notice its absence. For the one component on our list where the answer was no, it was also the component paying the most.

