How to Build a Restaurant Website with HTML, CSS and JavaScript

How to Build a Restaurant Website with HTML, CSS and JavaScript

In this tutorial you'll build a complete website for a made-up restaurant, Ember & Thyme. It looks and works like a real one: the hero tells visitors whether the restaurant is open right now, the menu filters as you type, the booking form only offers times the kitchen is open, and the reviews swipe sideways on a phone.

Open the live demo Try searching the menu for lamb, or picking a Monday in the booking form.

Ember and Thyme restaurant website hero: a dark header, the headline Cooked over fire, served with care, a green Open now status and an illustrated plate with a steak and rising steam
The finished hero: a live status, the headline and a plate drawn entirely in SVG.

Everything is plain HTML, CSS and JavaScript, with no frameworks, no libraries and no image files. The tutorial has three parts, one per language, so you can stop after any part and still have something that works:

  1. Part 1, HTML: the structure and content of every section.
  2. Part 2, CSS: the warm dark theme, the menu layout and the responsive design.
  3. Part 3, JavaScript: the open-now status, menu filter, booking form and carousel.

What you'll learn

Project setup

Create a folder with three empty files:

restaurant-landing/
├── index.html   ← all the sections and their content
├── style.css    ← layout, colours, animation, mobile styles
└── script.js    ← open-now status, menu, booking, carousel

Each file is split into sections marked with comments like /* ---------- HERO ---------- */. The code blocks below follow those markers from top to bottom, so if you copy every block in order you'll rebuild each file exactly.

Plan the page first

People visit a restaurant website with very few questions: are you open, what's on the menu, can I get a table and where are you. A good page answers those fast and skips the rest. That gives us this order:

  1. Header with navigation and a booking button
  2. Hero with the live status
  3. A short story
  4. The menu, with filters
  5. Table booking
  6. Reviews
  7. Address and opening hours

Part 1: The HTML

In this part we write the content and structure only. Without CSS the page looks plain, but every dish, form field and opening time is already there and readable.

1.1 The head and the header

index.html: head
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Ember &amp; Thyme | Wood-Fired Kitchen</title>
  <meta name="description" content="Ember &amp; Thyme is a demo restaurant website built with plain HTML, CSS and JavaScript.">
  <link rel="stylesheet" href="style.css">
</head>
<body>
index.html: header
<!-- ---------- HEADER ---------- -->
<header class="site-header" id="siteHeader">
  <div class="wrap nav">
    <a class="logo" href="#top"><span class="logo-mark" aria-hidden="true"></span>Ember &amp; Thyme</a>
    <button class="menu-btn" id="menuBtn" type="button" aria-label="Open menu" aria-controls="mainNav" aria-expanded="false"><span></span></button>
    <nav class="main-nav" id="mainNav" aria-label="Main">
      <a href="#story">Our story</a>
      <a href="#menu">Menu</a>
      <a href="#reviews">Reviews</a>
      <a href="#visit">Visit</a>
      <a class="btn btn-accent" href="#reserve">Book a table</a>
    </nav>
  </div>
</header>

<main id="top">

The viewport meta tag makes the page use the phone's real width, which the mobile styles depend on. The menu button is a real <button>: aria-controls says what it opens, aria-expanded says whether it's open, and aria-label gives it a name, because it only shows an icon.

1.2 The hero

index.html: hero
<!-- ---------- HERO ---------- -->
<section class="hero">
  <div class="wrap hero-grid">
    <div class="hero-text">
      <p class="status" id="openStatus">Open Tuesday to Sunday</p>
      <h1>Cooked over fire, served with <em>care</em></h1>
      <p class="lead">A neighbourhood kitchen built around one wood-fired oven, the best local
         produce we can find and a table that is always worth the wait.</p>
      <div class="hero-actions">
        <a class="btn btn-accent" href="#reserve">Book a table</a>
        <a class="btn btn-line" href="#menu">See the menu</a>
      </div>
    </div>

    <div class="hero-art" aria-hidden="true">
      <svg viewBox="0 0 420 420">
        <g class="steam">
          <path d="M170 74c-12-16 12-24 0-42"/>
          <path d="M210 66c-12-16 12-24 0-42"/>
          <path d="M250 74c-12-16 12-24 0-42"/>
        </g>
        <circle class="plate" cx="210" cy="240" r="160"/>
        <circle class="plate-rim" cx="210" cy="240" r="122"/>
        <ellipse class="steak" cx="205" cy="245" rx="76" ry="56" transform="rotate(-14 205 245)"/>
        <g class="grill">
          <path d="M150 262l76-58"/>
          <path d="M168 280l76-58"/>
          <path d="M186 296l76-58"/>
        </g>
        <circle class="tomato" cx="292" cy="196" r="17"/>
        <circle class="tomato" cx="310" cy="228" r="13"/>
        <circle class="tomato" cx="284" cy="164" r="11"/>
        <g class="herb">
          <path d="M112 186q44 10 62 56"/>
          <path d="M128 192l-10 20M144 202l-12 20M156 216l-14 18M130 192l18-14M146 202l18-14M158 216l18-12"/>
        </g>
        <circle class="sauce" cx="232" cy="318" r="9"/>
        <circle class="sauce" cx="208" cy="330" r="6"/>
        <circle class="sauce" cx="186" cy="334" r="4"/>
      </svg>
    </div>
  </div>
</section>

The status line starts with a sensible sentence, Open Tuesday to Sunday, which stays true if JavaScript never runs. The script replaces it with the live status in Part 3.

The plate is an inline SVG made of circles, an ellipse for the steak and a few paths. Because it's code rather than an image, it's tiny, sharp at any size and easy to recolour with CSS. transform="rotate(-14 205 245)" tilts the steak around its own centre. The art has aria-hidden="true" because it's decoration.

1.3 The story and the menu

index.html: story
<!-- ---------- STORY ---------- -->
<section class="story" id="story">
  <div class="wrap story-grid">
    <div>
      <p class="eyebrow">Our story</p>
      <h2>One oven, one street, twelve years</h2>
      <p>We opened with four tables and a secondhand oven. The tables have multiplied, but the idea
         has not: buy well, cook simply and let the fire do the work.</p>
      <p>Everything on the menu spends at least a moment over oak and thyme, from the bread we bake
         each morning to the peaches we blacken for dessert.</p>
    </div>
    <ul class="facts">
      <li class="reveal"><strong>12</strong><span>years on Mill Lane</span></li>
      <li class="reveal"><strong>40 km</strong><span>furthest supplier</span></li>
      <li class="reveal"><strong>480&deg;C</strong><span>oven at full heat</span></li>
    </ul>
  </div>
</section>
index.html: menu
<!-- ---------- MENU ---------- -->
<section class="menu-section" id="menu">
  <div class="wrap">
    <p class="eyebrow">The menu</p>
    <h2>What's on the fire</h2>

    <div class="menu-tools">
      <div class="tabs" id="menuTabs" role="group" aria-label="Filter by course">
        <button type="button" class="tab" data-filter="all" aria-pressed="true">All</button>
        <button type="button" class="tab" data-filter="starter" aria-pressed="false">Starters</button>
        <button type="button" class="tab" data-filter="main" aria-pressed="false">Mains</button>
        <button type="button" class="tab" data-filter="dessert" aria-pressed="false">Desserts</button>
      </div>
      <div class="menu-search">
        <label class="sr-only" for="menuSearch">Search the menu</label>
        <input id="menuSearch" type="search" placeholder="Search, e.g. lamb">
        <label class="check"><input id="vegOnly" type="checkbox"> Vegetarian only</label>
      </div>
    </div>

    <p class="menu-count" id="menuCount" aria-live="polite">12 dishes</p>

    <ul class="dishes" id="dishes">
      <li class="dish" data-course="starter" data-veg="true">
        <h3>Hearth bread <span class="tag">V</span></h3><span class="price">$7</span>
        <p>Sourdough from the oven, smoked butter, sea salt.</p>
      </li>
      <li class="dish" data-course="starter" data-veg="true">
        <h3>Charred leeks <span class="tag">V</span></h3><span class="price">$12</span>
        <p>Whipped feta, hazelnut crumb, lemon oil.</p>
      </li>
      <li class="dish" data-course="starter" data-veg="false">
        <h3>Oak-smoked trout</h3><span class="price">$15</span>
        <p>Pickled fennel, dill cream, rye crisp.</p>
      </li>
      <li class="dish" data-course="starter" data-veg="false">
        <h3>Grilled octopus</h3><span class="price">$18</span>
        <p>Paprika potatoes, salsa verde, burnt lemon.</p>
      </li>
      <li class="dish" data-course="main" data-veg="false">
        <h3>Ember ribeye</h3><span class="price">$38</span>
        <p>Dry-aged beef, thyme butter, roast tomatoes.</p>
      </li>
      <li class="dish" data-course="main" data-veg="false">
        <h3>Slow lamb shoulder</h3><span class="price">$32</span>
        <p>Twelve hours in the coals, mint yoghurt, flatbread.</p>
      </li>
      <li class="dish" data-course="main" data-veg="false">
        <h3>Whole sea bream</h3><span class="price">$29</span>
        <p>Fennel, capers, brown butter.</p>
      </li>
      <li class="dish" data-course="main" data-veg="true">
        <h3>Wood-roast cauliflower <span class="tag">V</span></h3><span class="price">$24</span>
        <p>Tahini, pomegranate, toasted almonds.</p>
      </li>
      <li class="dish" data-course="main" data-veg="true">
        <h3>Wild mushroom orzo <span class="tag">V</span></h3><span class="price">$23</span>
        <p>Thyme, aged parmesan, truffle oil.</p>
      </li>
      <li class="dish" data-course="dessert" data-veg="true">
        <h3>Blackened peaches <span class="tag">V</span></h3><span class="price">$11</span>
        <p>Vanilla mascarpone, honey, oat crumble.</p>
      </li>
      <li class="dish" data-course="dessert" data-veg="true">
        <h3>Dark chocolate tart <span class="tag">V</span></h3><span class="price">$12</span>
        <p>Smoked salt, creme fraiche.</p>
      </li>
      <li class="dish" data-course="dessert" data-veg="true">
        <h3>Burnt basque cheesecake <span class="tag">V</span></h3><span class="price">$11</span>
        <p>Baked hard and fast, with sherry cherries.</p>
      </li>
    </ul>

    <p class="menu-empty" id="menuEmpty" hidden>Nothing matches that search.
      <button type="button" class="link-btn" id="menuReset">Show the whole menu</button></p>
  </div>
</section>

This is the most important decision on the page: the dishes are written in the HTML, not built by JavaScript. The menu is the content people and search engines came for, so it should be there even if the script fails. JavaScript will only show and hide what's already on the page.

Each dish carries the facts the filter needs as data- attributes: data-course="main" and data-veg="true". The tabs use aria-pressed, which tells screen readers which one is switched on. The dish count has aria-live="polite", so a screen reader announces the new number after each search.

1.4 The booking form

index.html: reserve
<!-- ---------- RESERVE ---------- -->
<section class="reserve" id="reserve">
  <div class="wrap reserve-grid">
    <div class="reserve-intro">
      <p class="eyebrow">Reservations</p>
      <h2>Book a table</h2>
      <p>Tables are held for 15 minutes. For parties of more than eight, call us and we'll
         work something out.</p>
      <p class="reserve-phone"><a href="tel:+15550142">(555) 0142</a></p>
    </div>

    <form class="reserve-form" id="reserveForm" novalidate>
      <div class="field">
        <label for="guestName">Your name</label>
        <input id="guestName" name="name" type="text" autocomplete="name" required minlength="2" aria-describedby="guestNameError">
        <p class="error" id="guestNameError"></p>
      </div>
      <div class="field">
        <label for="guestEmail">Email</label>
        <input id="guestEmail" name="email" type="email" autocomplete="email" required aria-describedby="guestEmailError">
        <p class="error" id="guestEmailError"></p>
      </div>
      <div class="field">
        <label for="bookDate">Date</label>
        <input id="bookDate" name="date" type="date" required aria-describedby="bookDateError">
        <p class="error" id="bookDateError"></p>
      </div>
      <div class="field">
        <label for="bookTime">Time</label>
        <select id="bookTime" name="time" required disabled aria-describedby="bookTimeError">
          <option value="">Pick a date first</option>
        </select>
        <p class="error" id="bookTimeError"></p>
      </div>
      <div class="field">
        <span class="label" id="guestsLabel">Guests</span>
        <div class="stepper" role="group" aria-labelledby="guestsLabel">
          <button type="button" id="guestsDown" aria-label="One guest fewer">&minus;</button>
          <output id="guestsValue">2</output>
          <button type="button" id="guestsUp" aria-label="One guest more">+</button>
        </div>
      </div>
      <div class="field">
        <label for="occasion">Occasion (optional)</label>
        <select id="occasion" name="occasion">
          <option value="">None</option>
          <option>Birthday</option>
          <option>Anniversary</option>
          <option>Business dinner</option>
        </select>
      </div>
      <button class="btn btn-accent full" type="submit">Request my table</button>
    </form>

    <div class="confirm" id="confirmBox" hidden tabindex="-1">
      <span class="confirm-tick" aria-hidden="true">&#10003;</span>
      <h3>See you soon, <span id="confirmName"></span></h3>
      <p id="confirmText"></p>
      <p class="confirm-ref">Reference <strong id="confirmRef"></strong></p>
      <button type="button" class="btn btn-line-dark" id="confirmReset">Make another booking</button>
    </div>
  </div>
</section>

The form has novalidate because we'll show our own friendlier messages. We still add required, type="email" and minlength: the browser checks those rules for us, and novalidate only turns off its pop-up bubbles.

Each field points at its error paragraph with aria-describedby, so a screen reader reads the error along with the field. The time list starts disabled because the available times depend on the date. The confirmation box starts hidden, with tabindex="-1" so we can move keyboard focus to it later.

1.5 Reviews, visit and footer

index.html: reviews
<!-- ---------- REVIEWS ---------- -->
<section class="reviews" id="reviews">
  <div class="wrap">
    <div class="reviews-head">
      <div>
        <p class="eyebrow">Reviews</p>
        <h2>People keep coming back</h2>
      </div>
      <div class="car-btns">
        <button type="button" id="prevReview" aria-label="Previous review">&larr;</button>
        <button type="button" id="nextReview" aria-label="Next review">&rarr;</button>
      </div>
    </div>

    <div class="review-track" id="reviewTrack" tabindex="0" role="region" aria-label="Customer reviews">
      <figure class="review">
        <blockquote>The lamb shoulder fell apart at the touch of a fork. We ordered a second portion of bread just to finish the sauce.</blockquote>
        <figcaption>Priya N.</figcaption>
      </figure>
      <figure class="review">
        <blockquote>Warm room, kind staff and a menu with no filler. It's our first choice for birthdays now.</blockquote>
        <figcaption>Tom A.</figcaption>
      </figure>
      <figure class="review">
        <blockquote>As a vegetarian I usually get one sad option. Here I had trouble choosing. That cauliflower!</blockquote>
        <figcaption>Elena R.</figcaption>
      </figure>
      <figure class="review">
        <blockquote>You can smell the oak from down the street. The blackened peaches are worth the trip by themselves.</blockquote>
        <figcaption>Marcus B.</figcaption>
      </figure>
      <figure class="review">
        <blockquote>They remembered our anniversary without being reminded, and the cheesecake arrived with a candle.</blockquote>
        <figcaption>Hana S.</figcaption>
      </figure>
    </div>
  </div>
</section>
index.html: visit
<!-- ---------- VISIT ---------- -->
<section class="visit" id="visit">
  <div class="wrap visit-grid">
    <div>
      <p class="eyebrow">Visit</p>
      <h2>Find us on Mill Lane</h2>
      <address>18 Mill Lane, Riverside<br><a href="tel:+15550142">(555) 0142</a><br><a href="mailto:hello@example.com">hello@example.com</a></address>
    </div>
    <table class="hours">
      <caption class="sr-only">Opening hours</caption>
      <tbody id="hoursBody">
        <tr data-day="1"><th scope="row">Monday</th><td>Closed</td></tr>
        <tr data-day="2"><th scope="row">Tuesday</th><td>5:00 PM &ndash; 10:00 PM</td></tr>
        <tr data-day="3"><th scope="row">Wednesday</th><td>5:00 PM &ndash; 10:00 PM</td></tr>
        <tr data-day="4"><th scope="row">Thursday</th><td>5:00 PM &ndash; 10:00 PM</td></tr>
        <tr data-day="5"><th scope="row">Friday</th><td>5:00 PM &ndash; 11:00 PM</td></tr>
        <tr data-day="6"><th scope="row">Saturday</th><td>12:00 PM &ndash; 11:00 PM</td></tr>
        <tr data-day="0"><th scope="row">Sunday</th><td>12:00 PM &ndash; 9:00 PM</td></tr>
      </tbody>
    </table>
  </div>
</section>

</main>
index.html: footer
<!-- ---------- FOOTER ---------- -->
<footer class="site-footer">
  <div class="wrap footer-row">
    <span class="logo"><span class="logo-mark" aria-hidden="true"></span>Ember &amp; Thyme</span>
    <p>Demo restaurant for a coding tutorial. Not a real business.</p>
  </div>
</footer>

<script src="script.js"></script>
</body>
</html>

The review track has tabindex="0" so keyboard users can focus it and scroll with the arrow keys. Each review is a <figure> with a <blockquote> and a <figcaption>, the standard markup for a quote and who said it.

Opening hours belong in a real <table>. Each row has data-day, matching the numbers JavaScript uses for days, where Sunday is 0. Open index.html in a browser now. It's unstyled, but the whole page is there. That's the end of Part 1.

Part 2: The CSS

Now we turn the plain document into a warm, dark restaurant site, section by section.

2.1 Variables and shared styles

style.css: base
/* ---------- BASE ---------- */
:root {
  --ink: #17110d;
  --ink-2: #241a14;
  --cream: #f8f1e6;
  --paper: #fffaf2;
  --accent: #d9783a;
  --accent-dark: #b95d23;
  --herb: #5d7a4a;
  --text: #2c2119;
  --muted: #75675a;
  --line: #e7dbc9;
  --serif: Georgia, "Times New Roman", serif;
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

*, *::before, *::after { box-sizing: border-box; }

html { scroll-behavior: smooth; scroll-padding-top: 84px; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--text);
  font-family: var(--sans);
  line-height: 1.65;
}

h1, h2, h3 { font-family: var(--serif); font-weight: 400; line-height: 1.15; margin: 0; }
h2 { font-size: clamp(1.9rem, 4vw, 2.8rem); margin-bottom: 18px; }

.wrap { width: min(1120px, 100% - 40px); margin-inline: auto; }

section { padding: 88px 0; }

.eyebrow {
  margin: 0 0 10px;
  color: var(--accent-dark);
  font-size: .78rem;
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
}

.btn {
  display: inline-block;
  padding: 13px 26px;
  border: 1px solid transparent;
  border-radius: 999px;
  font: 600 .95rem var(--sans);
  text-decoration: none;
  cursor: pointer;
  transition: background .2s, color .2s, transform .2s;
}
.btn:hover { transform: translateY(-2px); }
.btn-accent { background: var(--accent); color: #fff; }
.btn-accent:hover { background: var(--accent-dark); }
.btn-line { border-color: rgba(255, 255, 255, .4); color: #fff; }
.btn-line:hover { background: rgba(255, 255, 255, .1); }
.btn-line-dark { border-color: var(--line); background: none; color: var(--text); }
.full { width: 100%; grid-column: 1 / -1; }

:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

All the colours live in :root, so changing the brand takes a few lines. We also define two font stacks. A serif for headings gives the page its restaurant feel, and Georgia is installed almost everywhere, so there's no web font to download.

2.2 The header

style.css: header
/* ---------- HEADER ---------- */
.site-header {
  position: sticky;
  top: 0;
  z-index: 20;
  background: var(--ink);
  color: #fff;
  transition: box-shadow .2s;
}
.site-header.scrolled { box-shadow: 0 8px 24px rgba(0, 0, 0, .35); }

.nav { display: flex; align-items: center; justify-content: space-between; height: 72px; }

.logo {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  color: inherit;
  font: 1.2rem var(--serif);
  text-decoration: none;
}

/* A flame cut from a circle with border-radius */
.logo-mark {
  width: 20px;
  height: 20px;
  background: linear-gradient(135deg, #f2b45a, var(--accent));
  border-radius: 50% 0 50% 50%;
  transform: rotate(-45deg);
}

.main-nav { display: flex; align-items: center; gap: 26px; }
.main-nav a:not(.btn) { color: #dcd0c2; font-size: .95rem; text-decoration: none; }
.main-nav a:not(.btn):hover { color: #fff; }
.main-nav .btn { padding: 9px 20px; }

.menu-btn { display: none; }

The logo flame is one square. border-radius: 50% 0 50% 50% rounds three corners and leaves one sharp, which makes a drop shape, and rotate(-45deg) points the tip upward. .main-nav a:not(.btn) styles the plain links without touching the button.

2.3 The hero, status pill and steam

style.css: hero
/* ---------- HERO ---------- */
.hero {
  padding: 72px 0 96px;
  background:
    radial-gradient(600px 420px at 78% 45%, rgba(217, 120, 58, .28), transparent 70%),
    var(--ink);
  color: #fff;
  overflow: hidden;
}

.hero-grid {
  display: grid;
  grid-template-columns: 1.1fr 1fr;
  align-items: center;
  gap: 40px;
}

.hero h1 { font-size: clamp(2.6rem, 6vw, 4.5rem); margin: 18px 0; }
.hero h1 em { color: #f2b45a; }
.lead { max-width: 30rem; margin: 0 0 30px; color: #d9cdbf; font-size: 1.1rem; }
.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; }

/* The status pill: its dot colour comes from the data-state attribute */
.status {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  margin: 0;
  padding: 6px 15px;
  border: 1px solid rgba(255, 255, 255, .2);
  border-radius: 999px;
  color: #eadfd2;
  font-size: .86rem;
}
.status::before {
  content: "";
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: #a89888;
}
.status[data-state="open"]::before { background: #6fd08c; box-shadow: 0 0 0 4px rgba(111, 208, 140, .22); }
.status[data-state="soon"]::before { background: #f2b45a; box-shadow: 0 0 0 4px rgba(242, 180, 90, .22); }
.status[data-state="closed"]::before { background: #e0736a; }

.hero-art svg { display: block; width: 100%; max-width: 440px; margin-inline: auto; }
.plate { fill: #f5ecdf; }
.plate-rim { fill: #fffaf2; stroke: #e7dbc9; stroke-width: 2; }
.steak { fill: #6b3219; }
.grill path { fill: none; stroke: #3a1a0c; stroke-width: 6; stroke-linecap: round; }
.tomato { fill: #c9402c; }
.herb path { fill: none; stroke: var(--herb); stroke-width: 4; stroke-linecap: round; }
.sauce { fill: #8f3f1f; }

.steam path {
  fill: none;
  stroke: rgba(255, 255, 255, .5);
  stroke-width: 4;
  stroke-linecap: round;
  animation: steam 3.2s ease-in-out infinite;
}
.steam path:nth-child(2) { animation-delay: .6s; }
.steam path:nth-child(3) { animation-delay: 1.2s; }

@keyframes steam {
  0%   { opacity: 0; transform: translateY(14px); }
  40%  { opacity: 1; }
  100% { opacity: 0; transform: translateY(-14px); }
}

The background is a soft orange radial-gradient placed behind the plate, on top of the dark colour, which looks like firelight. The headline uses clamp(2.6rem, 6vw, 4.5rem), so it scales with the screen between a minimum and a maximum, with no media query.

The status dot is a nice example of styling by attribute. JavaScript only sets data-state="open", "soon" or "closed", and rules like .status[data-state="open"]::before pick the colour. The script never mentions a colour, so design and logic stay apart.

The steam is three paths running the same animation. The animation-delay on the second and third path means they rise one after another, not all together.

2.4 The story

style.css: story
/* ---------- STORY ---------- */
.story-grid {
  display: grid;
  grid-template-columns: 1.2fr 1fr;
  align-items: center;
  gap: 60px;
}
.story p:not(.eyebrow) { color: var(--muted); }

.facts { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; }
.facts li {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  padding: 16px 0;
  border-bottom: 1px solid var(--line);
}
.facts strong { color: var(--accent-dark); font: 2rem var(--serif); }
.facts span { color: var(--muted); font-size: .92rem; text-align: right; }

A two-column grid with a simple list of facts. align-items: baseline lines up the big numbers and the small labels along the bottom of their text, which looks tidier than centring.

2.5 The menu

style.css: menu
/* ---------- MENU ---------- */
.menu-section { background: var(--cream); }

.menu-tools {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-top: 12px;
}

.tabs { display: flex; flex-wrap: wrap; gap: 8px; }
.tab {
  padding: 8px 18px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--paper);
  color: var(--text);
  font: 600 .9rem var(--sans);
  cursor: pointer;
}
.tab[aria-pressed="true"] { border-color: var(--ink); background: var(--ink); color: #fff; }

.menu-search { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; }
.menu-search input[type="search"] {
  width: 200px;
  padding: 9px 14px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--paper);
  font: inherit;
}
.check { display: inline-flex; align-items: center; gap: 7px; font-size: .92rem; cursor: pointer; }
.check input { accent-color: var(--herb); width: 17px; height: 17px; }

.menu-count { margin: 18px 0 6px; color: var(--muted); font-size: .9rem; }

.dishes {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0 56px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* h3 and price share the first row; the description spans both columns */
.dish {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 2px 16px;
  padding: 18px 0;
  border-bottom: 1px dotted #cdbfaa;
  animation: dishIn .3s ease backwards;
}
.dish[hidden] { display: none; }
.dish h3 { font-size: 1.2rem; }
.dish p { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: .94rem; }
.price { color: var(--accent-dark); font: 1.15rem var(--serif); }

.tag {
  display: inline-block;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--herb);
  color: #fff;
  font: 700 .66rem/20px var(--sans);
  text-align: center;
  vertical-align: 3px;
}

@keyframes dishIn {
  from { opacity: 0; transform: translateY(8px); }
}

.menu-empty { padding: 36px 0; color: var(--muted); text-align: center; }
.link-btn {
  border: 0;
  background: none;
  color: var(--accent-dark);
  font: inherit;
  text-decoration: underline;
  cursor: pointer;
}

Each dish is its own small grid. grid-template-columns: 1fr auto gives the name all the spare room and the price only what it needs, so prices line up on the right. The description uses grid-column: 1 / -1 to run across both columns.

There's one line here that's easy to miss: .dish[hidden] { display: none; }. The browser's own rule for hidden is a weak display: none, so our display: grid beats it and the dish stays visible. Whenever you put display on something you plan to hide with hidden, add this rule.

The dishIn animation has only a from step. The browser fills in the end from the element's normal style, so a dish fades in every time the filter shows it again.

The menu section: tabs for All, Starters, Mains and Desserts, a search box, a vegetarian checkbox and twelve dishes in two columns with prices on the right
The full menu in two columns, with the prices lined up by a small grid in each dish.

2.6 The booking form

style.css: reserve
/* ---------- RESERVE ---------- */
.reserve { background: var(--ink); color: #fff; }

.reserve-grid {
  display: grid;
  grid-template-columns: 1fr 1.35fr;
  align-items: start;
  gap: 56px;
}
.reserve .eyebrow { color: #f2b45a; }
.reserve-intro p:not(.eyebrow) { color: #d9cdbf; }
.reserve-phone a { color: #fff; font: 1.6rem var(--serif); text-decoration: none; }

.reserve-form, .confirm {
  padding: 30px;
  border-radius: 14px;
  background: var(--paper);
  color: var(--text);
}

.reserve-form { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 18px; }
.confirm[hidden], .reserve-form[hidden] { display: none; }

.field { min-width: 0; }
.field label, .label { display: block; margin-bottom: 5px; font-size: .88rem; font-weight: 600; }
.field input, .field select {
  width: 100%;
  height: 46px;
  padding: 0 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: #fff;
  color: var(--text);
  font: inherit;
}
.field select:disabled { background: #f3ece1; color: var(--muted); }
.field [aria-invalid="true"] { border-color: #c5392b; }

.error { min-height: 1.2em; margin: 4px 0 0; color: #b42f22; font-size: .82rem; line-height: 1.2; }

.stepper {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 46px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: #fff;
}
.stepper button {
  width: 46px;
  height: 100%;
  border: 0;
  background: none;
  color: var(--accent-dark);
  font-size: 1.3rem;
  cursor: pointer;
}
.stepper button:disabled { color: #ccc; cursor: not-allowed; }
.stepper output { font-weight: 700; font-variant-numeric: tabular-nums; }

.confirm { text-align: center; }
.confirm:focus { outline: none; }
.confirm h3 { margin: 12px 0 8px; font-size: 1.7rem; }
.confirm p { margin: 0 0 10px; color: var(--muted); }
.confirm-ref strong { color: var(--text); letter-spacing: .06em; }
.confirm-tick {
  display: inline-grid;
  place-items: center;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: var(--herb);
  color: #fff;
  font-size: 1.5rem;
}

The form is a two-column grid, and the submit button uses grid-column: 1 / -1 to span both. .error has a min-height, which reserves a line for each message, so the form doesn't jump when an error appears.

.field [aria-invalid="true"] turns the border red. As with the status pill, the CSS reads the same attribute that screen readers use, so what people see and what they hear always agree.

2.7 The reviews carousel

style.css: reviews
/* ---------- REVIEWS ---------- */
.reviews-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }
.car-btns { display: flex; gap: 8px; margin-bottom: 20px; }
.car-btns button {
  width: 46px;
  height: 46px;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: #fff;
  font-size: 1.1rem;
  cursor: pointer;
}
.car-btns button:disabled { opacity: .35; cursor: default; }

/* Native scrolling does the work; snap settles each card into place */
.review-track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: calc((100% - 40px) / 3);
  gap: 20px;
  padding-bottom: 12px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
}
.review-track::-webkit-scrollbar { display: none; }

.review {
  display: flex;
  flex-direction: column;
  margin: 0;
  padding: 26px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: #fff;
  scroll-snap-align: start;
}
.review blockquote { flex: 1; margin: 0 0 16px; font: 1.05rem/1.55 var(--serif); }
.review figcaption { color: var(--muted); font-size: .9rem; }

This carousel is mostly CSS. grid-auto-flow: column puts every card in one long row, and overflow-x: auto makes that row scroll. Then two lines do the rest: scroll-snap-type: x mandatory on the track, and scroll-snap-align: start on each card, so a card always settles neatly in place. Swiping on a phone, trackpad scrolling and keyboard scrolling all work for free.

The width calc((100% - 40px) / 3) fits exactly three cards with their two 20px gaps.

Three review cards side by side under the heading People keep coming back, with round previous and next arrow buttons above them
Scroll snap does the hard work. The arrows only need a couple of lines of JavaScript.

2.8 Visit and footer

style.css: visit and footer
/* ---------- VISIT AND FOOTER ---------- */
.visit { background: var(--cream); }
.visit-grid { display: grid; grid-template-columns: 1fr 1fr; align-items: center; gap: 56px; }
address { color: var(--muted); font-style: normal; font-size: 1.05rem; }
address a { color: var(--accent-dark); }

.hours { width: 100%; border-collapse: collapse; }
.hours th, .hours td { padding: 11px 10px; border-bottom: 1px solid var(--line); text-align: left; }
.hours th { font-weight: 600; }
.hours td { color: var(--muted); text-align: right; }
.hours tr.today { background: #fff; }
.hours tr.today th, .hours tr.today td { color: var(--accent-dark); font-weight: 700; }

.site-footer { padding: 28px 0; background: var(--ink); color: #b8aa9b; }
.site-footer .logo { color: #fff; }
.footer-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
.footer-row p { margin: 0; font-size: .86rem; }

The .today class highlights the current day in the hours table. JavaScript adds it in Part 3.

2.9 Animations, phones and reduced motion

style.css: animations
/* ---------- ANIMATIONS ---------- */
.js .reveal:not(.visible) { opacity: 0; }
.js .reveal.visible { animation: rise .55s ease both; }
.js .reveal.visible:nth-child(2) { animation-delay: .1s; }
.js .reveal.visible:nth-child(3) { animation-delay: .2s; }

@keyframes rise {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: none; }
}

.js .reveal:not(.visible) only hides an item when the script has loaded and hasn't revealed it yet. If the script fails, or the animation never runs, the content still shows. Always build scroll effects this way.

style.css: small screens
/* ---------- SMALL SCREENS ---------- */
@media (max-width: 900px) {
  section { padding: 64px 0; }
  .hero { padding: 48px 0 64px; }
  .hero-grid, .story-grid, .reserve-grid, .visit-grid { grid-template-columns: 1fr; gap: 36px; }
  .hero-art svg { max-width: 300px; }
  .dishes { grid-template-columns: 1fr; }
  .review-track { grid-auto-columns: calc((100% - 20px) / 2); }

  .menu-btn {
    display: block;
    position: relative;
    width: 44px;
    height: 44px;
    border: 0;
    background: none;
    cursor: pointer;
  }
  .menu-btn span, .menu-btn span::before, .menu-btn span::after {
    position: absolute;
    left: 10px;
    width: 24px;
    height: 2px;
    background: #fff;
    transition: transform .2s, background .2s;
  }
  .menu-btn span { top: 21px; }
  .menu-btn span::before, .menu-btn span::after { content: ""; left: 0; }
  .menu-btn span::before { top: -7px; }
  .menu-btn span::after { top: 7px; }
  .menu-btn[aria-expanded="true"] span { background: transparent; }
  .menu-btn[aria-expanded="true"] span::before { transform: translateY(7px) rotate(45deg); }
  .menu-btn[aria-expanded="true"] span::after { transform: translateY(-7px) rotate(-45deg); }

  .main-nav {
    display: none;
    position: absolute;
    top: 72px;
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: stretch;
    gap: 4px;
    padding: 12px 20px 22px;
    background: var(--ink);
    box-shadow: 0 16px 24px rgba(0, 0, 0, .35);
  }
  .main-nav.open { display: flex; }
  .main-nav a:not(.btn) { padding: 10px 0; }
  .main-nav .btn { margin-top: 8px; text-align: center; }
}

@media (max-width: 600px) {
  .reserve-form { grid-template-columns: 1fr; padding: 22px; }
  .review-track { grid-auto-columns: 86%; }
  .menu-search, .menu-search input[type="search"] { width: 100%; }
  .hero-actions .btn { flex: 1; text-align: center; }
}

At 900px the two-column sections stack, and the links hide behind a hamburger. The three bars are one <span> plus its ::before and ::after. When aria-expanded="true", the middle bar goes transparent and the other two rotate into an X. At 600px the form becomes one column, and each review takes 86% of the width, so the next card peeks in and shows people they can swipe.

The restaurant site on a phone: stacked headline, status and booking buttons above the plate illustration
On a phone.
The phone layout with the menu open: the hamburger has become an X and the links are listed below the header
Menu open: the icon turns into an X.
style.css: reduced motion
/* ---------- REDUCED MOTION ---------- */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-delay: 0ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
  .steam path { opacity: .5; }
}

Some people turn off motion in their system settings because it makes them dizzy. This block makes every animation finish instantly for them. That's the end of Part 2: the page looks finished, and only the live parts are missing.

Part 3: The JavaScript

The script has seven small, separate jobs. Most of the interesting ideas here are about dates and times, so we'll start there.

3.1 Settings and time helpers

script.js: settings
/* ---------- SETTINGS ---------- */
// Opening hours in 24-hour time. The index is the same as Date.getDay(): 0 is Sunday.
const HOURS = [
  { open: '12:00', close: '21:00' }, // Sunday
  null,                              // Monday: closed
  { open: '17:00', close: '22:00' }, // Tuesday
  { open: '17:00', close: '22:00' }, // Wednesday
  { open: '17:00', close: '22:00' }, // Thursday
  { open: '17:00', close: '23:00' }, // Friday
  { open: '12:00', close: '23:00' }  // Saturday
];
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const LAST_BOOKING = 60;   // minutes before closing
const SLOT_LENGTH = 30;    // minutes between booking times
const MAX_GUESTS = 8;
const BOOK_AHEAD_DAYS = 60;

// '17:30' becomes 1050, the number of minutes since midnight
const toMinutes = (time) => {
  const [hours, minutes] = time.split(':').map(Number);
  return hours * 60 + minutes;
};

// 1050 becomes '5:30 PM'
const toLabel = (total) => {
  const hours = Math.floor(total / 60);
  const minutes = String(total % 60).padStart(2, '0');
  return `${hours % 12 || 12}:${minutes} ${hours < 12 ? 'AM' : 'PM'}`;
};

// YYYY-MM-DD in the visitor's own time zone. toISOString() would give the UTC date,
// which is the wrong day for part of every evening or morning in many countries.
const toDateValue = (date) => [
  date.getFullYear(),
  String(date.getMonth() + 1).padStart(2, '0'),
  String(date.getDate()).padStart(2, '0')
].join('-');

// new Date('2026-11-06') is read as UTC, so split the value and build a local date
const fromDateValue = (value) => {
  const [year, month, day] = value.split('-').map(Number);
  return new Date(year, month - 1, day);
};

HOURS has seven entries in the same order as Date.getDay(), where Sunday is 0, so HOURS[new Date().getDay()] gives today's hours directly. A closed day is null.

The key idea is toMinutes(). Comparing '17:30' and '9:00' as text gives the wrong answer, because text compares letter by letter. Turn both into minutes since midnight and you can compare, add and subtract them like any number. toLabel() converts back for display. hours % 12 || 12 is a neat trick: 12 and 0 both give 0, which || replaces with 12.

The last two helpers avoid a time zone bug that catches nearly everyone:

toDateValue() and fromDateValue() build the date from its parts in local time, which fixes both.

3.2 Menu button, header and reveal

script.js: mobile menu
/* ---------- MOBILE MENU ---------- */
const menuBtn = document.getElementById('menuBtn');
const mainNav = document.getElementById('mainNav');

function setMenu(open) {
  menuBtn.setAttribute('aria-expanded', open);
  menuBtn.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
  mainNav.classList.toggle('open', open);
}

menuBtn.addEventListener('click', () => {
  setMenu(menuBtn.getAttribute('aria-expanded') !== 'true');
});
mainNav.addEventListener('click', (e) => {
  if (e.target.closest('a')) setMenu(false);
});
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') setMenu(false);
});
script.js: sticky header
/* ---------- STICKY HEADER ---------- */
const siteHeader = document.getElementById('siteHeader');
window.addEventListener('scroll', () => {
  siteHeader.classList.toggle('scrolled', window.scrollY > 10);
}, { passive: true });
script.js: scroll reveal
/* ---------- SCROLL REVEAL ---------- */
document.documentElement.classList.add('js');

const revealer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return;
    entry.target.classList.add('visible');
    revealer.unobserve(entry.target);
  });
}, { threshold: 0.2 });

document.querySelectorAll('.reveal').forEach((el) => revealer.observe(el));

setMenu() is the single place that opens or closes the menu, so the attribute, the label and the class can never disagree. classList.toggle(name, condition) replaces an if/else. IntersectionObserver tells us when an item scrolls into view, which is far cheaper than measuring positions on every scroll event.

3.3 The open-now status

script.js: open now
/* ---------- OPEN NOW ---------- */
const openStatus = document.getElementById('openStatus');

function nextOpening(now) {
  // Look up to seven days ahead for the next day with opening hours
  for (let i = 1; i <= 7; i++) {
    const day = (now.getDay() + i) % 7;
    if (HOURS[day]) {
      const name = i === 1 ? 'tomorrow' : DAY_NAMES[day];
      return `${name} at ${toLabel(toMinutes(HOURS[day].open))}`;
    }
  }
  return 'soon';
}

function updateStatus() {
  const now = new Date();
  const today = HOURS[now.getDay()];
  const minutes = now.getHours() * 60 + now.getMinutes();

  if (today && minutes >= toMinutes(today.open) && minutes < toMinutes(today.close)) {
    const left = toMinutes(today.close) - minutes;
    openStatus.dataset.state = left <= 60 ? 'soon' : 'open';
    openStatus.textContent = left <= 60
      ? `Closing soon, at ${toLabel(toMinutes(today.close))}`
      : `Open now until ${toLabel(toMinutes(today.close))}`;
  } else if (today && minutes < toMinutes(today.open)) {
    openStatus.dataset.state = 'closed';
    openStatus.textContent = `Closed, opens today at ${toLabel(toMinutes(today.open))}`;
  } else {
    openStatus.dataset.state = 'closed';
    openStatus.textContent = `Closed, opens ${nextOpening(now)}`;
  }
}

updateStatus();
setInterval(updateStatus, 60000); // keep it right if the tab stays open

// Highlight today's row in the opening hours table
const todayRow = document.querySelector(`#hoursBody tr[data-day="${new Date().getDay()}"]`);
todayRow.classList.add('today');

updateStatus() gets the current time in minutes and handles three cases:

  1. Between opening and closing: open. With 60 minutes or less to go, it switches to the amber "closing soon" state.
  2. Before today's opening: closed, opening later today.
  3. After closing, or a closed day: nextOpening() looks forward through the week. (now.getDay() + i) % 7 wraps Saturday (6) back round to Sunday (0). On Sunday night it skips Monday and says "opens Tuesday at 5:00 PM".

The same check runs every minute with setInterval, so the status stays right on a tab left open. The last two lines find today's row with a template literal in the selector and add the today class.

3.4 Filtering the menu

script.js: menu filter
/* ---------- MENU FILTER ---------- */
const dishes = [...document.querySelectorAll('.dish')];
const menuTabs = document.getElementById('menuTabs');
const menuSearch = document.getElementById('menuSearch');
const vegOnly = document.getElementById('vegOnly');
const menuCount = document.getElementById('menuCount');
const menuEmpty = document.getElementById('menuEmpty');

// One object holds every filter, and filterMenu() is the only place that reads it
const filters = { course: 'all', query: '', veg: false };

function filterMenu() {
  let shown = 0;

  dishes.forEach((dish) => {
    const matchesCourse = filters.course === 'all' || dish.dataset.course === filters.course;
    const matchesVeg = !filters.veg || dish.dataset.veg === 'true';
    const matchesQuery = dish.textContent.toLowerCase().includes(filters.query);

    dish.hidden = !(matchesCourse && matchesVeg && matchesQuery);
    if (!dish.hidden) shown++;
  });

  menuCount.textContent = `${shown} ${shown === 1 ? 'dish' : 'dishes'}`;
  menuEmpty.hidden = shown > 0;
}

menuTabs.addEventListener('click', (e) => {
  const tab = e.target.closest('.tab');
  if (!tab) return;
  menuTabs.querySelectorAll('.tab').forEach((t) => t.setAttribute('aria-pressed', t === tab));
  filters.course = tab.dataset.filter;
  filterMenu();
});

menuSearch.addEventListener('input', () => {
  filters.query = menuSearch.value.trim().toLowerCase();
  filterMenu();
});

vegOnly.addEventListener('change', () => {
  filters.veg = vegOnly.checked;
  filterMenu();
});

document.getElementById('menuReset').addEventListener('click', () => {
  menuSearch.value = '';
  vegOnly.checked = false;
  filters.query = '';
  filters.veg = false;
  menuTabs.querySelector('[data-filter="all"]').click();
});

There are three filters that all work together, so they share one filters object. Every control does the same two things: update that object, then call filterMenu(). No control needs to know about the others.

Inside filterMenu(), a dish must pass all three checks. !filters.veg || dish.dataset.veg === 'true' reads as "either the box is off, or this dish is vegetarian". dish.textContent holds the name, price and description together, so searching for mint finds the lamb from its description alone. Lowercasing both sides makes the search ignore capitals.

[...document.querySelectorAll('.dish')] copies the results into a real array, because we'll want array methods on it. The tabs use event delegation: one listener on the group, with closest('.tab') finding which tab was clicked.

The menu filtered to Mains with Vegetarian only ticked, showing 2 dishes: Wood-roast cauliflower and Wild mushroom orzo
Mains plus Vegetarian only: all the filters combine.

3.5 The booking form

script.js: reservation
/* ---------- RESERVATION ---------- */
const reserveForm = document.getElementById('reserveForm');
const bookDate = document.getElementById('bookDate');
const bookTime = document.getElementById('bookTime');
const confirmBox = document.getElementById('confirmBox');

// Limit the date picker to today and the next 60 days
const firstDay = new Date();
const lastDay = new Date();
lastDay.setDate(lastDay.getDate() + BOOK_AHEAD_DAYS);
bookDate.min = toDateValue(firstDay);
bookDate.max = toDateValue(lastDay);

// Guests stepper
let guests = 2;
const guestsValue = document.getElementById('guestsValue');
const guestsDown = document.getElementById('guestsDown');
const guestsUp = document.getElementById('guestsUp');

function setGuests(value) {
  guests = value;
  guestsValue.textContent = guests;
  guestsDown.disabled = guests <= 1;
  guestsUp.disabled = guests >= MAX_GUESTS;
}
guestsDown.addEventListener('click', () => setGuests(guests - 1));
guestsUp.addEventListener('click', () => setGuests(guests + 1));

// Every bookable time on a given date
function slotsFor(date) {
  const hours = HOURS[date.getDay()];
  if (!hours) return [];

  const slots = [];
  const last = toMinutes(hours.close) - LAST_BOOKING;
  for (let t = toMinutes(hours.open); t <= last; t += SLOT_LENGTH) slots.push(t);

  // For a booking today, drop the times that have already passed
  const now = new Date();
  if (toDateValue(date) !== toDateValue(now)) return slots;
  const minutesNow = now.getHours() * 60 + now.getMinutes();
  return slots.filter((t) => t > minutesNow);
}

function fillTimes() {
  bookTime.innerHTML = '';
  const slots = bookDate.value ? slotsFor(fromDateValue(bookDate.value)) : [];

  bookTime.disabled = slots.length === 0;
  bookTime.add(new Option(slots.length ? 'Choose a time' : 'No times on this day', ''));
  slots.forEach((t) => bookTime.add(new Option(toLabel(t), toLabel(t))));
}

function setError(field, message) {
  document.getElementById(field.id + 'Error').textContent = message;
  if (message) field.setAttribute('aria-invalid', 'true');
  else field.removeAttribute('aria-invalid');
}

// Returns the message for one field, or '' if it's fine
function messageFor(field) {
  const v = field.validity;

  if (field === bookDate) {
    if (v.valueMissing) return 'Choose a date.';
    if (v.rangeUnderflow) return 'That date has passed.';
    if (v.rangeOverflow) return `You can book up to ${BOOK_AHEAD_DAYS} days ahead.`;
    if (!HOURS[fromDateValue(field.value).getDay()]) return 'We are closed on Mondays.';
    if (slotsFor(fromDateValue(field.value)).length === 0) return 'No times are left today.';
    return '';
  }
  if (v.valueMissing) return field === bookTime ? 'Choose a time.' : 'This field is required.';
  if (v.tooShort) return 'Please enter your full name.';
  if (v.typeMismatch) return 'Enter a valid email address.';
  return '';
}

const checkField = (field) => {
  const message = messageFor(field);
  setError(field, message);
  return message === '';
};

const requiredFields = [...reserveForm.querySelectorAll('[required]')];

// Check a field when the visitor leaves it, not on every key press
requiredFields.forEach((field) => field.addEventListener('blur', () => checkField(field)));

bookDate.addEventListener('change', () => {
  fillTimes();
  checkField(bookDate);
});

// A readable reference, skipping letters that look like numbers
function makeReference() {
  const letters = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
  let ref = 'ET-';
  for (let i = 0; i < 5; i++) ref += letters[Math.floor(Math.random() * letters.length)];
  return ref;
}

reserveForm.addEventListener('submit', (e) => {
  e.preventDefault();

  const results = requiredFields.map(checkField);
  if (results.includes(false)) {
    requiredFields[results.indexOf(false)].focus();
    return;
  }

  const data = new FormData(reserveForm);
  const when = fromDateValue(data.get('date')).toLocaleDateString('en-US', {
    weekday: 'long', month: 'long', day: 'numeric'
  });

  // textContent treats the typed name as plain text, so it can never run as code
  document.getElementById('confirmName').textContent = data.get('name').trim().split(' ')[0];
  document.getElementById('confirmText').textContent =
    `Table for ${guests} on ${when} at ${data.get('time')}.`;
  document.getElementById('confirmRef').textContent = makeReference();

  reserveForm.hidden = true;
  confirmBox.hidden = false;
  confirmBox.focus();
});

document.getElementById('confirmReset').addEventListener('click', () => {
  reserveForm.reset();
  setGuests(2);
  fillTimes();
  bookTime.options[0].textContent = 'Pick a date first';
  confirmBox.hidden = true;
  reserveForm.hidden = false;
  document.getElementById('guestName').focus();
});

This is the longest block, so let's take it in order.

The booking form submitted empty, with red borders on the name, email, date and time fields and a short error message under each
Submitting too early: each field explains what it needs.

The confirmation shows the visitor's name with textContent, never innerHTML. Whatever someone types into the form appears as plain text and can never run as code. Moving focus to the confirmation box means keyboard and screen reader users land on the result, not on a form that just vanished.

A confirmation card with a green tick, the text See you soon, Sam, the booking details and a reference number, next to the Book a table heading
A successful booking, with a generated reference.

3.6 The carousel arrows

script.js: reviews carousel
/* ---------- REVIEWS CAROUSEL ---------- */
const track = document.getElementById('reviewTrack');
const prevReview = document.getElementById('prevReview');
const nextReview = document.getElementById('nextReview');

function moveReviews(direction) {
  const gap = parseFloat(getComputedStyle(track).columnGap) || 0;
  track.scrollBy({ left: direction * (track.firstElementChild.offsetWidth + gap), behavior: 'smooth' });
}

function updateArrows() {
  const end = track.scrollWidth - track.clientWidth;
  prevReview.disabled = track.scrollLeft <= 2;
  nextReview.disabled = track.scrollLeft >= end - 2;
}

prevReview.addEventListener('click', () => moveReviews(-1));
nextReview.addEventListener('click', () => moveReviews(1));
track.addEventListener('scroll', updateArrows, { passive: true });
window.addEventListener('resize', updateArrows);
updateArrows();

Swiping already works from the CSS, so the arrows only need to scroll by one card. getComputedStyle reads the gap from the stylesheet, so nothing here breaks if you change it. updateArrows() disables an arrow at each end of the track, and the 2px of slack allows for browsers that stop a fraction of a pixel short of the end.

Test it yourself

  1. Look at the status pill, then check it against the opening hours table at the bottom.
  2. Type LAMB in the menu search. Capitals don't matter.
  3. Click Mains, then tick Vegetarian only. You should see 2 dishes.
  4. Search for zzz and use the link that appears to reset everything.
  5. In the booking form, pick a Monday. You should get a message, and no times.
  6. Submit the empty form. All the errors should appear, and focus should jump to the name field.
  7. Make a booking with a name, a Saturday and a time, and check the confirmation.
  8. Make the window narrow and swipe or scroll the reviews.

Using it on a real site

Share your version

Built a site for your own favourite restaurant? Share a screenshot as a link with sshot.online, or share your code with the free code sharing tool. No account needed. For another project in the same style, try the real estate website tutorial, which builds its listings from data and adds a mortgage calculator.

Conclusion

You've built a complete, responsive restaurant website in three parts. Along the way you used:

Frequently asked questions

Do I need a framework to build a restaurant website?

No. This whole site is one HTML file, one CSS file and one JavaScript file, with no frameworks, libraries or image files.

How does the open-now status work?

The opening hours are stored in an array indexed by day of the week. The script turns the current time into minutes since midnight and compares it with that day's opening and closing times.

Does the booking form actually send a reservation?

Not in this demo. It validates the form and shows a confirmation on the page. To take real bookings, send the form data to your server or a booking service with fetch(), and check the table is free there.

Why is the menu written in the HTML instead of a JavaScript array?

A restaurant menu is the content people and search engines come for. Keeping it in the HTML means it is readable with JavaScript turned off, and the script only shows or hides dishes.

How do I use the visitor's time zone correctly?

This demo uses the visitor's device time, which is correct for local customers. If tourists book from abroad, work out the time in the restaurant's own zone with Intl.DateTimeFormat and its timeZone option.

Related articles

← All guides