Design Lounge
Install

Nº 386 of 520

Restaurant menu page

An oxblood-and-mustard bistro menu: riso halftone hero, sticky category bar with scrollspy, dotted price leaders, a Chef's pick stamp, and a booking popover.

Preview

Open full size
1280 × 800 · 20.3 KB · 252 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Restaurant menu page" (piece restaurant-menu-page) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

260 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The menu page of a fictional Norwich bistro, Lanterne. It looks like a riso-printed menu card: cream paper with a fine dot grain, an oxblood band at the top, mustard used as a second ink, and a 136px condensed serif name printed slightly off-register. Type for everything else is a typewriter mono. Under the hero, a category bar sticks to the top and follows your scroll. Dishes sit in two columns with dotted leaders running to the price, the way a printed menu does. The detail worth copying is the leader: a flex spacer with a dotted bottom border, so prices line up on the right at every width without a table.

Reference behaviour

  1. First frame at 1280×800: 56px oxblood top bar, the hero band (name, today’s hours, address, Reserve a table), the category bar, and the first three rows of Small plates. Small plates is the current category.
  2. The hero shows Open until 23:00 with a 10px mustard dot, then Saturday · kitchen 12:00–22:30, then 112 Carrow Street, Norwich NR1 2HQ.
  3. Scroll down. When the category bar reaches the top it sticks there (top: 0). The current category follows the section under the bar: a section becomes current when its top passes 24px below the bar’s bottom edge. At the very bottom of the page, the last category (Drinks) is current.
  4. Click a category. The page scrolls to that section (smooth unless reduced motion). The section heading lands 60px from the top, just under the bar. The category becomes current.
  5. On narrow screens the bar scrolls sideways. When the current category changes, the bar scrolls so the current link sits in the middle.
  6. Every dish row is: name (24px serif), a dotted leader, price (16px mono bold). Under it, one line of description in 13px mono and any dietary tags.
  7. Dietary tags are small bordered text badges: V, VG, GF. Each is an abbr with a full-word title. A legend in the allergy note repeats what they mean.
  8. The Grill section opens with one Chef’s pick: Whole grilled plaice, £26.00. It has a mustard tint, a 2px ink border, a 4px oxblood offset shadow, and an oxblood Chef's pick stamp tilted -2° over its top edge.
  9. Click Reserve a table. A popover opens under the button (right-aligned on desktop, left-aligned under 820px) with a 220ms fade and 6px drop. Focus moves to the minus button.
  10. In the popover: party size stepper (1–8, starts at 2; minus disables at 1, plus at 8), Date select (Sat 3 Oct first), Time select (19:30 selected), and Find a table.
  11. Submit. No reload. A polite live line reads Held: 3 at 19:30, Sat 3 Oct. We will text to confirm. Any change clears it.
  12. Esc closes the popover and returns focus to the button. A click outside closes it without moving focus. Clicking the button again closes it.
  13. Reduced motion: no smooth scroll, no popover animation, transitions 1ms.

Structure

1280 × 800, content max 1280, side padding 56px
┌──────────────────────────────────────────────────────────────────────┐
│ Lanterne(italic 26)  MENU  WINE LIST  PRIVATE DINING  FIND US        │ 56 oxblood
├──────────────────────────────────────────────────────────────────────┤
│ BISTRO & WINE BAR · SINCE 2014                ─────────────────────── │
│                                               ● Open until 23:00      │
│ Lanterne.   (136px serif, mustard offset)     Saturday · kitchen …    │ hero ≈ 170
│                                               112 Carrow Street …     │ oxblood +
│                                               [ RESERVE A TABLE ]     │ halftone right
├──────────────────────────────────────────────────────────────────────┤
│ 01 SMALL PLATES | 02 GRILL | 03 SIDES | 04 DESSERTS | 05 DRINKS       │ 52 sticky, 2px ink rule
├──────────────────────────────────────────────────────────────────────┤
│ 01 Small plates (52px)                      Two or three per person   │
│ ──────────────────────────────────────────────────────────────────── │
│ Gildas ··························· £4.50  │ Burrata ··············· £9.00 │
│ Anchovy, guindilla pepper… [GF]           │ Blood orange… [V][GF]         │
│ … two columns, 56px gap, dashed row rules                             │
│ 02 Grill                                                              │
│ ┌ CHEF'S PICK ───────────────────────┐   Bavette, 250g ········ £22.00│
│ │ Whole grilled plaice ····· £26.00  │                                │
│ └────────────────────────────────────┘                                │
│ … Sides, Desserts, Drinks                                             │
│ [!] Allergies and intolerances … legend V / VG / GF                   │
└──────────────────────────────────────────────────────────────────────┘
  • header.top holds the wordmark link and nav aria-label="Primary". Menu is aria-current="page".
  • section.hero is labelled by the h1. Its inner grid is minmax(0,1fr) 340px, gap 40px, align-items: end, padding 28px top and 34px bottom.
  • The booking block is a div.bk (position relative) with the trigger button and the popover div role="dialog".
  • nav.cats aria-label="Menu sections" is a sibling of the hero, so it sticks across the whole page. It holds a ul of five anchor links.
  • main holds five section.menu elements, each with id, scroll-margin-top: 60px, and an h2. Dishes are li.it in a ul.items grid with an h3 name.
  • The allergy note is an aside at the end of main.

Tokens

:root {
  --cream: #f2e6cf;      /* page paper */
  --paper: #f8efdc;      /* category bar, popover, note */
  --ox: #6b1e23;         /* oxblood: bands, headings, tags, focus on cream */
  --ox-2: #86292f;       /* oxblood hover */
  --ink: #2a1a17;        /* text, rules, offset shadows */
  --ink-2: #5b4440;      /* descriptions, labels */
  --mustard: #d79b22;    /* second ink: misregister, CTA, dot, halftone, focus on oxblood */
  --mustard-t: #f0d79a;  /* Chef's pick fill, eyebrow on oxblood */
  --rule: #d8c6a6;       /* section and row rules */

  --display: "Instrument Serif", Georgia, serif;
  --mono: "Courier Prime", "Courier New", monospace;

  --fs-10: 10px; --fs-11: 11px; --fs-12: 12px; --fs-13: 13px; --fs-15: 15px; --fs-16: 16px;
  --fs-22: 22px; --fs-24: 24px; --fs-30: 30px; --fs-52: 52px; --fs-136: 136px;

  --s-4: 4px; --s-8: 8px; --s-16: 16px; --s-24: 24px; --s-40: 40px; --s-56: 56px;

  --r: 3px;
  --offset-sm: 3px 3px 0 var(--ink);   /* CTA */
  --offset-md: 4px 4px 0 var(--ox);    /* Chef's pick */
  --offset-lg: 6px 6px 0 var(--ox);    /* popover */
  --t-micro: 150ms; --t-pop: 220ms;
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
}

Grain: background: var(--cream) radial-gradient(rgba(107,30,35,.07) .7px, transparent .8px) 0 0 / 4px 4px on body.

Halftone: a ::before on the hero, right-aligned, 520px wide, full height, with radial-gradient(var(--mustard) 1.6px, transparent 1.9px) 0 0 / 9px 9px, masked by a radial fade, opacity 0.55.

Typography

RoleFamilySizeWeightLine-heightTrackingCase
Name h1Instrument Serif136px4000.86-0.02emTitle, mustard shadow 4px 3px
Open lineInstrument Serif30px40010Sentence
Section h2Instrument Serif52px40010Sentence, --ox
Dish h3Instrument Serif24px4001.150Sentence
Popover titleInstrument Serif30px40010Sentence
BodyCourier Prime15px4001.550Sentence
DescriptionCourier Prime13px4001.550Sentence, max 44ch
PriceCourier Prime16px70010£0.00
Category linkCourier Prime13px400, current 70010.1emUPPER
Eyebrow, labelsCourier Prime11–12px4001.40.14–0.18emUPPER
Tag badgeCourier Prime10px70016px0.06emUPPER

Always print prices with two decimals so the leaders end at the same visual width. The serif is display only; never set a description in it.

Motion

ThingTriggerPropertyFrom → toDurationEasingReduced motion
Popoveropenopacity, transform0, translateY(-6px) scale(.98) → 1, none220ms--easenone
Category jumplink clickscroll positioncurrent → sectionbrowser smoothnativeinstant
Current categoryscrollcolor, border-bottom-colorink-2, transparent → ink, oxblood150ms--ease1ms
CTA hoverpointertransform, box-shadow0, 3px → -1px, 4px150ms--ease1ms
CTA pressactivetransform, box-shadow→ 2px, 1px150ms--ease1ms

The press looks like a stamp pushed into paper: the button moves toward its shadow. Do not add a loop, a marquee, or a reveal on the dish rows.

States

  • Category link resting: --ink-2, 4px transparent bottom border. Hover: --ink. Current: aria-current="true", --ink, weight 700, 4px oxblood bottom border that overlaps the bar’s 2px rule (margin-bottom: -2px).
  • CTA resting: mustard fill, ink text, 3px ink offset shadow. Hover lifts 1px. Active presses 2px.
  • Popover closed: hidden. Open: visible, trigger aria-expanded="true".
  • Stepper at 1: minus disabled, opacity 0.35. At 8: plus disabled.
  • Find a table hover: --ox-2.
  • Confirmation: 13px ink line under the button. Cleared on any change.
  • Chef’s pick: mustard-tint card, 2px ink border, oxblood offset, stamp. Its leader turns ink, not ink-2.
  • Focus-visible: 2px outline, offset 2px. Oxblood on cream areas, mustard inside the oxblood bands. Set it with a --focus variable per region.

Accessibility

  • One h1 (the name). Each menu section is labelled by its h2. Dish names are h3.
  • The category bar is a nav with a label. The current link has aria-current="true" (a location in the page, not a new page).
  • Dietary badges are abbr elements with title="Vegetarian", "Vegan", "Gluten free". The legend spells them out as plain text, so meaning is not hover-only.
  • The trigger has aria-haspopup="dialog", aria-expanded, and aria-controls pointing to the popover.
  • The popover has role="dialog" and aria-labelledby on its title. It is not modal: the page behind stays usable, so no focus trap. Esc closes it and returns focus to the trigger.
  • The stepper is a role="group" labelled Party size. The buttons are Fewer guests and More guests. The count is an output with aria-live="polite".
  • The booking result line is aria-live="polite".
  • The open dot is decorative and aria-hidden; the words say Open.
  • Contrast: cream on oxblood is about 10:1. Ink on cream about 14:1. #5b4440 on cream passes 7:1. Mustard is never used for small text on cream; on oxblood the eyebrow uses the lighter #f0d79a.
  • Hit targets: category links 52px tall, stepper buttons 44px, selects 44px, CTA 46px.

Responsive rules

  • ≥1280: as drawn. Padding 56px. Hero grid minmax(0,1fr) 340px. Two dish columns, 56px gap.
  • 1024 (max-width 1100px): padding 40px. Name 112px. Hero right column 300px. Dish gap 36px.
  • 768 (max-width 820px): top-bar links hide; the wordmark stays. Hero stacks: name, then the today block. Name 96px. One dish column. Popover aligns to the left edge of the button.
  • <640: padding 18px. Name 72px with a 3px 2px shadow. Section headings 40px; their side note hides. Category bar padding 8px, links 48px tall and 12px side padding, scrolling sideways with no visible scrollbar. Dish names 21px. Popover width min(320px, 100vw - 36px).
  • Never scroll the page sideways at 390px. Only the category bar may scroll on the x axis.

Acceptance checklist

Always

  • The category bar is a sibling of the hero and sticks at top: 0 for the whole page.
  • Exactly one category is current; it changes as sections pass under the bar, and the last one is current at the page end.
  • Section anchors use scroll-margin-top so headings land under the bar, not behind it.
  • Every price is right-aligned by a dotted leader that fills the remaining width.
  • Dietary tags are text badges with full-word titles, plus a legend in text.
  • One highlighted dish per menu, with a stamp label.
  • The booking popover opens from the trigger, moves focus inside, closes on Esc (focus back to trigger) and on outside click.
  • Party size is clamped with disabled ends.
  • body uses min-height: 100%, not a fixed height, or sticky stops after one screen.
  • No horizontal page scroll at 390px.

This demo

  • The name is Lanterne. at 136px with a 4px 3px mustard shadow.
  • Hero reads Open until 23:00, Saturday · kitchen 12:00–22:30, 112 Carrow Street, Norwich NR1 2HQ.
  • Categories are numbered 01–05: Small plates, Grill, Sides, Desserts, Drinks.
  • Chef’s pick is Whole grilled plaice at £26.00.
  • Popover starts at 2 guests, Sat 3 Oct, 19:30.

Implementation notes

1. Dotted leaders. Use flex with a growing spacer. Nudge the spacer up so the dots sit near the baseline of the name, not under the descenders.

.row { display: flex; align-items: baseline; gap: 8px; }
.row h3 { margin: 0; font: 400 24px/1.15 var(--display); }
.lead {
  flex: 1; min-width: 16px;
  border-bottom: 2px dotted var(--ink-2);
  transform: translateY(-5px);
}
.pr { font: 700 16px var(--mono); }

Do not fill the gap with a string of . characters. It breaks at every width and is read aloud.

2. Scrollspy. Measure on scroll inside requestAnimationFrame. Compare each section’s top to the bar’s bottom edge. Only touch the DOM when the current index changes.

function spy() {
  const y = bar.getBoundingClientRect().bottom + 24;
  let cur = 0;
  sections.forEach((s, i) => { if (s.getBoundingClientRect().top <= y) cur = i; });
  if (innerHeight + scrollY >= document.documentElement.scrollHeight - 2) cur = sections.length - 1;
  if (links[cur].getAttribute('aria-current') === 'true') return;
  links.forEach((a, i) => a.setAttribute('aria-current', i === cur ? 'true' : 'false'));
  const ul = bar.querySelector('ul'), a = links[cur];
  ul.scrollLeft = a.offsetLeft - (ul.clientWidth - a.offsetWidth) / 2;
}
let tick = false;
addEventListener('scroll', () => {
  if (!tick) { tick = true; requestAnimationFrame(() => { spy(); tick = false; }); }
}, { passive: true });

The bottom-of-page rule matters: a short last section can never reach the bar, so without it Drinks is never current.

3. Off-register name and halftone. The riso feel is two inks slightly out of line. One text shadow does it. Keep the halftone in a ::before that stays inside the hero box, so the hero does not need overflow: hidden (which would clip the popover).

h1 { font: 400 136px/.86 var(--display); color: var(--cream);
     text-shadow: 4px 3px 0 var(--mustard); }
.hero { position: relative; background: var(--ox); }
.hero::before {
  content: ""; position: absolute; right: 0; top: 0;
  width: min(520px, 100%); height: 100%; pointer-events: none; opacity: .55;
  background: radial-gradient(var(--mustard) 1.6px, transparent 1.9px) 0 0 / 9px 9px;
  mask: radial-gradient(closest-side, #000, transparent);
}

Common mistakes:

  • overflow: hidden on the hero. The popover is clipped at the hero’s bottom edge.
  • html, body { height: 100% } left as is. The sticky bar scrolls away after 800px.
  • Putting the category bar inside the hero or inside main’s first section. Sticky only works inside its parent.
  • Emoji or leaf icons for vegan. Use text badges.
  • A drop shadow with blur. Every shadow here is a hard offset, like a misprinted second ink.
  • Mustard text on cream. It fails contrast. Mustard is a fill and a shadow only.
  • A modal for booking. This is a small non-modal popover.
  • Prices without decimals next to prices with decimals.

Rebuild order:

  1. Set tokens, grain, and the top bar.
  2. Build the hero with the halftone and the off-register name.
  3. Add the sticky category bar after the hero.
  4. Build one section with the leader rows, then copy for all five.
  5. Add the Chef’s pick card and the allergy note.
  6. Wire scrollspy.
  7. Build the popover and its keyboard rules.
  8. Check 1024, 768 and 390.

Details

Palette
Type
Instrument Serif · Courier Prime
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Haunted Boulder City

Pin the screen and let scroll turn the pages. Only the line you are on gets to shout.

All sources