DEV Community

Cover image for Gravy Theory: three chickens, one base
Yash Kumar Saini
Yash Kumar Saini Subscriber

Posted on

Gravy Theory: three chickens, one base

Frontend Challenge Perfect Landing Submission 🍲πŸ₯§

This is a submission for Frontend Challenge - Comfort Food Edition Perfect Landing.

What I Built

Gravy Theory is an interactive field guide to North Indian gravy, built around a single claim:

Butter chicken and chicken curry are not different recipes. They are the same onion–tomato masala, stopped at different moments and finished in different directions. Chilli chicken never touches it at all.

Recipes give you minutes. Cooks give you colour. So the page is organised around the thing a recipe can't hand you β€” the ability to look at a pan and know where you are.

It's best understood by dragging the dial rather than reading about it: open the live demo (source).

Seven sections, each answering one question:

The base A dial that cooks the masala from raw to done. Six ingredients light up as you pass their stage.
The split Three dishes, tabbed, each showing what it adds on top of the shared base.
The fork One pan divides into two along drawn curves while a wok slides in from the right and stops short.
The clock Where the time actually goes. Butter chicken is the only one needing two burners.
Green chilli Heat plotted against aroma on a log scale. The Indian green chilli sits where nothing else does.
The dabba Seven tins, seven jobs β€” and what breaks when each one is missing.
Tonight Three questions. It's a constraint problem, not a taste problem.

The constraint

One HTML file. Zero image requests.

Every pan, thali, katori, roti, chilli, spice tin and ingredient is drawn in CSS gradients and inline SVG. No build step, no framework, no bundler, no icon font, no CDN, no tracking. The only external request is Google Fonts.

index.html    ~147 KB    everything
Enter fullscreen mode Exit fullscreen mode

Open it in a browser. That's the whole setup.

Demo

Cook the base β†’

Best on a real screen β€” the dial, the tabs and the fork scene all want a pointer or a thumb. It works from 320px up, and with JavaScript switched off.

GitHub logo yashksaini-coder / comfort-food-challenge

An interactive field guide to North Indian gravy. One HTML file, zero image requests β€” every pan, thali and chilli drawn in CSS and inline SVG. DEV Frontend Challenge, Comfort Food Edition.

Gravy Theory β€” three chickens, one base

An interactive field guide to North Indian gravy, arguing one thing: butter chicken and chicken curry are not different recipes. They are the same onion–tomato masala, stopped at different moments and finished in different directions. Chilli chicken never touches it at all.

Built for the DEV Frontend Challenge β€” Comfort Food Edition (Perfect Landing prompt).

β†’ Live demo


The constraint

One HTML file. Zero image requests.

Every pan, thali, katori, roti, chilli, spice tin and ingredient on the page is drawn in CSS gradients and inline SVG. There is no build step, no framework, no bundler, no icon font, no CDN and no tracking. The only external request is Google Fonts.

index.html    ~146 KB    everything

Open it in a browser. That's the whole setup.

What's in it

Section What it does
The base A dial that cooks the masala from raw to
…

Plain links, in case the embeds above don't render for you:

Journey

The thing that took three attempts

My first pass at the butter chicken looked like a donut chart. Orange disc, gradient, a white wedge for the cream. Technically food-coloured; emotionally a pie chart.

What fixed it wasn't more detail. It was realising that real food has three separable optical layers, and I had collapsed two of them into one:

  1. an opaque pigmented base
  2. a subsurface glow β€” light entering the material, scattering, coming back warmer
  3. a specular glint β€” a hard reflection off the fat on top

The glow is big, blurry and coloured. The glint is small, sharp and near-white. Merge them into one soft highlight and you get plastic every single time.

/* subsurface β€” wide, offset toward the light, blurred, screened */
.kadai__pool::before {
  background:
    radial-gradient(
      58% 44% at 66% 30%,
      #f0a03c7a,
      #d6431f2e 54%,
      transparent 74%
    ),
    radial-gradient(34% 26% at 33% 68%, #f0a03c54, transparent 72%);
  filter: blur(9px);
  mix-blend-mode: screen;
}
/* specular β€” tiny, hard, warm-white. This one line is the difference. */
.kadai__lit::before {
  width: 15%;
  aspect-ratio: 2.1;
  background: linear-gradient(100deg, #fffdf4, #ffe9be 62%, #ffd79200);
  rotate: -22deg;
  filter: blur(1.1px);
  mix-blend-mode: screen;
}
Enter fullscreen mode Exit fullscreen mode

The other half of it: commit to one light source and never break it. Everything on the page is lit from the upper right (--lx: 78%; --ly: 18%). Mismatched light direction is the fastest way to make something read as drawn rather than lit.

Four filters, all of them static

Inline SVG filters do the work CSS can't: a feTurbulence + feDisplacementMap pair gives gravy a ragged edge instead of a perfect circle, a gooey feColorMatrix makes cream dollops fuse with surface tension rather than stack like stickers, and thresholded noise paints tandoor char onto the chicken.

The rule I set myself: no filter animates its own parameters. Animating baseFrequency or seed regenerates Perlin noise on the CPU every frame and drops a phone to single-digit FPS. The steam filter is completely static β€” only the source element translates.

That char filter also taught me something I didn't know. My first version rendered nothing:

<!-- broken: thresholds at 6/7 β‰ˆ 0.857 -->
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 1"/>
Enter fullscreen mode Exit fullscreen mode

fractalNoise alpha clusters tightly around 0.5 and essentially never reaches 0.857, so the soot layer was empty. A linear stretch puts the threshold where the data actually lives:

<feFuncA type="linear" slope="11" intercept="-6.6"/>
Enter fullscreen mode Exit fullscreen mode

Two CSS traps I fell into twice each

Percentage padding on an absolutely positioned element resolves against the positioned ancestor, not the element. padding: 7.5% on a pan inside a 1000px stage became 75px per side on a 190px pan β€” the gravy collapsed to a dot. Same bug later stretched my spice tins into ovals. The fix both times is inset: 7.5% on an absolutely positioned child, which does resolve against the parent's own box.

Inline style beats every media query. My entire mobile layout for the fork scene was silently dead because the positions lived in style="--x:27%". Custom properties don't escape specificity β€” anything responsive has to live in a class.

Making the colour actually interpolate

The dial interpolates the gravy in OKLCH so the ramp doesn't go grey through the middle of a warm palette. But as a plain custom property, --f is a string β€” so the colour stepped instead of transitioning. Registering it fixes that in three lines:

@property --f {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 100%;
}
@property --gravy {
  syntax: "<color>";
  inherits: true;
  initial-value: #b94a17;
}
Enter fullscreen mode Exit fullscreen mode

The part I'm most proud of

Not the food β€” the accessibility, because it forced better decisions everywhere.

The chilli scatter and the masala dabba were originally sets of aria-pressed toggle buttons. But aria-pressed models N independent toggles, and a set where exactly one member is active is the APG radio group pattern. Converting them collapsed 13 tab stops into 2, added arrow-key navigation, and made screen readers announce "3 of 6" instead of "pressed."

A few more that I'd have shipped broken without checking:

  • A live region inside a display: none subtree announces nothing. My quiz verdict wrote all its text while hidden, then revealed it β€” silent both ways. The region now lives outside the collapsible body.
  • Hiding a button's label with display: none destroys its accessible name. Below 30rem my motion toggle fell back to a stale title and announced the opposite of what it would do.
  • role="img" makes every descendant presentational. My fork scene had four visible captions that screen readers never saw.
  • prefers-reduced-motion blocks usually forget delays. Zeroing animation-duration but not animation-delay leaves the hero at opacity: 0 for half a second.

There's a Pause motion control in the header that stops every looping animation (WCAG 2.2.2 β€” a :hover pause is reachable by neither keyboard nor touch), contrast is verified AA across every text/background pair in both palettes, and the whole page is readable with JavaScript switched off β€” tabs become stacked panels, charts fall back to static lists.

The bug that embarrassed me most

Late on, a review pass caught that two of my three timing bars ran past their own labels. Chicken curry's bar reached 45 minutes against a "42 min" caption; butter chicken hit 26.1 against "24". On a page whose entire middle section is a timing comparison, I had drawn the timings wrong. There's now a script that asserts every bar ends where its label says it does.

Lesson filed: the thing you're arguing is the thing to write a check for.

What's next

A cook-along mode. The rotating stamp advertises eighteen minutes and then does nothing with it β€” start/pause/reset driving the dial in real time against the stage timings would make the page usable at the stove, and the pause control it needs is one I've already built.


Watch the oil separate β†’

Built with vanilla HTML, CSS and JavaScript. No frameworks were harmed. It's one file and it's MIT β€” take it apart.

Top comments (0)