🥁 Mâm Cơm Landing Page: I built a Vietnamese dinner tray on a 3,000-year-old bronze drum
This is a submission for Frontend Challenge - Comfort Food Edition, Perfect Landing What I...
개요 #
Dev.to의 Frontend Challenge — Comfort Food Edition에 독특한 출품작이 올라왔다. 개발자 Mike는 "위안이 되는 음식"이라는 주제를 받고 요리가 아니라 모양을 떠올렸다.
베트남어에는 '메인 요리'에 해당하는 단어가 없다. 밥(cơm)이 있고, 그 주변에 놓인 모든 것이 있을 뿐이다. 저녁마다 수천만 가정에서 예닐곱 개의 작은 접시가 둥근 쟁반 하나에 올라가고, 온 가족이 순서 없이 함께 먹는다. 이것이 멈껌(mâm cơm), 가족의 밥상이다.
여기서 Mike가 발견한 연결고리가 이 프로젝트의 출발점이 됐다. 베트남에서 가장 오래된 유물은 기원전 1000년경 주조된 동선(Đông Sơn) 청동북이다. 북면은 동심원으로 채워진 완벽한 원반이다. 정중앙에 14개 광선의 태양이 있고, 그 둘레를 락(Lạc) 왜가리와 긴 배, 깃털 관을 쓴 무희들이 띠를 이루며 감싼다. 가장 평범한 살림살이인 대나무 쟁반이 정확히 같은 모양이었던 것이다.
그래서 그는 밥상을 북 위에 올렸다. 3000년의 간격, 같은 원.

회전하는 밥상, 실은 원형으로 구부린 탭리스트 #
쟁반은 실제로 돌아가는 회전 접시다. 일곱 개의 요리가 북면을 따라 배치되고, 하나를 고르면 북 전체가 회전해 그 요리를 12시 방향으로 데려온다. 이때 각 요리는 반대 방향으로 함께 돌아 음식이 뒤집히지 않는다. 중앙 메달리온에는 맨 위로 온 요리의 이름이 뜨고, 옆 패널이 전체 폭 클로즈업과 함께 열린다.
드래그, 스와이프, 요리 클릭, 방향키, 회전 버튼 — 조작 방법이 다섯 가지다. 회전 접시란 원래 밀어서 돌리는 물건이기 때문이다.

내부를 열어보면 원형으로 구부린 ARIA 탭리스트다. 각 요리가 role="tab", 각 카드가 role="tabpanel"이고 roving tabindex를 쓴다. 지루한 가로 탭 줄과 똑같은 위젯 패턴인데, 겉모습만 청동북일 뿐이다.
페이지의 나머지 구성도 밀도가 높다. 차려진 밥상 전체를 담은 풀블리드 사진, 용과 선녀의 자손 백 명이 오십은 산으로 오십은 바다로 갈라졌다는 Con Rồng Cháu Tiên 건국 신화, 인분 수 조절 기능이 붙은 팃코쯩(thịt kho trứng) 레시피, 그리고 베트남 식탁의 불문율까지.

페이지는 하나의 인사말로 끝난다. "Ăn cơm chưa?" — 밥은 먹었니? 베트남 사람들이 안녕이라고 말하는 방식이자, 네 생각을 하고 있었다고, 괜찮으냐고, 솥에 아직 남았다고 말하는 방식이다. Mike는 "사랑처럼 들리지 않게 옮길 방법이 없다"고 썼다.
그 문장 아래에는 한 쟁반을 둘러싼 삼대의 그림이 있고, 뒤로 북이 비쳐 보인다. 둘 다 검은 배경에 금색이고 스크린 블렌드로 합성해서, 3000년 된 유물이 오늘 저녁을 먹는 가족의 후광이 됐다. 의도한 결과가 아니라 합성이 만들어낸 우연이었지만, 결국 페이지 전체의 논지가 됐다.

데모 #
🔗 Live: https://the-family-rice-tray.vercel.app 💻 Source: https://github.com/longphanquangminh/the-family-rice-tray
키보드로도, 모션 감소 설정을 켜고도, 자바스크립트를 꺼도 시도해보라는 게 제작자의 권유다. JS가 없으면 쟁반이 물러나고 일곱 개 요리 카드가 전부 펼쳐진다.
미디어 쿼리 없이 굴러가는 원형 배치 #
일곱 개 요리를 원 위에 배치하는 계산이 이 프로젝트에서 가장 재밌는 부분이었다고 한다. 각 요리는 자기 각도 --a를 들고 있고, 적용 순서가 보장되는 개별 transform 속성(translate → rotate → transform)을 쓴다.
.dish {
translate: -50% -50%; /* centre it */
rotate: var(--a); /* aim down its ray */
transform: translateY(-189.5%); /* walk out along that ray */
}궤도 반경이 요리 크기의 백분율이고 요리는 다시 쟁반의 백분율이라, 위젯 전체가 320px에서 560px까지 미디어 쿼리 없이 스케일된다. JS가 건드리는 커스텀 프로퍼티는 --ring-rot 딱 하나뿐이고 나머지는 CSS가 처리한다. 2단계 역회전이 모든 그릇을 똑바로 세워둔다.
그림자에서도 같은 요령이 한 번 더 통했다. 각 요리는 이미 --a만큼 회전한 프레임 안에 있어서, 밋밋한 0 -2px 오프셋이 모든 요리에 대해 이미 중심 반대 방향을 가리킨다. 선언 하나로 일곱 개의 방사형 그림자가 생기고, 식탁 한가운데 등불이 하나 걸린 것처럼 보인다. 요리별 계산은 없다.
axe가 볼 수 없는 명암비 버그 #
가장 흥미로운 대목은 접근성 검증이다. 첫 시안은 사진 위에 헤드라인을 얹고 뒤에 그라디언트 스크림을 깔았다. 자동 검사기는 전부 통과했는데, axe-core는 이미지 위에 놓인 텍스트를 평가하지 못하기 때문이다. 글자 뒤에 어떤 픽셀이 있는지 알 방법이 없다.
그래서 Mike는 Playwright 스크립트를 직접 짰다. 텍스트를 숨기고 합성된 배경만 스크린샷으로 찍은 뒤, 각 텍스트 영역의 바운딩 박스 안 모든 픽셀을 샘플링해 실제 글자 색과의 최악 WCAG 명암비를 계산하는 방식이다.
kicker over photo worst 1.77:1 (needs 4.5) FAIL
headline over photo worst 2.49:1 (needs 3.0) FAIL쌀색 배경 위 금색 글자가 1.77:1. 도구에는 보이지 않지만 측정하는 순간 명백해진다. 스크림을 더 어둡게 하자 오히려 악화됐다. 텍스트 블록 높이가 589px라 어떤 그라디언트로도 덮을 수 없을 만큼 사진 위로 올라갔던 탓이다.
정직한 해법은 미봉책이 아니라 구조 변경이었다. 사진 위에 글을 올리는 걸 그만두는 것. 이미지가 프레임 전체를 갖고 글은 그 아래 단색 띠에 놓이도록 바꿨다. 명암비가 보장되고 음식도 더 크게 보인다. 같은 네 항목의 측정값은 9.95:1과 14.89:1로 바뀌었다.
반대 방향의 사례도 있다. 나중에 axe가 명판 버튼을 3.24:1로 경고했는데, 픽셀 샘플링은 6.63:1이라고 답했고 샘플링이 맞았다. 금색 배경을 라벨의 절대 위치 형제 요소에 넣어둔 탓에 axe가 DOM을 거슬러 올라가 엉뚱한 청동색 선을 찾아 존재하지 않는 실패를 보고한 것이다. 그래도 배경을 텍스트가 실제로 들어 있는 요소로 옮겨 구조를 바꿨다. 심사위원이 axe를 돌려 빨간 줄을 봤다면 그게 오탐인지 알 방법이 없었을 테니까.
멀쩡해 보였지만 측정값이 틀렸던 네 가지 #
- 정사각형을 회전시키면 바운딩 박스가 최대 √2배로 커진다. 모바일에서 49px의 가로 스크롤이 조용히 생겼다. 눈에 띄게 넘친 요소는 없었고, 페이지만 화면보다 넓었다.
- 히어로에 건
min-block-size: 92svh는 sticky 헤더를 계산에 넣지 않는다. 히어로가 항상 헤더 높이만큼 정확히 화면 아래로 튀어나왔다.calc(100svh - 4.5rem)으로 해결. aspect-ratio와min-block-size를 같이 쓰면 박스가 높이가 아니라 너비 쪽으로 풀려 374px이 터져 나왔다.offsetHeight는 정수 픽셀로 반올림한다. 804.42px 패널을 804로 보고해 0.5px의 점프가 남았다.getBoundingClientRect().height에Math.ceil을 씌워 0.00px까지 잡았다.
UI가 깨졌는데 통과한 테스트도 있었다. 모바일 내비게이션이 마지막 라벨을 "Nếp Nh"로 잘라먹고 있었지만, 검증 조건이 document.scrollWidth만 보고 있었다. 내비게이션 자체의 overflow-x: auto가 문제를 가려버린 것이다. 어떤 스크롤 컨테이너든 문서 레벨 검사로부터 잘린 레이아웃을 숨길 수 있다. 지금은 nav.scrollWidth <= nav.clientWidth도 함께 검증한다.
테스트 스위트를 제대로 작성하면서 320px에서의 실제 리플로우 실패도 발견했다. 예전에 "오래된 폰이니까"라며 넘겼던 문제였다. 요리 스트립이 두 줄로 감기면서 화면 아래로 떨어졌는데, 320px은 WCAG 리플로우 기준 폭이라 결국 고쳤다.
절대 튀지 않는 레이아웃 #
요리마다 설명 분량이 달라서 요리를 바꿀 때마다 패널 크기가 변했고, align-items: center 때문에 북까지 위아래로 끌려다녔다.
1440px: panel 713→738px (25px spread) → drum moves 13px
1100px: panel 723→768px (45px spread) → drum moves 23px편차가 뷰포트 폭에 따라 달라지니 하드코딩한 min-height는 딱 한 크기에서만 맞고 나머지 전부에서 틀린다. 해법은 런타임에 가장 높은 패널을 측정해 전부에 고정하고, 리사이즈와 document.fonts.ready 시점에 다시 재는 것이었다. 웹폰트는 첫 페인트 이후에 교체되면서 모든 텍스트 메트릭을 바꿔놓기 때문이다.
쟁반을 물건처럼 만들기 #
드래그해서 돌리는 기능은 40줄 정도였는데, 예상 못 한 두 가지가 깨졌다.
pointerdown에서 setPointerCapture를 호출하면 클릭이 죽는다. 캡처가 이후의 click을 캡처한 요소로 리타깃하기 때문에 요리를 탭해도 그 요리의 버튼에 도달하지 못한다. 포인터가 데드존을 실제로 벗어난 뒤에 캡처를 걸어야 한다.
브라우저가 자기 제스처를 먼저 실행한다. 요리 사진을 누르면 네이티브 이미지 드래그가 시작되고, 쟁반을 쓸면 텍스트 선택이 시작된다. 둘 중 하나라도 포인터 스트림을 가로챈다. 첫 회전은 멀쩡한데 두 번째부터 뻑뻑하게 느껴진 이유가 이것이었다. draggable="false"와 user-select: none이 해결책인데, 합성 테스트 입력에는 잡히지 않아서 동작이 아니라 상태를 들여다보며 찾아내야 했다.
만들었지만 버린 것 #
CSS 3D로 쟁반을 40° 기울여 식탁 위 물건처럼 보이게 하는 프로토타입도 만들었다. 계산은 잘 맞았다. 북은 1.78 비율로 단축돼 보이고 요리들은 1.0을 유지하며 올바르게 빌보딩됐다.
그런데 버렸다. 3D 변환 아래에서는 각 요리의 원형 overflow: hidden 크롭이 잘라내기를 멈췄고, 금색 선택 링이 사라졌고, 모든 사진이 평면으로 래스터화됐다가 재투영되면서 흐려졌다. 셋 다 공을 들이면 고칠 수 있는 문제지만, 멈껌은 원래 위에서 보는 것이고 북면은 원래 납작한 원반이다. 기울이는 순간 "3000년 간격의 같은 원"이 평범한 저녁 식탁으로 바뀌었다. 더 잘 렌더링된 더 나쁜 아이디어였다.
딥링크가 안 되던 이유 #
/#manners로 진입하는 게 동작하지 않았고, 원인이 두 겹으로 쌓여 있었다. scroll-behavior: smooth는 프래그먼트 점프를 9500px짜리 페이지를 가로지르는 긴 애니메이션으로 바꿔놓는데, 독자가 휠을 건드리는 순간 취소된다. 게다가 지연 로딩 이미지와 측정된 패널 높이가 브라우저 스크롤이 끝난 뒤에도 목표 지점 위쪽에 픽셀을 계속 더한다.
진범은 따로 있었다. F5를 누르면 history.scrollRestoration이 프래그먼트 점프 이후에 이전 위치를 복원하며 이겨버린다. 아이러니하게도 "독자 발밑에서 페이지를 잡아채지 말 것"이라는 자체 가드가 그 위치 변화를 감지하고 얌전히 물러났다. URL에 해시가 있다면 그 해시가 곧 의도이므로, 복원을 꺼버리는 것으로 정리했다.
접근성은 마지막 점검이 아니라 1급 제약 #
- 전체 키보드 탭리스트, 44×44 회전 컨트롤, 그리고 입력 필드 안이나 다른 컨트롤 위, 쟁반이 화면 밖일 때는 물러나는 페이지 레벨 방향키
- 사각 히트 영역이 아니라 원형을 따라가는
:focus-visible링 —clip-path가 outline을 잘라내기 때문에 명판 버튼에 요소가 두 개 필요한 이유이기도 하다 prefers-reduced-motion은 모든 회전·전환·관성 활강을 끄고,prefers-contrast: more는 장식용 블렌드 레이어를 제거한다- 베트남어 구절에
lang="vi"를 표시해 스크린 리더가 영어로 뭉개지 않고 제대로 발음하게 했다 <noscript>스타일시트를 통한 점진적 향상- 1440px과 390px에서 axe-core 위반 0건 (WCAG 2.1 A/AA + best-practice)
이 검증은 전부 저장소의 tools/verify.py에서 돌아간다. axe, 픽셀 샘플링 명암비 검사, 레이아웃 안정성 검증, 모든 회전 조작 수단, 이미지 스로틀링 상태의 딥링크, 14개 폭에 걸친 반응형 무결성까지 포함한다.
스택 #
손으로 쓴 HTML과 CSS, 그리고 260줄 남짓의 바닐라 자바스크립트. 프레임워크도 빌드 스텝도 없고 파일 하나에 이미지 몇 장이 전부다. 외부 요청은 Google Fonts 하나뿐이다. 폴드 아래 이미지는 전부 지연 로딩하고 금색 장식은 12색 PNG다. 전체 용량 4.2MB 중 거의 대부분이 사진이다.
한편 제작자는 이 글의 초안 작성에 AI를 사용했고, 발행 전 직접 검토하고 수정했다고 밝혔다.
이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.
