DEV Community

Satyakoidala
Satyakoidala

Posted on

Creamy Tomato Penne, Plated in Pure CSS 🍝

Frontend Challenge CSS Art Submission 🍲🥧

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

Inspiration

Comfort food, for me, is a bowl of penne in creamy tomato sauce. It is the thing I make when the day has gone sideways and I want dinner to be twenty minutes of stirring and nothing more.

I had a photo I loved as a starting point: a fluted cream stoneware bowl on a terrazzo counter, penne heaped in rust orange sauce, two slices of garlic bread leaning on the rim. I wanted to paint that in CSS.

But a still picture of pasta felt like half the idea. The comfort isn't only the eating, it's the making. So the artwork plates itself: the empty bowl lands, plain boiled pasta drops in, the sauce goes in and the pasta takes its colour, then garlic, then basil, then a shower of cheese with the garlic bread arriving through it.

Demo

No images. No SVG. No JavaScript. Every shape is a div or an <i> styled with gradients, border-radius, clip-path, mask and filter.

The plating sequence

Cue Beat
0.5s the empty bowl lands
1.15s 34 pieces of plain pasta drop in, 32ms apart
2.5s sauce spreads out beneath them
2.65s the pasta takes the sauce's colour
3.3s a last spoonful drizzled over the top
3.8s softened garlic
4.2s basil, wilted into the sauce
4.6s cheese shavings rain over the whole plate
4.9s garlic bread arrives while the cheese is still falling
6.1s steam starts its loop

Every cue is a custom property in :root, so the entire choreography can be re-timed from one block of ten lines.

Journey

One keyframe block plates the whole dish

My first instinct was a keyframe per ingredient. That would have been dozens of nearly identical blocks. Instead I flipped it around: the stylesheet describes the finished dish, and a single from-only keyframe describes only where each thing came from.

The catch is that transform is needed for the animation, so it can't also be doing the placement. Modern CSS solves this neatly with the independent translate, rotate and scale properties:

.plate,
.plating > * {
  animation: plate-in var(--dur, .62s) var(--ease, var(--ease-land)) var(--delay) both;
  --delay: calc(var(--t, 0s) + var(--i, 0) * var(--step, 45ms));
}

@keyframes plate-in {
  from {
    opacity: 0;
    transform: translate(var(--fx, 0em), var(--fy, -5em))
               rotate(var(--fr, 0deg))
               scale(var(--fs, .88));
  }
}
Enter fullscreen mode Exit fullscreen mode

Every ingredient carries its own numbers inline:

<i class="penne" style="--x:45%;--y:49%;--r:-14deg;--i:0"></i>
Enter fullscreen mode Exit fullscreen mode

Adding a piece of pasta is one line of HTML. Staggering 34 of them is arithmetic on --i, not 34 rules. And because the resting state is the base CSS, animation: none lands on the finished dish, which means reduced-motion support cost me exactly one media query and no special cases.

The pasta taking the sauce

This is the bit I'm happiest with. The pasta lands plain and turns sauce-coated when the sauce goes in, as a second animation on the same elements.

My first attempt was to desaturate the rust orange. That just made grey pasta. What actually works is sepia():

.plating > .penne {
  filter: sepia(0) saturate(1) brightness(1) drop-shadow();   /* sauced, at rest */
}

@keyframes sauce-coat {
  from { filter: sepia(1) saturate(1.75) brightness(1.24) drop-shadow(); }
}
Enter fullscreen mode Exit fullscreen mode

Pushing the colour through sepia and lifting the brightness lands on a convincing plain-boiled ivory.

The important detail: both filter lists have the same functions in the same order. Mismatched filter lists interpolate discretely, so you get a visible snap halfway through instead of a smooth change. Matching them is the whole trick.

The bug that taught me the most

For a while the sauce-coat animation simply did not run, and the pasta was sauce-coloured from the moment it landed. No console error, no warning, nothing.

The pasta is the only element running two animations, so it has its own animation shorthand. But the generic single-animation rule lives further down the stylesheet, and it had the same specificity. Later rule wins on source order, so it quietly replaced the two-animation shorthand with a one-animation one and dropped the colour change entirely.

The fix was to select it as a child so it outranks the generic rule:

.plating > .penne { animation: plate-in , sauce-coat ; }
Enter fullscreen mode Exit fullscreen mode

I only found it by dropping filter: invert(1) into the keyframe and seeing absolutely nothing happen. Worth remembering: a shorthand losing a cascade fight looks identical to a shorthand you forgot to write.

Things that turned out to be harder than they looked

Cheese shavings. My first two attempts used ellipses, and 36 pale ellipses scattered over orange sauce read unmistakably as grains of rice. Real grated cheese is angular. Three clip-path polygons, cycled, fixed it instantly:

.cheese   { clip-path: polygon(8% 34%, 40% 4%, 88% 16%, 100% 62%, 62% 96%, 20% 84%); }
.cheese-b { clip-path: polygon(0 52%, 26% 8%, 74% 0, 100% 40%, 80% 92%, 34% 100%); }
.cheese-c { clip-path: polygon(14% 10%, 68% 0, 100% 44%, 86% 88%, 30% 100%, 0 66%); }
Enter fullscreen mode Exit fullscreen mode

The fluted rim. The single highest-value detail in the whole piece, and one line of CSS: a repeating conic gradient, masked down to a ring.

background: repeating-conic-gradient(from .4deg,
  rgba(255, 252, 243, .85) 0 1.5deg,
  rgba(184, 158, 122, .5)  1.5deg 3deg);
mask: radial-gradient(closest-side, #0000 57%, #000 63%, #000 97.5%, #0000 100%);
Enter fullscreen mode Exit fullscreen mode

Terrazzo. About thirty hand-placed radial gradient chips across three background layers at different background-size values, with zero extra elements. The largest tile is wider than the frame, so it never visibly repeats.

Making a heap look like a heap. The pasta is built in two passes: a lower layer sitting down in the sauce with a soft contact shadow, and a top layer a shade lighter, seven percent larger, casting a harder shadow. That contrast alone creates the depth, so there is no z-index anywhere in the file.

Grounding the tableware. One light source, declared once as two shadow colours, and every object carries both a tight contact shadow hugging its edge and a broad soft cast offset down and right. A single soft shadow makes things look like they're floating. The tight one is what sells the weight.

Scaling

Every dimension in the file is in em, and one font-size: clamp(6px, 1.55vmin, 16px) on the scene is the only thing that changes with the viewport. The artwork scales as a single unit, so the only media query in the stylesheet is the reduced-motion one.

By the numbers

  • 133 elements
  • ~870 lines of CSS
  • 122 gradients
  • 1 media query, and it's for prefers-reduced-motion
  • 0 images, SVGs, and lines of JavaScript

Built With

  • Semantic HTML
  • CSS gradients, clip-path and mask
  • The independent translate / rotate / scale properties
  • CSS custom properties for the whole timeline
  • CSS animations, no JavaScript
  • No external images

Thank you for reading. Now I'm hungry. 🍝

Top comments (0)