Load pub-trivia.app with the Network tab open and filter to Media. Nothing. Then run this on the homepage and on a page with no demo on it, such as /faq:
const js = performance.getEntriesByType("resource").filter(r => /\.js(\?|$)/.test(r.name)); ({ files: js.length, bytes: js.reduce((a, r) => a + r.encodedBodySize, 0) }) homepage: { files: 21, bytes: 430572 } /faq: { files: 18, bytes: 360723 } About 70 KB of extra JavaScript, and that 70 KB is three animated screens of the product running in a loop: a host view in a laptop frame and two phone views. No screen recording, no <video>, no GIF, no Lottie file.
What a screen recording costs you, other than bytes
Recording the real UI is the obvious approach and it is usually the right one. These are the four reasons it was not:
It is unreadable at the size it will be viewed. A video of an interface has to be scaled to fit a column on a phone, and interface text does not survive that. The demo on our page is text in the DOM, so it reflows, and it is still 14 px text at the bottom of a 320 px wide phone frame rather than 14 px text scaled to 40% of its size.
It goes stale silently. A product video is a snapshot of a build. Ship a change to a button and the video is now showing a UI that does not exist, and nothing in CI will tell you. The mock is built from the same component primitives and the same design tokens as the app, so a token change moves both. It picks up dark mode for free, because it is styled by the same classes as everything else on the page.
Autoplay is a negotiation. Muted inline autoplay mostly works now, and "mostly" is doing a lot of work in that sentence: data saver modes, low power mode on iOS, reduced motion settings, and in-app browsers all have opinions. A looping video that does not loop is a still frame of a UI mid-gesture, which looks like a bug.
The weight is not comparable. A few seconds of clean screen recording at a readable bitrate is megabytes. The entire JavaScript cost of all three demos here is 70 KB over the wire, which includes the animation library.
The animation library is the part worth copying
Three components animate, so the naive import is framer-motion's motion object, which drags in every feature it supports. The library ships a smaller path for exactly this:
import { LazyMotion, domAnimation, m as motion, useInView } from "framer-motion" export function DemoHostLiveQuestion() { return ( <LazyMotion features={domAnimation}> <LaptopFrame url="pub-trivia.app/dashboard/host"> <HostLiveQuestion /> </LaptopFrame> </LazyMotion> ) } m is the stripped component with no built in features, and LazyMotion features={domAnimation} loads the DOM animation feature set around it. Aliasing m as motion means the call sites read like ordinary framer-motion code, which matters in a file that contains several hundred lines of them.
The frames are CSS. PhoneFrame is a rounded border, a notch made of two divs and a home indicator bar; LaptopFrame is three dots and a fake address bar with the URL as a text node:
<div className="mx-3 flex-1 rounded bg-background border border-border px-2 py-0.5 text-[10px] font-mono truncate"> {url} </div> That text node is the detail I like most, because it is honest in a way a device mockup PNG is not. It says pub-trivia.app/dashboard/host, and you can select it with your cursor. It is not pretending to be a browser, it is captioning which screen you are looking at.
Three timelines that do not know about each other
Each demo screen is its own component with its own small state machine and its own timers. There is no shared clock and no orchestrator, which was the thing I expected to need and did not: three screens stepping through their own scripts at their own pace reads as three things happening live, while one master timeline would have them all pivot on the same beat and read as a single animation.
The data they run on is a module scope fixture. Ten questions, a list of tables, a few derived values computed at module load. No fetch, no WebSocket, no mock service worker, nothing that could fail in front of a visitor.
Two things are wrong with it, and I would rather write them down than pretend the implementation is clean.
The first is that the timers start on mount rather than on view. The entrance animations are gated properly:
const inView = useInView(ref, { once: true, margin: "-80px" }) but the interval that advances each demo's script is a plain effect, so the second and third screens are stepping through their timelines while the visitor is still reading the hero. That is wasted work on a page where the budget was the reason for not using a video. Gating the intervals on the same inView ref is a small change and it is on the list.
The second: the demo is a client component, so Next renders its first frame into the HTML and the rest only exists after hydration. You can see exactly where the line falls:
curl -s https://pub-trivia.app/ | grep -c "most moons" # 1 curl -s https://pub-trivia.app/ | grep -c "Submit Answer" # 0 The first question's text ships in the document. The answer button, which belongs to a later step of the player timeline, does not. For a decorative demo that is the correct trade, but it is worth knowing before you decide to put anything load bearing inside an animated mock: a crawler gets frame one.
The expensive part of that section was never the JavaScript
While measuring for this post, the same page reported 1.87 MB of images. Two of the three demo rows sit on top of decorative background images that are not optimised, because they are applied as inline background-image styles and the framework's image optimiser only sees images that go through its own component. The three animated product screens cost 70 KB. The decoration behind them costs about 27 times that.
Which is the useful ending, I think. The interesting engineering in that section turned out to be worth about a tenth of the page weight, and the thing nobody thought about at all turned out to be most of it.
Go and watch it rather than taking my word for it: the three demos are on pub-trivia.app, about one screen down, and the app they are mocking has a free tier that does not ask for a card.