This is a submission for Frontend Challenge - Comfort Food Edition, CSS Art.
Inspiration
My other entry in this challenge is Still Warm, a museum where comfort food is the art. Its catalogue runs to CAT. 007, and number 006 is missing from the walls. This is that exhibit. It could not hang in a frame because it refuses to sit still: borscht is not a dish so much as a patience ritual, the comfort food that colors itself while you wait. CAT. 006 - THE POT lives outside the museum, on its own pedestal, cooking.
Demo
If the embed does not load, open the pen directly. Press "Cook it again" to replay. If your system prefers reduced motion, the pot is served already cooked.
Journey
The idea
Every CSS Art entry I have seen draws the finished plate. I wanted the cooking. The piece runs three acts in about eight seconds: a pale broth at a quiet simmer, three pieces of beet falling in, and the color spreading through the liquid until it is unmistakably borscht, with potatoes plopping in after it, a dollop of smetana, and a pinch of dill from above. Then it rests on low heat.
The spread
The part I care most about is the dye. Four registered custom properties drive the gradient stops of the broth surface:
@property --spread-a { syntax: "<percentage>"; inherits: false; initial-value: 0%; }
@property --spread-b { syntax: "<percentage>"; inherits: false; initial-value: 0%; }
@property --broth-shift { syntax: "<percentage>"; inherits: false; initial-value: 0%; }
@property --spread-edge { syntax: "<percentage>"; inherits: false; initial-value: 0%; }
.broth {
--spread-a: 100%; /* at rest, the dye has reached every wall */
background: radial-gradient(
130% 118% at 62% 38%,
var(--bordeaux) calc(var(--spread-a) * 0.58),
color-mix(in oklab, var(--bordeaux) 56%, var(--ink))
calc(var(--spread-a) + var(--spread-edge)),
transparent calc(var(--spread-a) + var(--spread-edge) * 2 + 14%)
);
}
Three dyed layers sit over the pale Act I base, each with its own center near the beet's entry point and its own duration and delay. The fourth property, --spread-edge, starts wide and tightens to nothing, so the traveling front is always softer than the core. Different centers plus different clocks plus a softening edge is what makes it read as ink in water instead of a radar sweep. The pale broth and its fat eyes are still there underneath, permanently, waiting to be dyed over on every replay.
The architecture
The default values of every property are the finished borscht. Keyframes only declare "from"; where a "to" is missing, the resting rule already knows the end. So a browser without @property support, and any visitor with prefers-reduced-motion, lands on the finished dish instead of a broken pale one. The replay is one button and an :active sibling selector: while the button is held, every animation is removed, and letting go starts the kitchen over. Still zero JavaScript.
37 DOM elements, 59 animations, one style block.
Honest fails
- The smetana became a cartoon face three times (a swirl ring, then an eyeball, then two dill flecks landing side by side). My worklog now contains the rule "at most one round dot on any white surface".
- The first beet froze mid-air. Three easings met at zero velocity and the anticipation beat read as a glitch. It is now a moving hold: it keeps sinking and turning through the pause.
- The simmer was invisible twice. Once for starting too late (fixed with negative delays, so the pot was simmering before we arrived), and once for losing contrast when the broth turned bordeaux (a bubble recipe tuned on pale gold vanishes on dark red).
- The beet batons spent one whole review as kidney beans, and the fat eyes as tan cookies. The fix was value relationships, not shapes: the color source must sit deeper than the liquid it dyes.
The small print
The figure is role="img" with an aria-label that reads the dish to screen readers, placard line included. No images, no SVG, no fonts, no libraries; the palette is eight named colors and every other paint is a color-mix of them. MIT licensed.



Top comments (2)
im coming at this from the wrong side of the house, i dont do css art, i spend my time on systems that fail badly. so the part that got me is not the dye, its this.
the default values of every property are the finished borscht, and keyframes only declare from. so no @property support or reduced motion lands you on the finished dish instead of a broken pale one.
thats a fail safe and its the good kind, which is rarer than it sounds. almost everything degrades toward its broken state, because the working version lives in the animation and the animation is the thing that dies. you put the truth in the resting rule and let the motion only describe leaving it. the degraded path and the correct path are the same path. i work on stuff where absence of a signal reads as fine and it takes real effort to invert that, and you got it for free by choosing where the end state lives.
the honest fails section is the other reason im commenting. three cartoon faces and a worklog rule that says at most one round dot on any white surface is funnier and more useful than a clean writeup would have been. the beet batons reading as kidney beans until you fixed the value relationship instead of the shape is the kind of note people actually learn from.
eight seconds, no js, and the broken case is dinner. nice.
WOW, looks good! 😃