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
Use it
Free to use · credit appreciated1 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.
<!-- Design Lounge Nº 386 · "Restaurant menu page" · www.designlounge.live -->
# Restaurant menu page
> **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
… 220 more lines. Copy or download for the full brief. Loading the source…Brief
260 linesBuild 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
- 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 platesis the current category. - The hero shows
Open until 23:00with a 10px mustard dot, thenSaturday · kitchen 12:00–22:30, then112 Carrow Street, Norwich NR1 2HQ. - 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. - 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.
- On narrow screens the bar scrolls sideways. When the current category changes, the bar scrolls so the current link sits in the middle.
- 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.
- Dietary tags are small bordered text badges:
V,VG,GF. Each is anabbrwith a full-word title. A legend in the allergy note repeats what they mean. - 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 pickstamp tilted -2° over its top edge. - 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. - 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. - 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. - Esc closes the popover and returns focus to the button. A click outside closes it without moving focus. Clicking the button again closes it.
- 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.topholds the wordmark link andnav aria-label="Primary". Menu isaria-current="page".section.herois labelled by theh1. Its inner grid isminmax(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 triggerbuttonand the popoverdiv role="dialog". nav.cats aria-label="Menu sections"is a sibling of the hero, so it sticks across the whole page. It holds aulof five anchor links.mainholds fivesection.menuelements, each withid,scroll-margin-top: 60px, and anh2. Dishes areli.itin aul.itemsgrid with anh3name.- The allergy note is an
asideat the end ofmain.
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
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
Name h1 | Instrument Serif | 136px | 400 | 0.86 | -0.02em | Title, mustard shadow 4px 3px |
| Open line | Instrument Serif | 30px | 400 | 1 | 0 | Sentence |
Section h2 | Instrument Serif | 52px | 400 | 1 | 0 | Sentence, --ox |
Dish h3 | Instrument Serif | 24px | 400 | 1.15 | 0 | Sentence |
| Popover title | Instrument Serif | 30px | 400 | 1 | 0 | Sentence |
| Body | Courier Prime | 15px | 400 | 1.55 | 0 | Sentence |
| Description | Courier Prime | 13px | 400 | 1.55 | 0 | Sentence, max 44ch |
| Price | Courier Prime | 16px | 700 | 1 | 0 | £0.00 |
| Category link | Courier Prime | 13px | 400, current 700 | 1 | 0.1em | UPPER |
| Eyebrow, labels | Courier Prime | 11–12px | 400 | 1.4 | 0.14–0.18em | UPPER |
| Tag badge | Courier Prime | 10px | 700 | 16px | 0.06em | UPPER |
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
| Thing | Trigger | Property | From → to | Duration | Easing | Reduced motion |
|---|---|---|---|---|---|---|
| Popover | open | opacity, transform | 0, translateY(-6px) scale(.98) → 1, none | 220ms | --ease | none |
| Category jump | link click | scroll position | current → section | browser smooth | native | instant |
| Current category | scroll | color, border-bottom-color | ink-2, transparent → ink, oxblood | 150ms | --ease | 1ms |
| CTA hover | pointer | transform, box-shadow | 0, 3px → -1px, 4px | 150ms | --ease | 1ms |
| CTA press | active | transform, box-shadow | → 2px, 1px | 150ms | --ease | 1ms |
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, triggeraria-expanded="true". - Stepper at 1: minus
disabled, opacity 0.35. At 8: plusdisabled. - 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
--focusvariable per region.
Accessibility
- One
h1(the name). Each menu section is labelled by itsh2. Dish names areh3. - The category bar is a
navwith a label. The current link hasaria-current="true"(a location in the page, not a new page). - Dietary badges are
abbrelements withtitle="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, andaria-controlspointing to the popover. - The popover has
role="dialog"andaria-labelledbyon 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"labelledParty size. The buttons areFewer guestsandMore guests. The count is anoutputwitharia-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.
#5b4440on 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: 0for 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-topso 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.
-
bodyusesmin-height: 100%, not a fixedheight, 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: hiddenon 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:
- Set tokens, grain, and the top bar.
- Build the hero with the halftone and the off-register name.
- Add the sticky category bar after the hero.
- Build one section with the leader rows, then copy for all five.
- Add the Chef’s pick card and the allergy note.
- Wire scrollspy.
- Build the popover and its keyboard rules.
- Check 1024, 768 and 390.
Details
- Palette
- Type
- Instrument Serif · Courier Prime
- Motion
- Subtle motion
- Build
- One session
- Tags
Studied
Haunted Boulder CityPin the screen and let scroll turn the pages. Only the line you are on gets to shout.
All sourcesOn the shelves