DEV Community

Cover image for Stay for Dinner - CSS Art Submission
Natasha Pierre-Louis
Natasha Pierre-Louis

Posted on

Stay for Dinner - CSS Art Submission

Frontend Challenge CSS Art Submission 🍲🥧

This is a submission for Frontend Challenge - Comfort Food Edition, CSS Art.

Inspiration

Pasta and meatballs - but really, the kitchen it gets made in.

Comfort food isn't just the plate. It's the warm room at the end of the day, the window going orange, someone saying stay, there's plenty. So I didn't draw a bowl of spaghetti on a flat background. I drew the whole kitchen at dusk: sage cabinets, subway tile, an island with three stools, and dinner already sitting out.

Then I made it explorable, because a room you can only look at isn't really an invitation.

Demo

Live demo: https://stay-for-dinner-three.vercel.app/
Source: https://github.com/natashapl/stay-for-dinner

Everything you see is HTML elements and CSS. No SVG, no canvas, no images, no icon fonts, no emoji. React is in there, but only as a composition tool. The components return nested divs and CSS paints all of it.

Things to try: click either bowl for the recipe, click the plate stack to serve dinner, click the window to watch night fall, turn on the faucet, and open the fridge and the wall cabinets.

Journey

A note on how this was built: I've been a frontend developer for 15+ years, but this was my first real attempt at CSS art. So, I worked through it in collaboration with Claude Code and Codex. I wrote the brief, set the twelve build phases, made the design calls, and reviewed every phase against the reference. The lessons below are the ones I hit doing that.

One unit ran the whole drawing

The scene is a CSS size container, so --u: 1cqw is always 1% of the frame's width no matter how big the frame is:

.kitchen {
  --u: 1cqw;
}
Enter fullscreen mode Exit fullscreen mode

That gave me one rule I applied everywhere: positions in %, sizes in multiples of --u. The payoff is that the kitchen rescales as a single composition - a 4u bowl rim is still 4u on a phone - and my responsive stylesheet only touches the page around the frame. Not one media query moves a cabinet. Any time an object seemed to need a breakpoint to stay put, it turned out I'd sized it in px by mistake.

Lighting is a pass over the room, not shading on each object

My first version shaded every object individually and looked flat no matter how many gradients I piled on. The fix was to stop lighting objects and light the room, in two layers on top of the finished artwork:

.scene-shade { mix-blend-mode: multiply; }  /* shadow */
.scene-light { mix-blend-mode: screen; }    /* light  */
Enter fullscreen mode Exit fullscreen mode

A shadow painted as rgb(0 0 0 / 0.3) is a grey film - it lowers brightness but leaves hue alone, so a shadowed sage cabinet and a shadowed terracotta floor both drift toward the same dead grey. multiply in a warm brown instead pushes the sage toward olive and the terracotta toward oxblood: each surface darkens through its own hue. screen is the same trick inverted for light. That one change was most of the difference between flat and lit.

They have to be separate layers, because shadow and light aren't opposites - occlusion collects in corners and under overhangs, light pours from one direction. One gradient trying to do both just reads as a vignette.

The bug that taught me the most

Two trees outside the window looked wrong, and I was certain it was a layering problem - the hill appeared to pass straight through the trunks. I reordered the DOM. I added z-indexes. Nothing.

It was never a z-index bug. The trunk's mid-tone landed on roughly #a2703f; the hill behind it was #a96a3b. A silhouette is read entirely by value, so two shapes at the same value merge no matter what the stacking order says. Darkening the trunk fixed it instantly.

The canopies had a second problem: I'd backed the leaf crowns with a large filler ellipse, and because radial-gradient radii are percentages of the box, that one ellipse reached every edge and rendered as a rectangle with rounded corners. The twelve crowns I'd carefully placed were all sitting inside it, drawing nothing. The rule I took away: the filler has to be smaller than the ring of crowns, so the crowns are the silhouette.

Night mode had to be registered to interpolate

All the scene colour lives on .kitchen rather than :root, so .kitchen.is-night can redeclare the same names and re-light everything downstream. But the transition still snapped, because plain custom properties don't interpolate. Registering the palette fixes it:

@property --wall {
  syntax: "<color>";
  inherits: true;
  initial-value: #ece0cc;
}
Enter fullscreen mode Exit fullscreen mode

Now the sky, cabinets, wood and lighting strengths all blend through the in-between values and night settles in instead of arriving as a palette flash. The strengths being typed <number> also means other effects can just multiply by them - the fridge light gets brighter as the room darkens, for free.

Making artwork accessible

This was the part my day job actually prepared me for. Buttons may only contain phrasing content, and my artwork is divs - so every interactive object is a transparent button overlaid on the art, not a button wrapping it. Hover and :focus-within then drive the same visual response.

The recipe is a native <dialog> styled as a right-hand drawer, which hands me Escape and focus containment for free; JavaScript only returns focus to the bowl that opened it. Plating announces its steps through an aria-live region, toggles carry aria-pressed and aria-expanded, and decorative motion respects prefers-reduced-motion.

Two fixes I only found by measuring rather than eyeballing: the scrollable recipe body had no focusable element inside it, so keyboard users couldn't scroll the ingredients at all - the container needed to be focusable. And the bowls and plate stack came out 34px and 27px tall on a 360px phone. That led to the one place I deliberately broke my own unit rule: a touch target isn't part of the drawing, it's the size of a fingertip, and a fingertip doesn't shrink when the frame does. Those insets are in px.

Where it landed

Production Lighthouse: Performance 100, Accessibility 100, Best Practices 100, SEO 100, with CLS 0 and TBT 0, from about 450 DOM elements and 22 KB of gzipped CSS.

What's next

I want to go back into the refrigerator and give every item inside its own top face - the three-face rule (lightest top, base-colour front, darker side) is what sells volume, and I applied it to the room's big objects but not to the small ones. And the doors want real thickness: at 68° open you're looking at a plane edge-on, and zero-thickness planes are the giveaway.

Mostly I want to keep doing this. I came in thinking CSS art was a party trick and left with a set of techniques - blend-mode lighting, container-relative units, typed custom properties - that I'm going to use in ordinary product work moving forward.

Top comments (0)