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)
The Constraint
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.
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:
| Property | Where Used | What It Does |
|---|---|---|
| display: flex | Header, nav, cards, item-header, footer | Creates a flex container |
| flex-direction: column | Header, card, footer (mobile) | Stacks children vertically |
| flex-direction: row | Nav, item-header, footer (tablet+) | Lays children horizontally |
| flex-wrap: wrap | Card grid, footer, item-header | Items flow to next line |
| justify-content: center | Header, nav | Centers on main axis |
| justify-content: space-between | Item-header, footer | Pushes items to edges |
| align-items: center | Header, nav, footer | Centers on cross axis |
| align-items: baseline | Item-header | Aligns text baselines |
| align-content: flex-start | Card grid | Packs wrapped rows at top |
| flex-basis + calc() | Cards | Sets column widths (100%/50%/33%) |
| flex-grow: 1 | Description | Fills remaining vertical space |
| flex-shrink: 0 | Price | Never 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.
<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.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.
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.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.
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.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.
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:
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).
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.
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.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% forces itself onto its own line. This puts the copyright centered below the three main footer columns — no extra wrapper needed.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:
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.
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.
: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.
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
| Property | Default | What It Does |
|---|---|---|
| flex-basis | auto | Starting size before growing/shrinking (like "preferred width") |
| flex-grow | 0 | How leftover space is distributed (0 = don't grow, 1 = take your share) |
| flex-shrink | 1 | How 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
| Columns | Formula | Result (with 1.5rem gap) |
|---|---|---|
| 1 | flex-basis: 100% | Full width |
| 2 | calc(50% - gap / 2) | 50% - 0.75rem |
| 3 | calc(33.333% - gap * 2 / 3) | 33.333% - 1rem |
| 4 | calc(25% - gap * 3 / 4) | 25% - 1.125rem |