Munchable's landing page opens with a phone that scans a carton of soup and shows a verdict. That animation is the pitch. The product is a camera, a barcode and an answer, and no paragraph of copy lands the way a screen that does it in front of you does.
The obvious way to build it is a screen recording. We did not, and the reasons turned out to be more than aesthetic.
Why the hero is not a video
A muted autoplaying loop is a fine hero on most sites. It was wrong for this one:
- It dates instantly. The app's result sheet changed three times in two weeks. Each change would have meant re-recording, re-encoding and re-uploading, or shipping a hero that shows a screen the app no longer has.
- It cannot use the design tokens. Our colours and type live in a package that both apps consume. A video is pixels: when the token changes, the video is the one thing on the page that stays the old colour.
- It is heavy in the worst place. The hero is the largest contentful paint on the page. A loop crisp enough for a desktop hero is megabytes, and it competes with the fonts for the first second of the connection.
- Reduced motion becomes an all-or-nothing switch. With a video you can pause it or swap in a poster frame. You cannot say "hold the informative moment", because the video does not know which moment that is.
So the hero is a React component, some SVG, and CSS. No animation library, no Lottie runtime, no video element.
The component owns exactly one thing
The entire state of the animation is a phase name and a timer:
type Phase = 'approach' | 'scan' | 'lock' | 'result';
/** Phase order and how long each one holds, in milliseconds. */
const TIMELINE: ReadonlyArray<readonly [Phase, number]> = [
['approach', 2200],
['scan', 1700],
['lock', 900],
['result', 4600],
];
React renders one attribute:
<div className="sd" data-phase={phase}>
and every moving part is a CSS rule keyed off that attribute:
.sd[data-phase='scan'] .sd-scanline {
opacity: 1;
animation: sd-sweep 0.8s ease-in-out infinite alternate;
}
.sd[data-phase='lock'] .sd-reticle,
.sd[data-phase='result'] .sd-reticle {
border-color: var(--gold);
box-shadow: 0 0 0 5px rgba(231, 172, 91, 0.22), 0 0 26px rgba(231, 172, 91, 0.4);
animation: sd-lock 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
That split is the whole design. React sets a string four times per loop. It never sees a frame, never holds a transform, never runs a requestAnimationFrame. The compositor does the work, so the hero keeps animating while the rest of the page hydrates.
The staggered reveal of the result sheet, which is the part people actually read, is seven delays in a stylesheet rather than seven timers in JavaScript:
.sd[data-phase='result'] .sd-word { animation: sd-rise 0.4s ease 0.45s both; }
.sd[data-phase='result'] .sd-conf { animation: sd-rise 0.4s ease 0.55s both; }
.sd[data-phase='result'] .sd-reason{ animation: sd-rise 0.35s ease 0.7s both; }
If you want to retime the reveal you edit numbers in CSS, and nothing about the component changes.
The camera does not find the barcode. The geometry was decided in advance
The soup carton is an inline SVG drawn in the app's own palette and type, down to the 500 ml strapline. Its barcode is not an image. It is an array of module widths:
// alternating bar and gap widths in modules, scaled to fit the printed block
const MODULES = [1, 1, 1, 3, 2, 1, 1, 2, 2, 2, 1, 1, 4, /* ... */];
Bars are the even entries, gaps the odd ones, and the first, middle and last pairs are drawn taller so they read as guard bars the way a real EAN prints. Total module count divides the available width, so the block can be resized without the bars going soft.
The interesting part is what makes the "lock on" moment work. The barcode block is centred at a known point of the viewBox, and the CSS zoom uses that exact point as its origin:
.sd-feed {
transform-origin: 50% 98px;
animation: sd-zoom 2.1s cubic-bezier(0.55, 0.05, 0.25, 1) both;
}
@keyframes sd-zoom {
0% { transform: translate(4px, 60px) rotate(-5deg) scale(0.7); filter: blur(3px) brightness(0.88); }
28% { transform: translate(6px, 58px) rotate(-4.5deg) scale(0.72); filter: blur(0.6px) brightness(0.95); }
100% { transform: translate(0, 0) rotate(0deg) scale(1.85); filter: blur(0) brightness(1); }
}
Nothing detects anything. The carton drifts in out of focus, sharpens at 28 percent the way a phone camera does, and the zoom lands the barcode under the reticle because both were placed at the same coordinate months ago. A separate slow wobble on an inner wrapper keeps the frame from looking like a still:
.sd-hold { animation: sd-hold 3.2s ease-in-out infinite alternate; }
Two independent animations on nested elements, one for the camera and one for the hand. Composing them beats keyframing a single transform that has to express both.
The one thing CSS could not do on its own
Loops. A CSS animation with both does not restart when an unrelated attribute changes, so on the second pass the camera was already zoomed in and the carton never drifted.
The fix is a remount:
// Bumped at the start of every loop; keys the camera feed so its zoom replays.
const [loop, setLoop] = useState(0);
<div className="sd-feed" key={loop}>
Changing a key throws the element away and builds a new one, and new elements start their keyframes from zero. It is a blunt tool and it is the right one here: one small subtree, four times a minute.
Reduced motion parks on the frame that carries the meaning
The usual reduced-motion treatment is to stop moving. Stopping is not enough. If the animation halts during approach, a visitor with reduced motion gets a blurry carton and no answer, which is the one thing the hero exists to show.
So the component subscribes to the media query and jumps to the end:
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setReduce(mq.matches);
const onChange = () => setReduce(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
useEffect(() => {
if (reduce) {
setPhase('result');
return;
}
// ...walk the timeline
}, [reduce]);
Subscribing rather than reading once matters: people flip the OS setting while a page is open, usually because a page is annoying them. The timer teardown is in the cleanup, so flipping it mid-loop parks the demo rather than leaving a stray setTimeout walking the phases with nothing to paint.
The site also carries the blanket rule, which is the safety net rather than the design:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
The four rows below the hero are stills, deliberately
Under the hero are four feature rows, each with the same phone showing a different screen: the result sheet, the photo review step of label capture, the hub, and the recipe library. None of them move. The comment at the top of that file is the reasoning, and I would defend it on any marketing page:
Still, not animated. The hero already moves; four more looping phones would turn the page into a wall of screensavers.
One thing moving is a demonstration. Five things moving is a slot machine, and the reader stops reading and starts waiting for the loops to finish.
Both the hero and the stills are aria-hidden. They are drawings of an interface, not the interface, and the copy next to each row already says what it shows. Announcing the SVG's text nodes to a screen reader would read out a fake product name in the middle of a sentence about scanning.
What it costs
382 lines of TSX and 88 CSS rules, against a video file that would have taken an afternoon. The honest cost is not the lines, it is that the hero is a second drawing of an interface that also exists for real in the mobile app, and a second drawing can drift. We accept that because it is illustrative rather than documentary, and because both drawings read their colours and type from the same token package, so the drift is limited to layout rather than brand.
What we bought: a hero that is text and vectors, that restyles when the tokens change, that is crisp on a 4K monitor and cheap on a phone, and that has a genuine reduced-motion state instead of a pause button.
Go and look at it
- The hero is on munchable.app. Watch one loop, then keep watching: the wobble and the zoom go out of phase, which is what stops it feeling like a GIF.
- Turn on Reduce Motion in your OS accessibility settings and reload. The phone should be sitting on the result sheet, with the verdict readable, not frozen mid-scan.
- The four stills below the hero are the same phone frame at a smaller size, sharing the token values.
- If you want to see the non-decorative side of the same design system, the condition guides and the recipe library are plain static pages built from the same tokens, with no animation at all.
Open dev tools on the hero and watch the data-phase attribute change on the wrapper. That single attribute is the entire animation state.
Top comments (0)