This is a submission for Frontend Challenge - Comfort Food Edition, CSS Art.
Inspiration
Two comfort foods collided here.
One is Méqui — what every Brazilian actually calls McDonald's. Not a fancy dish: the burger you begged your parents for on a Sunday afternoon.
The other is the way games looked when I was a kid. Chunky low-poly geometry, colours banding because there weren't enough of them, dithering scattered over every gradient, coloured fog swallowing the far plane, and that faint wobble PSX hardware had because it snapped vertices to integers.
So: a comfort-food burger, rendered like it's running on 1998 hardware.
Demo
Scroll and it comes apart, labelling itself as it goes:
Journey
Upfront: the burger's geometry is SVG, not CSS shapes — ~370 polygons emitted by a build-time script I wrote. Every look on this page, though, is CSS: the fog, the grain, the outlines, the motion. That's the part worth showing, because most of it is four lines each.
Posterization is just gradients with hard stops
Banding is usually the enemy. Here it's the entire point. Every colour stop is doubled so there's no interpolation between bands:
.fx--fog {
mix-blend-mode: screen;
opacity: 0.5;
background:
radial-gradient(58% 44% at 12% 8%, #ff2d95 0 6%, #a8215f 6% 13%,
#5a1140 13% 22%, #0000 40%),
radial-gradient(52% 40% at 88% 22%, #16d6ff 0 5%, #0f88b8 5% 12%,
#0a4a70 12% 21%, #0000 38%);
}
#ff2d95 0 6%, #a8215f 6% 13% — each colour holds flat, then snaps. Stack four of those and you get coloured fog that looks quantized to an 8-bit palette.
Ordered dithering is two conic gradients
My favourite four lines on the page. A 2px checkerboard and a 3px checkerboard, offset from each other, blended over everything:
.fx--dither {
mix-blend-mode: overlay;
opacity: 0.22;
background-image:
conic-gradient(from 90deg at 1px 1px, #0000 25%, #000 0),
conic-gradient(from 90deg at 1px 1px, #fff0 25%, #fff 0);
background-size: 2px 2px, 3px 3px;
background-position: 0 0, 1px 1px;
}
Two coprime grid sizes never line up, so the interference reads as ordered dither instead of a visible checkerboard. It breaks up the fog's bands and puts "low-resolution render" grain back into an otherwise perfectly crisp vector page.
Vertex jitter needs steps(), and that's the whole joke
PSX consoles had no sub-pixel precision, so geometry shimmered. The charm is in not interpolating — a smooth version of this just looks like drift:
@keyframes psx-jitter {
0% { translate: 0 0; }
16% { translate: 0.9px -0.6px; }
33% { translate: -0.7px 0.5px; }
50% { translate: 0.4px 0.9px; }
66% { translate: -0.9px -0.4px; }
83% { translate: 0.6px 0.7px; }
}
.burger-jitter { animation: psx-jitter 1.1s steps(1, jump-none) infinite; }
steps(1, jump-none) holds each keyframe and snaps to the next. Sub-pixel amounts, but your eye catches it instantly.
One light, five tones
The cel shading isn't hand-picked colour. The generator computes a normal per face, dots it with one warm key light, then quantizes the result to a 5-step ramp mixed toward that light and a single cool ambient.
Quantization is what makes it read as cel-shaded — the banding falls out of the renderer instead of being painted on top. And because all seven ingredients derive from the same two colours, they look like one render instead of seven stickers.
Black outlines, no soft edges, no round corners
PSX-era art has hard everything. No border-radius anywhere — corners are chamfered with clip-path: polygon(). No blurred shadows — box-shadow: 8px 9px 0 with zero blur. Text outlines are a ring of eight zero-blur text-shadows rather than -webkit-text-stroke.
The one that fought me
The big MÉQUI wordmark uses background-clip: text for its cel-shading bands, plus a stack of drop-shadow() filters for the outline. It rendered solid black: Chrome drops the text clip when a filter lands on the same element.
The fix was to stop asking one element to do two jobs — solid colour and the text-shadow ring on the base element, gradient clip on an ::after with content: attr(data-txt) sitting exactly on top.
Motion
The layers separate with a CSS scroll-driven animation on a named view-timeline, ranged contain 0% → contain 100% — which, for a section taller than the viewport, is exactly the window where its sticky child is pinned. No scroll listeners, no library. It's wrapped in @supports (animation-timeline: view()) with a real fallback, since Firefox doesn't ship it yet.
The page carries one line of runtime JS: the CSS import Vite needs as an entry point.
Thanks for reading! The fog and dither snippets are genuinely drop-in — steal them.


Top comments (0)