The homepage of notifio.app has four demos on it. Not screenshots, not a video, not a GIF. Four React components that animate a fake version of the app: a log streaming into a window frame, a browser where a listing appears and a toast fires, a fake macOS desktop that shows a notification and then an inbox, and a reply flow that types a message a character at a time.
That was a product decision I still agree with. It is also 997 lines of client components, and reading them in one sitting turned up four things worth writing down, three of which are mine to fix.
Why not a video
The app being sold is a thing that sits quietly and then tells you something. A still screenshot of that is a window with a list in it, which communicates nothing, and the honest screenshot of the interesting moment is a notification banner, which looks like every other notification banner.
A video would show it. A video also means an encode in the repo or on a CDN, a poster frame, autoplay rules that differ per browser, a file that is large on mobile, and a thing that goes stale the moment the UI changes and that I will not re-record. The demos are JSX, so when the real app's tab bar changes, changing the fake one is a line.
The cost is that they are code, and code has bugs that a video does not.
One loop pattern, written four times
Every demo runs the same shape. No setInterval, no animation library timeline. An async function, awaited delays, and a ref that cancels:
export default function BrowserDemo() {
const [showNew, setShowNew] = useState(false);
const [showToast, setShowToast] = useState(false);
const cancelled = useRef(false);
useEffect(() => {
cancelled.current = false;
async function run() {
setShowNew(false);
setShowToast(false);
await delay(1400);
if (cancelled.current) return;
setShowNew(true);
setShowToast(true);
await delay(3000);
if (cancelled.current) return;
...
}
run();
return () => { cancelled.current = true; };
}, []);
The if (cancelled.current) return after every single await is the whole safety mechanism, and it has to be after every single one. Miss one and a component that unmounted mid sequence calls setState on the way out, or worse, keeps looping forever because the recursion at the end of run was reached through the gap.
I would still choose this over setInterval for sequenced animation. The pauses are not uniform, the sequence restarts itself, and an interval that fires while the previous step is unfinished is a bug you get for free. A chain of awaits reads in the order the user sees things happen, which for a demo is exactly the code you want to be able to read.
The hero demo pays an extra price for being the only one with a visible on/off state, because its loop has to read a value that changes outside the closure:
const [running, setRunning] = useState(false);
const runningRef = useRef(false);
// Keep ref in sync so the loop can read latest value
useEffect(() => { runningRef.current = running; }, [running]);
Two refs and a mirror effect, because the closure captured running at the start and would be checking a stale boolean forever. That is the standard answer, and it is also the point at which a loop like this stops being obviously simple.
Four copies of this at the bottom of four files:
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Three copies of this at the top of three of them:
const DEMO_HEIGHT = 320;
One of those three carries the comment // Matches NotificationDemo height exactly, which is a comment describing a constraint that a shared constant would have enforced. The two demos sit side by side in alternating rows, so if one of those numbers is edited the layout goes lopsided. It is the smallest possible refactor and it has not happened because nothing has edited either number yet.
Pacing is content
The most interesting function in all four files is the one that decides how long to wait:
function pauseBefore(line: string): number {
if (line.includes("Poll #1")) return 1300;
if (line.includes("Poll #")) return 4200;
if (line.includes("new listing found")) return 1700;
return 1300;
}
The comment above it is the reasoning:
A fixed tick made the whole cycle race past in a few seconds. Real polls are 30s apart, so the long waits go before each new poll and the find is given a beat to land, which reads as a monitor idling rather than a ticker.
A uniform delay made the demo look like a terminal spewing output, which is the opposite of what the product does. The real cadence is a poll every thirty seconds, and nobody is going to watch thirty seconds of nothing, so the demo compresses it to 4.2 seconds and spends the pause in the right place. The find gets 1.7 seconds of its own, because the moment the product exists for should not go by in one frame.
The log lines themselves are deliberately generic:
const LOG_LINES = [
"[09:14:01] Monitor started",
"[09:14:02] Poll #1: 2 site(s)",
"[09:14:03] Baseline saved for site 1",
...
"[09:15:06] 1 new listing found on site 1",
];
"site 1" rather than a real site name. The format matches the app's real activity log, the timestamps advance at a realistic rate, and the content names nothing it cannot back up. If you want the per site version of that claim, the alert pages are where each site gets its own page and its own wording.
Finding one: nothing checks reduced motion
Four demos, all four of which start animating on mount and loop until the tab closes, and prefers-reduced-motion appears in none of them. Nor in any shared component, nor in the global stylesheet. I grepped for it specifically while writing this section, half expecting to find it in a Tailwind layer I had forgotten about. It is not there.
For a user who has asked their OS to reduce motion, this homepage is four looping animations, one of them a typing effect, and it will keep going for as long as the tab is open.
The fix is small, because framer-motion is already the dependency:
const reduce = useReducedMotion();
// then: skip the loop and render the end state of each sequence
The part that is not small is deciding what the still frame should be for each of the four, because the end state of a sequence is not always the informative one. The browser demo's end state is a listing that has already appeared with the toast gone, which shows nothing. The right still frame is mid sequence: new listing visible, toast visible, nothing moving. That is a design question per demo, which is probably why this was easy to not do.
Finding two: they run whether or not you can see them
There is no IntersectionObserver and no whileInView anywhere in these files. Three of the four demos sit well below the fold, and all three begin their loops on mount, which means a visitor who reads the hero and clicks straight through to the download page still paid for three animation loops they never saw.
It is timers and state updates rather than heavy paint work, so the cost is small. It is not zero, and on a phone nothing about it is free. Framer-motion's whileInView, or gating the useEffect on an observer, would make the demo start when the demo is on screen, which is also when it reads better: arriving at a section already mid sequence is worse than arriving at one that starts.
Finding three: one aria-hidden in 997 lines
The entire set has exactly one accessibility annotation, on the fake tab strip in the hero:
{/* Tabs, illustrative only, so not exposed as a tab widget */}
<div aria-hidden="true" className="flex gap-1 border-b border-[#1e1e1e]">
That one is right, and it shows the thinking was available. A fake tab list announced as a tab list is a promise of keyboard behaviour that does not exist.
Everything else is unannotated, which means the fake macOS menu bar, the dock labels, the dummy inbox subjects and the placeholder prices are all exposed to a screen reader as ordinary content. A screen reader user gets the text of an illustration read to them in page order, with no indication that it is a drawing of an app rather than the app.
These are decorative. They are illustrations of a product, and the real text next to each one carries the information. The right shape is almost certainly aria-hidden="true" on the outer wrapper of each demo, with the surrounding copy doing the work, which is one attribute in four places and a decision I should have made when I made it in the hero.
What I would tell myself before starting
Animated demos instead of screenshots were worth it. They are cheap to keep current, they show a product whose entire value is a timing, and they work with the page's styling instead of fighting it.
The three things I would set up on day one: a shared delay and a shared height constant, a reduced motion check wired in before the first sequence is written rather than after four exist, and aria-hidden on the wrapper by default so the exceptions are the annotated ones.
All four are on the homepage right now if you want to watch them, which is the other nice property of shipping a demo as code: the thing I am describing is the thing you are looking at.
Top comments (1)
Good write-up, and the reasoning for code over video holds up since the demo stays in sync with the real UI. For reduced motion, the simplest fix is one shared hook that reads prefers-reduced-motion and, when it is set, skips the delays and renders the final state of each demo. People still see the outcome, just without the typing and streaming. Since the four demos copy the same delay function, pulling it into one helper also gives you a single place to check both the cancel flag and the motion preference. I would also stop the loop after a couple of cycles or add a pause control, because endless looping motion is a problem even for people who have not set the preference.