Overview#
Bistro is a warm, high-end website for a restaurant, café or wine bar. The demo restaurant is Olive & Ember, a fictional wood-fired Mediterranean kitchen, with tabbed menus and a dietary filter, a reservation form whose time slots follow the opening hours, private dining packages and a live "open now" badge. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.3. It builds to static HTML files with no UI framework and a small amount of JavaScript, and can be hosted anywhere. See it live at freeframe.dev/templates/bistro.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/bistro into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | What's on it |
|---|---|---|
/ |
src/pages/index.astro |
Fire hero, open-now strip, intro and stats, signature dishes, chef quote, reviews, hours and booking call to action |
/menu |
src/pages/menu.astro |
Lunch / Dinner / Drinks / Desserts tabs, vegetarian / vegan / gluten-free filter, allergen notes |
/about |
src/pages/about.astro |
Story timeline, values, team, suppliers |
/reservations |
src/pages/reservations.astro |
Reservation form with day-dependent time slots, policies sidebar |
/events |
src/pages/events.astro |
Three private dining packages, upcoming events, enquiry form |
/contact |
src/pages/contact.astro |
Contact cards, opening hours with today highlighted, illustrated map, contact form, FAQ |
| 404 | src/pages/404.astro |
Not-found page with links to home, menu and booking (most hosts show 404.html automatically) |
Every page has a newsletter form in the footer.
Project structure#
bistro/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), skip link, header, footer, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro logo, desktop navigation, "Reserve a table" button, mobile menu
│ │ └─ SiteFooter.astro address, hours, open-now status, newsletter form, links
│ ├─ data/
│ │ └─ hours.ts opening hours, time zone, last seating and slot length
│ ├─ lib/
│ │ └─ site.ts site name, theme colour and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts open-now badge, menu, reveals, tabs, dietary filter, forms
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (colours, fonts) and shared component classes
├─ public/img/ SVG dishes, chef, fire and wine art, favicon, social image
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonPages are rendered to HTML at build time. The two files in src/scripts/ are the only JavaScript sent
to the browser, plus one inline line in the layout's <head> that adds the js class to <html> (so
scroll-reveal content is only hidden when scripts run). Layout.astro imports both scripts and Astro
bundles them into one small module.
src/scripts/main.ts is split into five numbered sections: 1. Opening hours, 2. Header + mobile
menu, 3. Scroll reveal, 4. Tabs + dietary filter and 5. Forms.
Customising#
Brand and colours#
The palette and fonts are tokens in the @theme block at the top of src/styles/global.css:
| Token | Used for |
|---|---|
--color-char, --color-char-2, --color-char-3 |
Dark charcoal backgrounds (header, hero, dark bands) |
--color-cream, --color-parchment, --color-sand |
Light backgrounds and borders |
--color-ember, --color-ember-dark, --color-ember-light |
Primary accent: buttons, links |
--color-olive, --color-olive-light |
Secondary accent |
--color-gold |
Highlights, "Today" tags, focus outlines |
--color-muted |
Secondary text |
For example, a deep blue accent instead of ember:
@theme {
--color-ember: #2b4c7e;
--color-ember-dark: #1f3a63;
--color-ember-light: #7c9ccf;
}pnpm dev picks the change up immediately. Also update themeColor in src/lib/site.ts, which sets
<meta name="theme-color"> on every page.
Typography#
Headings use Cormorant Garamond (--font-display) and text uses Manrope (--font-sans), loaded
from Google Fonts by the <link rel="stylesheet"> in src/layouts/Layout.astro. To change them, replace
that link and the two tokens in global.css.
Content#
Page text is plain markup in the .astro files in src/pages/. The restaurant name, address and phone
number also appear in SiteHeader.astro and SiteFooter.astro; search and replace "Olive & Ember" to
rebrand, and change name in src/lib/site.ts (used for og:site_name).
Opening hours are set once in src/data/hours.ts:
/** Opening hours per weekday (0 = Sunday). null = closed all day. */
export const HOURS: Record<number, DayHours | null> = {
0: { open: 12 * 60, close: 16 * 60 },
1: null,
2: { open: 17 * 60, close: 22 * 60 },
// ...
};
/** Minutes before closing of the last bookable table. */
export const LAST_SEATING_BEFORE_CLOSE = 90;
/** Minutes between reservation slots. */
export const SLOT_STEP = 30;
export const TIMEZONE = "America/New_York";Times are minutes after midnight. Set TIMEZONE to your restaurant's IANA zone (for example
"Europe/London" or "America/Chicago"). businessNow() in src/scripts/main.ts always reads "now"
in that zone, so every visitor sees the same open/closed status, today's row and bookable times, wherever
their device is.
HOURS drives the open/closed badge, the highlighted row in the hours tables and the reservation time
slots. The hours printed in the pages (the hours tables on the home, reservations and contact
pages) are plain text: update them to match. Each hours row lists the weekdays it covers so
today's row can be highlighted:
<div class="hours-row border-cream/15" data-day="2,3,4"><dt>Tuesday – Thursday ...</dt><dd class="text-cream/60">5pm – 10pm</dd></div>Menu dishes in src/pages/menu.astro are list items inside a tab panel and a group:
<div data-menu-group>
<h3>Small plates</h3>
<ul>
<li class="flex gap-6 border-b border-dashed border-sand py-5" data-dish data-diet="v gf">
...dish name, description and price...
</li>
</ul>
</div>data-diet holds space-separated codes: v (vegetarian), vg (vegan) and gf (gluten-free), or is
empty. Show the matching badges (badge-v, badge-vg, badge-gf) inside the dish so guests can see
them. Every dish needs data-dish; every group of dishes needs data-menu-group.
Private dining packages in src/pages/events.astro each have an "Enquire" link with
data-package="The Long Table"; the value must match an <option> in the enquiry form's package
select (see Forms).
Images and artwork#
All artwork in public/img/ is original SVG: dishes (dish-*.svg), chef.svg, hero-fire.svg,
wine.svg, plus favicon.svg and the social image (og-image.svg source, og-image.png export at
1200×630). To use photos, save them in public/img/ and link them with the url() helper so they still
work under a base path:
<img src={url("/img/dish-lamb.jpg")} alt="Slow-roasted lamb shoulder with smoked aubergine" width="800" height="640" />Keep similar proportions (dish images are square-ish, the hero is wide) and write descriptive alt
text. For automatic resizing and WebP output, put images in src/ and use Astro's <Image /> component
from astro:assets instead.
Navigation, header and footer#
src/components/SiteHeader.astroholds the desktop navigation and the mobile menu (#mobile-nav). Each link getsaria-current="page"when the page'scurrentprop matches it (current="menu"marks/menu).src/components/SiteFooter.astrois the footer, including the newsletter form.
To add a page, create src/pages/wine.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Wine list — Olive & Ember" description="Our natural and classic wines." page="wine" current="wine">
<section class="container-x pt-36 pb-20">
<h1 class="font-display text-5xl">Wine list</h1>
</section>
</Layout>Then add the link to both menus in SiteHeader.astro, written as href={url("/wine")} with
aria-current={current === "wine" ? "page" : undefined}, and to the footer. Always write internal links
with url() from src/lib/site.ts so they keep working under a base path.
Features#
Open now badge and today's hours#
Any element with data-open-status shows the live status from HOURS, for example "Open now · until
10pm" or "Closed · opens tomorrow at 5pm", and gets data-state="open" or "closed" for styling. Its
[data-open-text] child receives the text. Rows with data-day get the is-today class on matching
weekdays. The code is openStatus() and updateOpenStatus() in section 1 of src/scripts/main.ts; the
status refreshes every minute.
The status uses the time in TIMEZONE, not the visitor's device clock, so a guest browsing from another
country still sees whether you're open right now.
Menu tabs#
The tab bar on /menu (data-tabs) follows the WAI-ARIA tabs pattern: buttons with role="tab",
aria-selected and aria-controls pointing at panels. Left/Right arrows move between tabs and
Home/End jump to the first or last. Clicking a tab updates the URL hash, and opening /menu#drinks
selects the Drinks tab. To add a tab, add a button and a panel with matching aria-controls and id.
Dietary filter#
Buttons with data-diet-filter (all, v, vg, gf) show only matching dishes in every tab
(applyDietFilter() in main.ts). Vegan dishes also count as vegetarian. Groups with no matching dishes
are hidden, an empty-state message ([data-empty] inside a panel) appears if a whole tab is empty, and
[data-filter-status] announces the result ("Showing 14 matching dishes and drinks."). The buttons use
aria-pressed.
Reservation time slots#
On /reservations the time select is filled from the chosen date by setupReservation():
- Slots run every
SLOT_STEPminutes from opening untilLAST_SEATING_BEFORE_CLOSEminutes before closing. - For today (in
TIMEZONE), only slots at least an hour from now are offered. - Closed days (
nullinHOURS) show "Closed that day" and the date field explains why. - The date can't be before today in
TIMEZONE(data-min-todaysets the field'smin) or more than two months ahead (data-max-months="2"sets itsmax). - Choosing "9 or more" guests shows a note pointing to private dining (
[data-party-note]). - A note under the field (
[data-slot-note]) shows that day's kitchen hours and last seating.
Character counters#
A <textarea> with data-count and a maxlength shows "12 / 300" in the element whose id is the
textarea's id plus -count (for example res-notes-count).
Mobile menu and header#
The header (data-header) gains a background (is-scrolled) when the page scrolls. Below desktop width
the button with data-nav-toggle opens #mobile-nav, updates aria-expanded and its label, moves focus
to the first link, and closes with Esc (returning focus to the button), when a link is chosen or
when the window gets wider.
Scroll reveals#
Elements with data-reveal fade in as they enter the viewport; delay them with style="--d:160ms". The
styles are the .js [data-reveal] rules in global.css, so content stays visible if JavaScript is off.
Reduced-motion settings make them appear immediately.
Back to top button#
src/scripts/back-to-top.ts adds a .back-to-top button that appears after you scroll down about one
screen. It scrolls back up (instantly with reduced motion) and moves keyboard focus to #main. It lifts
itself above toasts. Mark your own fixed UI with data-back-to-top-avoid (lift above it) or
data-back-to-top-hide (hide while covering it). The styles are the .back-to-top rules in
global.css. To remove it, delete the file, its import in Layout.astro and those styles.
Forms#
Every form is marked data-validate="<kind>" and names its success panel with data-success="<id>":
| Form | File | Fields |
|---|---|---|
Reservation (data-validate="reservation") |
src/pages/reservations.astro |
date, time, guests, seating (radio cards), occasion, name, email, phone, notes, newsletter |
Private dining enquiry (enquiry) |
src/pages/events.astro |
name, email, package, type, date, guests (8–60), message (20+ characters) |
Contact (contact) |
src/pages/contact.astro |
name, email, topic, message (10+ characters) |
Newsletter (newsletter) |
src/components/SiteFooter.astro |
email |
Validation uses the browser's constraints (required, type="email", pattern, min/max,
minlength) with custom messages from data attributes on each field: data-msg-required,
data-msg-pattern, data-msg-min and data-msg-max. Errors appear in the element with id
<field-id>-error, the field gets aria-invalid, and the first invalid field is focused. On success the
form is hidden and its success panel shown and focused; elements with data-fill="name" (or date,
time, guests, seating, topic, package) are filled with the submitted values (a word marked
data-count-of="guests" switches between its data-one and data-other text: "1 guest", "2 guests"),
and a [data-reset] button brings the form back.
Nothing is sent until you connect the forms. In section 5 of src/scripts/main.ts, find the comment
"Demo only: nothing is sent anywhere" in the submit handler and post the data first, for example to
Formspree:
const data = new FormData(form);
fetch("https://formspree.io/f/YOUR_ID", {
method: "POST",
body: data,
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error("Request failed");
// ...existing success code: fill data-fill elements, hide the form, show the success panel
})
.catch(() => alert("Sorry, your request could not be sent. Please call us."));Use form.dataset.validate to send each form type to a different endpoint. Netlify Forms also works:
add data-netlify="true" and a name to each <form>. To run your own server code instead, add an
Astro adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel) and an
API endpoint such as src/pages/api/reserve.ts with
export const prerender = false, then post to it. For real reservations you will usually want a booking
system (OpenTable, Resy, SevenRooms...); replace the reservation form with their widget or link and keep
the page around it.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>. The layout turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://bistro.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm buildor change the default in
astro.config.mjs. Canonical URLs have no.html(https://example.com/menu). -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
For a sitemap, add
@astrojs/sitemap(pnpm astro add sitemap); it uses the samesite. Add apublic/robots.txtthat points to it. -
Restaurants benefit from Restaurant structured data: consider a JSON-LD block with your address, phone, hours and menu URL in
index.astroandcontact.astro.
Accessibility#
- A "Skip to content" link to
#main, labelled landmarks and one<h1>per page. - Menu tabs use the tabs pattern with arrow-key support; filter buttons use
aria-pressed, and the filter result is announced through[data-filter-status]. - Dietary badges have visible codes plus screen-reader text ("Vegetarian").
- Form errors are linked with
aria-describedby, fields getaria-invalid, and success panels receive focus. - The mobile menu updates
aria-expandedand returns focus to its button on Esc. - Animations (reveals, the flicker on the fire art, the back-to-top scroll) respect
prefers-reduced-motion.
When editing, keep the screen-reader text on new dietary badges, give every new field a <label> and an
-error element, and keep one <h1> per page.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as menu.html rather than
menu/index.html (build.format: "file" in astro.config.mjs), and links point to /menu without a
trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /menu from menu.html automatically;
Vercel does with cleanUrls (below).
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/menuservesmenu.html. - GitHub Pages: use the official
Astro GitHub Action. For a project site
(
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/menutomenu.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/bistro pnpm build. It becomes Astro's base, and every link written with url()
includes it.
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and
provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the
TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7,
replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- The open/closed badge is an hour or more out. Set
TIMEZONEinsrc/data/hours.tsto your restaurant's IANA zone name (for example"Europe/London"). - No reservation times appear. Check
HOURSfor that weekday isn'tnull, and that opening hours minusLAST_SEATING_BEFORE_CLOSEleaves at least one slot. - A dish doesn't show up when filtering. It needs
data-dishand the right codes indata-diet(v,vg,gf). /menu#drinksdoesn't open the Drinks tab. The hash must match the tab'saria-controlsvalue.- The "Enquire" button doesn't preselect a package. Its
data-packagemust exactly match an<option>in thepackageselect. - Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and asset paths withurl()rather than plain"/..."strings. /menushows a 404 on my host. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Forms "send" but nothing arrives. Connect them as described in Forms.
License#
MIT. The code and all included SVG artwork are free for personal and commercial use; the download
includes the license text in LICENSE.txt. Cormorant Garamond and Manrope are licensed under the SIL
Open Font License.