The Problem: Poutine Is a Depth Problem
A croissant is a silhouette problem. Get the outline right and you're most of the way there. A pile of fries under gravy is a depth problem. Things overlap, sauce runs into gaps, and the entire appeal is that you can't tell where one component stops and the next begins.
That's why xbill built a CSS-only poutine with 50 fries, 13 cheese curds, 11 gravy patches, a pouring ribbon, and steam — no images, no SVG, no canvas. Every edible object is an or shaped with CSS. The result is a submission for the Frontend Challenge: Comfort Food Edition, and it's packed with technical lessons.
One Element Per Fry
Each fry is a single element carrying its own coordinates via custom properties:
--x/--y: position in the pile--r: rotation (0deg is a fry standing on end)--l/--w: length and width--s: how well done it is
The --s variable feeds a color-mix() so every fry browns independently. That's what stops fifty identical rectangles from looking like fifty identical rectangles. The background uses color-mix(in oklab, ...) to interpolate between browns. The author notes that mixing in sRGB takes a detour through muddy grey — and fried potato is the one thing you can't render grey.
Curds and gravy patches follow the same pattern, with --br carrying a full lopsided border-radius so no two lumps of cheese share a shape.
Mistake 1: Sizing Everything in vmin
vmin felt like the obvious unit for responsive art. It isn't. The scene is capped at 620px, but vmin is relative to the viewport, not the artwork. On a wide screen, every part kept growing after the canvas had stopped. Each gravy blob was specified at 26vmin — on a 900px viewport that's 234px, or 37% of a 620px canvas. Six of those and the dish became a single brown dome.
The fix: let the artwork define its own unit.
.scene{
--u: min(86vw, 78vh, 620px); /* the artwork's own edge length */
--px: calc(var(--u) / 100); /* one hundredth of it */
}
Everything inside is expressed in --px — hundredths of the piece itself. A fry at --l:26 is 26% of the scene's height on every screen. Change --u and the entire dish rescales as one object. The lesson: art built from many parts needs a unit relative to the art, not the window. vmin couples every element to the browser chrome instead of its neighbours.
Mistake 2: Gravy as a Single Blob
The first gravy was one large shape laid over the pile. It read as a lid. Sauce doesn't sit on food; it collects between it — dark in crevices, thin over high points, with fries breaking back through.
What worked: three separate layers.
- A soft, blurred dark pool at the bottom, painted behind the fries, filling the gaps.
- Six irregular patches draped over the middle fries.
- Five more ladled on after the front row, so gravy visibly coats the nearest fries.
Then feathering the edge of every patch so it stops looking like a sticker:
mask-image: radial-gradient(closest-side, #000 58%, rgba(0,0,0,.55) 84%, transparent 100%);
That mask is what sells it, but it caused a head-scratcher: drips built as ::after pseudo-elements on each patch silently vanished. A mask clips pseudo-elements too, and the drips hung outside the closest-side radius. They had to move into their own elements. There's now a comment in the stylesheet explaining this.
Mistake 3: Rotation Tracking Position
The first pile put near-horizontal fries at the outer edges and upright ones in the middle — exactly how you draw a starburst. Every fry pointed away from the centre, making a radially symmetrical sea urchin.
Real fries jumble. The fix was counter-intuitive: steep rotations at the outer edge, near-horizontal ones in the interior. A fry standing up at the edge reads as leaning against the wall; a fry lying flat at the edge reads as a spoke. Inverting killed the symmetry. Also, --r must not correlate with --x — if rotation increases smoothly as you move right, the eye finds the pattern even through fifty elements.
Two Tricks Worth Stealing
The pile needed to be wider than the fries' --x values allowed, but scaling the container would have squashed the fries. Because fry size is in --px (scene-relative) and position is a percentage of the pile box, you can stretch one without touching the other:
.pile{ left:-4%; width:108%; }
The tray's near rim is a full ellipse drawn on top of the food, with the top half clipped away, so the pile sits inside the boat:
clip-path: polygon(-2% 50%, 102% 50%, 102% 104%, -2% 104%);
Accessibility: Not Just Decorative
CSS art is decorative, but it shouldn't be hostile. The whole scene is a single role="img" with a description of the dish, and all 90 decorative elements are aria-hidden. A screen reader gets one sentence about a tray of poutine, not ninety anonymous list items.
Every animation — steam, drips, the pour — is switched off under prefers-reduced-motion, and crucially the elements are left in a sensible resting state rather than frozen at opacity zero:
@media (prefers-reduced-motion:reduce){
.steam i,.sauce,.runnel,.pour,.pour::after,.droplet{ animation:none !important; }
.steam i{ opacity:.45; transform:translate(-50%,-40%) scale(1.1); }
.droplet{ opacity:1; transform:translate(-50%,300%); }
}
Turning motion off gives you a still photograph, not a blank space.
The Only JavaScript: Add More Gravy
There's one button — "Add more gravy." It's about twenty lines that replay an animation and drop another curd on the heap. Everything else is CSS.
What I'd Tell Myself at the Start
Render it and look at it, early and often. Every one of these mistakes was invisible in the code and obvious the second it hit a screen. The vmin bug looked completely reasonable as CSS and completely absurd as a picture.
Now go build something that piles up. And remember: the unit is the art, not the viewport.


