The hero of munchable.app is a phone that scans a soup carton. A carton drifts into the viewfinder, the camera finds focus and moves in on the barcode, a scan line sweeps, the reticle snaps gold, and the result sheet slides up with a verdict on it. Then it loops.
There is no video, no Lottie file and no animation library. It is one React component holding a phase, a block of CSS keyframes, and an SVG of a carton with a barcode I counted by hand. I have written before about why there is not a single img tag on that page; this post is about the four seconds of choreography.
The whole animation is one state variable
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 owns the phase and writes it to a data-phase attribute. Everything visual is a CSS rule keyed on 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); animation: sd-lock 0.45s cubic-bezier(0.34, 1.56, 0.64, 1); }
.sd[data-phase='result'] .sd-sheet { transform: translateY(0); }
The loop itself is a setTimeout chain over that array, which means the timings are a table a person can edit rather than durations scattered through a stylesheet.
The durations are not even. 4600ms on the result against 900ms on the lock, because the result is the only phase with anything to read: a verdict word, a confidence line and three reason rows. The others are the setup, and a reader who has already understood the setup is being made to wait through it. The ratio is roughly "half the loop is the payload", and it was arrived at by watching it with someone who had never seen the product.
The camera trick is one transform origin
The carton is an SVG drawn in a 170 by 230 viewBox, with its barcode block centred at a known point. The CSS puts that point at the centre of the viewfinder and scales around it:
.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); }
}
The 28% keyframe is the only interesting number in there. Blur clears almost entirely while the carton has barely moved, so the sequence reads as "autofocus settles, then the camera moves in" rather than one continuous slide. Two separate motions in one timeline, because that is what a phone camera actually does, and a viewer who has held a phone up to a barcode recognises it without being told.
The second layer is a wobble nobody consciously notices:
.sd-hold { animation: sd-hold 3.2s ease-in-out infinite alternate; }
@keyframes sd-hold {
from { transform: translate(-1.2px, 0.8px) rotate(-0.35deg); }
to { transform: translate(1.2px, -0.8px) rotate(0.35deg); }
}
Just over a pixel of drift and a third of a degree of rotation, running forever on its own clock. Without it the held frame looks like a photograph of a carton, which is exactly what it is. With it the frame looks like a live camera.
A barcode is an array of widths
A real EAN is bars and gaps of one to four modules with taller guard bars at the ends and the middle. Ours is that, as a list, scaled to whatever width it is drawn at:
const MODULES = [1, 1, 1, 3, 2, 1, 1, 2, 2, 2, 1, 1, 4, 1, /* ... */];
function buildBars(x0: number, width: number): Bar[] {
const total = MODULES.reduce((a, b) => a + b, 0);
const unit = width / total;
const bars: Bar[] = [];
let x = x0;
MODULES.forEach((m, i) => {
if (i % 2 === 0) bars.push({ x, w: m * unit, tall: false });
x += m * unit;
});
const mid = Math.floor(bars.length / 2);
for (const i of [0, 1, mid - 1, mid, bars.length - 2, bars.length - 1]) {
const bar = bars[i];
if (bar) bar.tall = true;
}
return bars;
}
Even indices are bars, odd ones are gaps, so one array describes the whole pattern. It encodes nothing: it is a plausible-looking barcode on a product that does not exist, printed under digits that are not a real GTIN, because the demo is a drawing of our app and not a claim about a specific item on a shelf.
Being generated rather than drawn is what makes it survive. At 1.85x zoom a traced SVG barcode either shows its hand-placed irregularities or goes soft. Widths computed from a module count stay crisp at any scale and look right because the proportions are right.
The loop has to be restartable, and a keyframe will not replay itself
A CSS animation with both runs once and holds its final frame. Coming back round to approach leaves the feed sitting at 1.85x with no blur, so the second loop has no approach in it at all. The fix is a remount:
const [loop, setLoop] = useState(0);
// ...
<div className="sd-feed" key={loop}>
Bump an integer at the top of each cycle, use it as the key, and React replaces the node so its keyframes start from zero. The sheet's contents work the other way, as delayed animations under the phase selector, which makes them replay every time the phase becomes result with no key games needed:
.sd[data-phase='result'] .sd-disc { animation: sd-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) 0.3s both; }
.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; }
A staircase of delays from 0.3s to 0.95s, so the verdict lands first and the reasons arrive under it in reading order. The one spring curve in the file is on the verdict disc, and it is the only element that gets to overshoot.
Reduced motion needs the right frame, not the first one
Our stylesheet has the usual blanket rule:
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}
That rule alone would have left the demo frozen on whatever the DOM happened to say, which is approach: an empty viewfinder with a blurred carton at the edge of it, and no result sheet. Technically motionless, and a complete misrepresentation of the product.
So the component reads the preference itself and parks the phase:
useEffect(() => {
if (reduce) {
setPhase('result');
return;
}
// ... start the timeline
}, [reduce]);
Someone with reduced motion on gets the frame the animation exists to arrive at: barcode under a gold reticle, result sheet up, verdict and reasons legible. The blanket CSS rule decides that nothing moves. Only the component knows which frame is the one worth stopping on. (The same stylesheet has a scroll-driven hole in that rule, which I wrote about in the blanket reduced-motion rule has a hole in it.)
One last thing: the whole demo is aria-hidden="true". A screen reader working through "Caution, 2 reasons, chevron" is being handed a transcript of a picture. The paragraph next to it carries the meaning.
Watch it, then look inside
Open munchable.app and let the hero run a couple of loops. Then open devtools, find the element with data-phase, and set the attribute by hand: approach, scan, lock, result. The entire animation is reachable from that one attribute, which is also how it was built.
And if you want to see the real thing the drawing is imitating, the app runs in a browser with no account: app.munchable.app.
Top comments (0)