·20 min read·Tutorial·Beginner

I Built a Restaurant Menu With Flexbox Only — Here's the Challenge

Prerequisites

  • A code editor (VS Code recommended)
  • A modern browser (Chrome, Firefox, or Edge)
  • Basic understanding of HTML and CSS (completed the Profile Card challenge or equivalent)
HTML5CSS3FlexboxCSS Custom PropertiesCSS AnimationsGoogle Fonts

The Constraint

⚠️The Rule
Zero display: grid allowed. Every layout problem — card grid, header centering, footer columns, name-price rows — must be solved with display: flex only.

In the first challenge I used CSS Grid for the skills section. This time I forced myself to do everything with Flexbox — including a responsive 1/2/3-column card grid. It turns out Flexbox can handle multi-column layouts just fine when you understand flex-basis, flex-wrap, and calc(). I also tackled hover-reveal buttons, dietary badges, sticky navigation, :target highlighting, staggered card entrance animations, and a responsive footer — all with Flexbox as the sole layout mechanism.

What I Built

A single-page restaurant menu for La Bella Cucina, an upscale Italian restaurant. 15 menu items across 4 categories (Antipasti, Primi Piatti, Secondi Piatti, Dolci), each with a food photo inside <figure>/<figcaption>, Italian name, English description, and price. Cards lift on hover, images zoom, prices change color, and an "Add to Order" button fades in.

💡Live Demo
See the finished restaurant menu here — sticky nav, dietary badges, hover effects, card animations, and a responsive 1/2/3-column layout. All built with Flexbox only. Try hovering the cards and clicking the nav links.

The page breaks down into these sections: a centered header with the restaurant name, decorative divider, and tagline. A sticky category nav with pill-shaped hover effects. Four menu sections each containing a flex-wrapped card grid. Each menu item card is both a flex item (of the grid) and a flex container (for its own content). And a dark footer that switches from column to row layout at the tablet breakpoint.

Here are the 12 Flexbox properties I used — and nothing else for layout:

PropertyWhere UsedWhat It Does
display: flexHeader, nav, cards, item-header, footerCreates a flex container
flex-direction: columnHeader, card, footer (mobile)Stacks children vertically
flex-direction: rowNav, item-header, footer (tablet+)Lays children horizontally
flex-wrap: wrapCard grid, footer, item-headerItems flow to next line
justify-content: centerHeader, navCenters on main axis
justify-content: space-betweenItem-header, footerPushes items to edges
align-items: centerHeader, nav, footerCenters on cross axis
align-items: baselineItem-headerAligns text baselines
align-content: flex-startCard gridPacks wrapped rows at top
flex-basis + calc()CardsSets column widths (100%/50%/33%)
flex-grow: 1DescriptionFills remaining vertical space
flex-shrink: 0PriceNever compress the price

Step 1 — Project Setup + Design Tokens

Two files: index.html and style.css. I loaded Google Fonts (Playfair Display for headings, Lato for body text) and defined every design token upfront. The reset includes scroll-behavior: smooth on html for the nav anchor links.

💡Why This Matters
preconnect hints<link rel="preconnect"> tells the browser to start the DNS + TLS handshake with Google Fonts before it even sees the font request. Saves 100-200ms. img { display: block } — Images are inline by default, which adds a mysterious 3-4px gap below them. Since every card has an image, this prevents gaps inside the cards. scroll-behavior: smooth — When clicking nav anchor links, the page glides to the target section instead of jumping. One line, zero JavaScript.
Checkpoint
Open index.html — you should see a cream-colored empty page. That's the reset working.

Step 2 — Restaurant Header

Three elements stacked vertically: the restaurant name, a decorative divider, and a tagline. flex-direction: column with align-items: center does the work. I used clamp() for fluid typography — the heading scales from 2rem to 3.5rem based on viewport width.

💡Why flex-direction: column here?
The header has three elements that stack vertically: h1, divider, tagline. column makes the main axis vertical, so align-items: center now centers them horizontally (the cross axis). This is a common source of confusion — justify-content and align-items swap their visual effect when you change direction.
Checkpoint
"La Bella Cucina" centered with a gold divider and a light uppercase tagline below.

Step 3 — Sticky Category Navigation

Four anchor links in a horizontal flex row. position: sticky; top: 0 makes the nav stick when you scroll past it. On small screens, overflow-x: auto lets it scroll horizontally instead of wrapping.

💡sticky vs fixed
position: sticky only sticks when you scroll to its position — it doesn't cover the header on page load. position: fixed is always anchored to the viewport and removes the element from flow. Sticky is almost always what you want for navigation bars. border-radius: 24px on the links — The pill-shaped hover effect is on the <a>, not the <li>. The link has the padding that creates the clickable area. The li is just a structural container.
Checkpoint
Four nav links on a gold bar. Hover — they turn deep red with white text. Scroll down and the nav sticks to the top.

Step 4 — Main Container + Section Headings

Four menu sections, each with an id matching the nav anchors. The section headings use a ::after pseudo-element for the gold underline — purely decorative, so it belongs in CSS, not HTML.

💡Why This Matters
scroll-margin-top: 4rem — When the nav links scroll to a section, the sticky nav covers the top ~4rem of the viewport. Without scroll-margin-top, the section heading hides behind the nav. This property adds invisible spacing above the scroll target. max-width + margin: 0 auto — Content shouldn't stretch across a 2560px ultrawide monitor. max-width caps it. margin: 0 auto centers the block by splitting the remaining space equally between left and right margins.
Checkpoint
Four section headings in deep red, each with a small gold underline, centered within a 1200px container.

Step 5 — Menu Item Cards (The Core)

This is the heart of the project. Each card is both a flex item (of the grid) and a flex container (for its own content). The grid container uses flex-wrap: wrap so cards flow to the next row. Each card starts at flex-basis: 100% (mobile-first, 1 column).

Here's the HTML pattern for one card — repeat it for each item using the content tables below:

Fill all 4 sections: Antipasti (4 items), Primi Piatti (4), Secondi Piatti (4), Dolci (3). Here's the full menu data:

All image URLs follow the pattern: https://picsum.photos/seed/{seed}/400/300

Now the CSS — this is where Flexbox does its heaviest lifting:

💡Key Flexbox Concepts
flex-grow: 0 on cards — If a row has only 1 card (like the 4th card in a 3-column section), flex-grow: 0 prevents it from stretching to fill the whole row. flex-shrink: 0 on price — A long dish name like "Panna Cotta ai Frutti di Bosco" could push the price and cause it to shrink. flex-shrink: 0 says "never shrink the price, let the name wrap instead." flex-grow: 1 on description — This is the key trick for equal-height cards. Cards in a row all stretch to the tallest card's height (because the parent defaults to align-items: stretch). flex-grow: 1 on the description makes it absorb the extra height, so all cards look uniform. align-items: baseline on item-header — The h3 and price have different font sizes. baseline aligns their text baselines so they read as one line. center would misalign them vertically. object-fit: cover on images — The source images are 400×300 but displayed at full-width × 220px. cover fills the space and crops the overflow — no stretching or distortion.
Checkpoint
15 cards stacked in a single column. Each has an image with a green badge overlay, an Italian name with price on the right, and an English description below.

Step 6 — Card Hover Effects

Three things happen on hover: the card lifts up with a deeper shadow, the image zooms 8%, and the price changes from red to gold. All using transform (GPU-accelerated) instead of changing width/height (which triggers layout reflow).

💡Why overflow: hidden matters
The image zooms to 108% on hover. Without overflow: hidden on the card and figure (set in Step 5), the zoomed image would overflow the card's rounded corners. The card clips it.

Step 7 — Responsive Breakpoints (2-column, 3-column)

The base CSS is mobile-first (1 column). Media queries add complexity at larger sizes. The math for flex-basis accounts for the gap between cards.

💡The calc() Math
2 columns: Two cards per row with one gap between them. Each card = 50% minus half the gap. If gap = 1.5rem, each card is 50% - 0.75rem. 3 columns: Three cards per row with two gaps. Total gap space = 1.5rem × 2 = 3rem, divided by 3 cards = 1rem per card. Each is 33.333% - 1rem. Why mobile-first? The base CSS is for the smallest screen. You only add complexity at larger sizes. If media queries fail to load, mobile users still get a working layout.
Checkpoint
Resize your browser: below 640px = 1 column, 640-1023px = 2 columns, 1024px+ = 3 columns.

Step 8 — Footer

The footer is another flex container that switches direction at the tablet breakpoint — column on mobile, row on tablet+. The copyright uses flex-basis: 100% to force itself onto its own line in the wrapping row.

💡flex-basis: 100% trick
In a wrapping flex row, an item with flex-basis: 100% forces itself onto its own line. This puts the copyright centered below the three main footer columns — no extra wrapper needed.
Checkpoint
Dark footer with address, hours, and a red reserve button. Stacks on mobile, goes horizontal with vertical dividers on tablet+.

Step 9 — Dietary Badges (V, GF)

Some dishes need dietary badges — V for Vegetarian, GF for Gluten Free. These go in the item-header between the name and price. The key is adding flex-wrap: wrap and adjusting the gap on item-header so badges can sit next to the dish name and the price pushes to the end.

Update the item-header HTML for dishes that have badges:

💡Which items get badges?
V (Vegetarian): Bruschetta, Burrata, Risotto, Gnocchi, Tiramisu, Panna Cotta, Cannoli. GF (Gluten Free): Carpaccio, Burrata, Risotto, Osso Buco, Branzino, Saltimbocca, Panna Cotta.

Step 10 — Hover-Reveal Order Button

An "Add to Order" button at the bottom of each card that's invisible by default and fades in + slides up on hover. This uses opacity: 0 plus transform: translateY(4px) on the button, then the card's :hover state reveals it.

💡Why opacity + transform?
The button is always in the DOM — it takes up its space even when invisible. Using display: none would cause a layout shift on hover as the button appears and pushes content around. opacity: 0 keeps the button's space reserved. The slight translateY(4px) adds a subtle slide-up motion when it fades in.

Step 11 — :target Highlighting + Smooth Scroll

When you click a nav link like "Antipasti," the URL becomes #antipasti and the :target pseudo-class matches that section. I used this to change the heading color to gold and expand the underline — a visual confirmation that navigation happened.

💡How :target works
The :target pseudo-class matches the element whose id equals the current URL fragment (the part after #). When you click <a href="#antipasti">, the URL becomes page.html#antipasti and #antipasti:target matches. Combined with scroll-behavior: smooth from Step 1, clicking a nav link smoothly scrolls to the section and highlights the heading.

Step 12 — Card Entrance Animations

Cards fade in and slide up on page load with staggered delays. The first card appears immediately, the second after 0.1s, the third after 0.2s, and so on. This creates a cascading entrance effect.

💡animation-fill-mode: both
The keyword both in the animation shorthand sets animation-fill-mode: both. This means the element stays at the 0% state before the animation starts (invisible + shifted down) and stays at the 100% state after it ends (visible + normal position). Without it, cards would flash visible before the delayed animation starts.
* * *

Acceptance Criteria

Here's the checklist I used to verify the build:

  • Zero display: grid in the stylesheet
  • Uses display: flex as the sole layout mechanism
  • Uses flex-direction (both row and column)
  • Uses justify-content with at least 2 values (center, space-between)
  • Uses align-items with at least 2 values (center, baseline)
  • Uses align-content: flex-start on the card grid
  • Uses flex-wrap: wrap for responsive card layout
  • Uses flex-grow: 1 on the description for equal-height cards
  • Uses flex-shrink: 0 on the price
  • Uses flex-basis with calc() for responsive columns
  • Uses gap for spacing between cards
  • Responsive: 1 col (< 640px), 2 col (640–1023px), 3 col (>= 1024px)
  • All images use <figure> and <figcaption>
  • 4 menu sections with 15 total items
  • Hover effects: shadow lift, image zoom, price color change
  • Sticky category nav with pill-shaped hover
  • Dietary badges (V and GF) with flex-wrap on item-header
  • Hover-reveal "Add to Order" button with opacity + transform transition
  • :target highlighting on section headings
  • Card entrance animations with staggered delays
  • Footer switches from column to row at 640px
  • All CSS values reference design token variables from :root
  • Google Fonts loaded: Playfair Display + Lato

Flexbox Properties Used

Quick Reference: flex-grow, flex-shrink, flex-basis

PropertyDefaultWhat It Does
flex-basisautoStarting size before growing/shrinking (like "preferred width")
flex-grow0How leftover space is distributed (0 = don't grow, 1 = take your share)
flex-shrink1How items give up space when too tight (0 = refuse to shrink)

justify-content vs align-items

justify-content controls spacing along the main axis (the direction items flow). align-items controls positioning along the cross axis (perpendicular). When flex-direction: row, main = horizontal and cross = vertical. When flex-direction: column, they swap. That's why align-items: center centers horizontally in the header (column direction) but vertically in the footer (row direction).

The flex-basis + calc() Pattern for Columns

ColumnsFormulaResult (with 1.5rem gap)
1flex-basis: 100%Full width
2calc(50% - gap / 2)50% - 0.75rem
3calc(33.333% - gap * 2 / 3)33.333% - 1rem
4calc(25% - gap * 3 / 4)25% - 1.125rem