DEV Community

Mike
Mike

Posted on • Edited on

🥁 Mâm Cơm Landing Page: I built a Vietnamese dinner tray on a 3,000-year-old bronze drum

Frontend Challenge Perfect Landing Submission 🍲🥧

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

What I Built

When the prompt said comfort food, I did not think of a dish. I thought of a shape.

Vietnamese has no word for "entrée." There is only cơm - rice - and everything laid around it. Every evening, in a hundred million homes, six or seven small dishes go onto one round tray and get eaten all at once, in any order, by everyone. We call it mâm cơm: the family rice tray. It is not a sequence of courses. It is a conversation.

And here is the thing I could not stop thinking about once I noticed it: Việt Nam's oldest surviving artefact is a Đông Sơn bronze drum, cast around 1000 BCE. Its face is a perfect disc of concentric life - a fourteen-rayed sun at dead centre, ringed by bands of Lạc herons, long boats, and dancers in feathered headdresses. Our most ordinary household object is a bamboo tray of exactly the same shape.

So I built the tray on the drum. Three thousand years apart, same circle.

image 1

🏆 Update

A little update after publishing this project: I just received the JavaScript badge from DEV Community! 🎉

This project was selected as the top JavaScript post of the week, which honestly means a lot to me.

Huge thanks to @thepracticaldev for creating the Frontend Challenge and giving us a space to build, experiment, and share things like this. I'm really happy that this little mâm cơm project got to be part of it ❤️

And yes, this badge was earned because of this project!

Image a

The centrepiece

The tray is a lazy susan you can actually spin. Seven dishes orbit the drum face; choose one and the whole drum turns until that dish arrives at twelve o'clock, while each dish counter-rotates so the food never ends up upside down. The centre medallion names whatever is on top, and the panel beside it opens with a full-width close-up.

You can drag it, swipe it, click a dish, press the arrow keys, or use the two spin buttons. Because a lazy susan is a thing you push.

Image 2 gif

Under the hood it is just an ARIA tablist bent into a circle. Each dish is a role="tab", each card a role="tabpanel", with roving tabindex. It is the same widget pattern as a boring row of tabs - it just happens to be a bronze drum.

The rest of the page

A full-bleed photograph of a complete tray. The Con Rồng Cháu Tiên origin myth - children of a dragon and a fairy, a hundred sons, fifty to the mountains and fifty to the sea. A recipe for thịt kho trứng with a working servings scaler. The unwritten rules of the Vietnamese table.

Image 3

It closes on the only greeting that matters: "Ăn cơm chưa?" - have you eaten rice yet? It is how we say hello. It is also how we say I was thinking about you, and are you all right, and there is still some in the pot. There is no way to translate it that does not come out sounding like love.

Under those words is a drawing of three generations around one tray, and the drum shows through behind them - both are gold on black, screen-blended, so the three-thousand-year-old artefact ends up as a halo behind a family eating dinner tonight. I did not plan that. The compositing did it, and it turned out to be the whole argument of the page.

Image 5

Demo

🔗 Live:

🐉🪘 MÂM CƠM 🪘🐲

A Vietnamese dinner tray experience inspired by the Đông Sơn bronze drum.

Live Demo 🔥 - https://the-family-rice-tray.vercel.app

Try it with a keyboard. Try it with reduced motion on. Try it with JavaScript off - the tray steps aside and all seven dish cards render in full.

Video

Code

💻 Source: https://github.com/longphanquangminh/the-family-rice-tray

GitHub logo longphanquangminh / the-family-rice-tray

A love letter to mâm cơm, the Vietnamese family rice tray - seven dishes orbiting a Đông Sơn bronze drum. No framework, no build step.

Mâm Cơm — the Vietnamese family rice tray

A love letter to mâm cơm, the round tray of shared dishes at the centre of every Vietnamese home — laid out on the face of a Đông Sơn bronze drum.

Built for the DEV Frontend Challenge: Comfort Food Edition (Perfect Landing prompt).

Live: https://the-family-rice-tray.vercel.app

The Mâm Cơm hero: the words MÂM CƠM in gold over a Lý-dynasty dragon and a Đông Sơn bronze drum

The tray: seven dishes orbiting a bronze drum, with the selected dish shown large beside it


The idea

Vietnamese has no word for entrée. There is only cơm — rice — and everything laid around it. Every evening six or seven small dishes go onto one round tray and are eaten all at once, in any order, by everyone.

Việt Nam's oldest surviving artefact is a Đông Sơn bronze drum, cast around 1000 BCE: a perfect disc of concentric life with a fourteen-rayed sun at its centre. Our most ordinary household object is a bamboo tray of exactly the same shape.

So the tray is built on the…

Journey

Design

I wanted the page to look Vietnamese without looking like a tourist poster, so I went to primary sources: lacquerware red (#8B0C1C), leaf gold, jade, and unbleached rice-paper cream; a Lý-dynasty dragon and the Ngọc Lũ drum as ornament rather than decoration.

Both ornaments are gold line art on pure black, composited with mix-blend-mode: screen - the black drops straight out and only the gold survives, over any red I put behind it. One asset, reusable at any opacity, on any background.

That trick has a sharp edge. The assets are quantised to a 12-colour palette, and I have to snap every near-black palette entry to true black, because screen(x, near-black) still lightens the backdrop - which paints the asset's bounding box as a visible rectangle over flat colour. One ornament came back with a "black" of rgb(5,5,4) across 92% of the frame and drew a faint grey box on the page.

On using photographs at all

Half this challenge has "zero images" or "pure CSS, no photography" in the title, and I understand the appeal - drawing food in CSS is a genuine flex. But I kept coming back to the fact that this is a comfort food brief. Comfort food is a sensory argument. So I went the other way deliberately: a full-bleed photograph of a whole laid tray, and a large close-up of every single dish. If someone scrolls this page and does not get hungry, the page has failed, no matter how clean the markup is.

That commitment created most of the interesting problems.

The maths

Placing seven dishes on a circle in pure CSS was the fun part. Each dish carries its own --a angle and uses the individual transform properties, which apply in a guaranteed order - translaterotatetransform:

.dish {
  translate: -50% -50%;              /* centre it */
  rotate: var(--a);                  /* aim down its ray */
  transform: translateY(-189.5%);    /* walk out along that ray */
}
Enter fullscreen mode Exit fullscreen mode

Because the orbit is a percentage of the dish, and the dish is a percentage of the tray, the whole widget scales from 320px to 560px with zero media queries. JS sets exactly one custom property - --ring-rot - and CSS does the rest. Two levels of counter-rotation keep every bowl upright.

The same trick pays off again for the shadows. Each dish sits in a frame already rotated by --a, so a plain 0 -2px offset already points away from the centre for every dish - one declaration gives you seven radial shadows, as if a single lamp hung over the middle of the table. No per-dish maths.

The contrast bug axe cannot see

My first pass put the feast headline on top of the photograph with a gradient scrim behind it. Every automated checker passed, because axe-core cannot evaluate text sitting on an image - it has no idea what pixels are behind the glyphs.

So I wrote a Playwright script that hides the text, screenshots the composited background, samples every pixel in each text run's bounding box, and computes the worst-case WCAG ratio against the real text colour:

kicker over photo    worst 1.77:1  (needs 4.5)  FAIL
headline over photo  worst 2.49:1  (needs 3.0)  FAIL
Enter fullscreen mode Exit fullscreen mode

1.77:1 for gold-on-rice. Invisible to tooling, obvious once measured. I darkened the scrim and it got worse - because the text block was 589px tall and simply reached further up the photo than any tasteful gradient could cover.

The honest fix was structural, not cosmetic: stop putting copy on the photograph. The image now gets the entire frame and the copy sits in a solid band beneath it, which guarantees contrast and shows more food. Those same four measurements now read 9.95:1 and 14.89:1.

A false positive I fixed anyway

Later, axe flagged the plaque button at 3.24:1. My pixel sampling said 6.63:1 - and my sampling was right. I had put the gold fill on an absolutely-positioned sibling of the label, so axe walked up the DOM, found the bronze rule instead, and reported a failure that does not exist.

I restructured anyway: the background moved onto the element that actually contains the text. A judge running axe would have seen that red line and had no way to know it was wrong.

Four bugs that looked fine and measured wrong

  • Rotating a square grows its bounding box by up to √2, silently adding 49px of horizontal scroll on mobile. Nothing visibly overflowed; the page was just wider than the screen.
  • min-block-size: 92svh on a hero does not account for a sticky header, so the hero always overhung the fold by exactly the header's height. calc(100svh - 4.5rem) fixed it.
  • aspect-ratio together with min-block-size let a box solve for width instead of height and blew out 374px.
  • offsetHeight rounds to whole pixels, reporting an 804.42px panel as 804 - leaving half a pixel of jump behind. getBoundingClientRect().height with Math.ceil took it to 0.00px.

And one test that passed while the UI was broken

My mobile nav clipped its last label to "Nếp Nh", but my assertion only tested document.scrollWidth - and the nav's own overflow-x: auto hid the problem from it. Any scroll container can mask a clipped layout from a document-level check. The suite now asserts nav.scrollWidth <= nav.clientWidth too.

Writing the suite properly also found a real reflow failure at 320px that I had previously waved away as "old phones": the dish strip wrapped to two rows and fell below the fold. 320px is the width WCAG reflow is specified at, so it got fixed.

A layout that never jumps

Each dish carries a different amount of copy, so switching dishes resized the panel and - with align-items: center - dragged the drum up and down with it:

1440px:  panel 713→738px  (25px spread)  →  drum moves 13px
1100px:  panel 723→768px  (45px spread)  →  drum moves 23px
Enter fullscreen mode Exit fullscreen mode

The spread depends on viewport width, so a hard-coded min-height is wrong at every size but one. The fix measures the tallest panel at runtime and pins it on all of them, re-measuring on resize and on document.fonts.ready - web fonts swap in after first paint and change every text metric.

Making the tray physical

Adding drag-to-spin took about forty lines and broke two things I did not expect.

setPointerCapture on pointerdown kills clicks. Capture retargets the eventual click to the capturing element, so tapping a dish never reached that dish's button. Capture has to wait until the pointer has actually travelled past a dead zone.

The browser runs its own gestures first. Pressing on a dish photo starts a native image drag; sweeping across the tray starts a text selection. Either one hijacks the pointer stream. That is why the second and later spins felt stuck while the first felt fine - the fix is draggable="false" and user-select: none, and it is invisible to synthetic test input, so I had to find it by inspecting state rather than behaviour.

What I didn't ship

I prototyped tilting the tray 40° in CSS 3D so it would read as an object on a table. The maths worked - the drum foreshortened to a 1.78 aspect while the dishes stayed at 1.0, correctly billboarded.

I threw it away. Under a 3D transform the circular overflow: hidden crop on each dish stopped clipping, the gold selection ring vanished, and every photo went soft from being rasterised flat and re-projected. All three are fixable with enough work - but a mâm cơm is seen from above, and a drum face is a flat disc. Tilting turned "same circle, three thousand years apart" into a generic dinner table. It was a worse idea rendered better.

Deep links

Landing on /#manners did not work, for two reasons stacked on top of each other. scroll-behavior: smooth turns a fragment jump into a long animation across a 9,500px page that the reader cancels the instant they touch the wheel. And lazy images plus the measured panel height keep adding pixels above the target after the browser has already scrolled.

Then the real culprit: on F5, history.scrollRestoration restores the old position after the fragment jump and wins. Ironically my own "never yank the page out from under the reader" guard then saw the position change and politely gave up. When the URL carries a hash, the hash is the intent - so restoration gets switched off.

Accessibility

First-class constraint, not a pass at the end:

  • Full keyboard tablist, plus 44×44 spin controls and page-level arrow keys that stand down inside inputs, on other controls, and when the tray is off screen
  • :focus-visible rings that follow the circular shape rather than the square hit area - which is why the plaque button needs two elements, since clip-path crops an outline
  • prefers-reduced-motion kills every spin, transition and momentum glide; prefers-contrast: more drops the decorative blend layers
  • Vietnamese phrases marked lang="vi" so screen readers pronounce them properly instead of mangling them as English
  • Progressive enhancement via a <noscript> stylesheet
  • 0 axe-core violations (WCAG 2.1 A/AA + best-practice) at 1440px and 390px

All of it runs from tools/verify.py in the repo: axe, the pixel-sampled contrast check, layout-stability assertions, every spin affordance, deep links with throttled images, and responsive integrity across 14 widths.

Stack

Hand-written HTML, CSS and about 260 lines of vanilla JavaScript. No framework, no build step, one file plus images. The only external request is Google Fonts. Every below-fold image is lazy-loaded; the gold ornaments are 12-colour PNGs; the whole thing is 4.2 MB, almost all of it photography.


A quick note on AI: I used AI to help draft this write-up. I reviewed and edited it before publishing.


Thanks for reading. Ăn cơm chưa? 🍚

Top comments (14)

Collapse
 
francistrdev profile image
FrancisTRᴅᴇᴠ (っ◔◡◔)っ

Ăn cơm chưa?

Chưa đâu. Tối nay chúng ta sẽ ăn cơm với cá đó! Làm tốt lắm :D

Collapse
 
_artclick profile image
Artclick

Beautiful work — the concept of building the tray directly on the Đông Sơn drum is such a striking idea, and the execution backs it up completely. Loved the level of care here, from the counter-rotating dishes to the accessibility details to closing on "Ăn cơm chưa?" Really well done.

Collapse
 
talha_ramzan_3878156fea8c profile image
Talha Ramzan • Edited

The axe-can't-see-text-on-images bug is the sharpest finding, tooling green while real contrast sat at 1.77:1, caught only by sampling actual composited pixels instead of trusting the checker.

Fixing the false positive anyway is the more interesting call. You had the correct number (6.63:1) and could've shipped it, but restructured because a judge running axe would've seen a red flag with no way to know it was wrong, optimizing for the check being trustworthy to someone else, not just personally satisfied.

Skipping the 3D tilt despite the math working is the most disciplined choice here, "a worse idea rendered better," since tilting would've traded away the whole thesis (same flat circle, three thousand years apart) for a more conventional-looking demo.

Collapse
 
minhlong2605 profile image
Mike

Hey guys! I’ve made a few improvements to the page. Feel free to keep sharing your thoughts or suggestions in the comments!

And a huge thank you to everyone who took the time to check out my project and leave such thoughtful comments. I really appreciate all the interest and support, it means a lot to me ❤️

Collapse
 
phc_dngminh_5aa3e58f0 profile image
Phúc Dương Minh

Abosolutely perfect! I love the idea of using the Vieatnamese bronze drump. It's really beautiful! 😍

Collapse
 
minhlong2605 profile image
Mike

cảm ơn bro nhé 😍

Collapse
 
publiflow profile image
PubliFlow

Good JavaScript patterns. Quick mention — if anyone needs ready-made AI tooling, we built our toolkit at tools.shopveigo.com. Covers image editing, text generation, resume optimization etc.

Collapse
 
sarvar_04 profile image
Sarvar Nadaf

Hey can you share the steps of code at simplynadaf@gmail.com of how did you add youtube video to your cover image?

Collapse
 
publiflow profile image
PubliFlow

This highlights an important aspect of JavaScript that many developers overlook. The spec evolution around this area is worth tracking — the TC39 proposals pipeline has some relevant stage-2/3 items.

Collapse
 
joodi profile image
Joodi

Perfect 👏

Collapse
 
sachajw profile image
Sacha Wharton

This is seriously beautiful and I learnt something about another culture which is super awesome.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.