Freeframe

Documentation

Bistro

v1.0.3Updated Oct 9, 2026A warm, high-end website for a restaurant, café or wine bar.

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).

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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.json

Pages 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:

css
@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:

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:

html
<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:

html
<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:

html
<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.

  • src/components/SiteHeader.astro holds the desktop navigation and the mobile menu (#mobile-nav). Each link gets aria-current="page" when the page's current prop matches it (current="menu" marks /menu).
  • src/components/SiteFooter.astro is the footer, including the newsletter form.

To add a page, create src/pages/wine.astro:

html
---
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.

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_STEP minutes from opening until LAST_SEATING_BEFORE_CLOSE minutes before closing.
  • For today (in TIMEZONE), only slots at least an hour from now are offered.
  • Closed days (null in HOURS) show "Closed that day" and the date field explains why.
  • The date can't be before today in TIMEZONE (data-min-today sets the field's min) or more than two months ahead (data-max-months="2" sets its max).
  • 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:

ts
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 title and description to <Layout>. The layout turns them into <title>, the meta description, og:title, og:description and a summary_large_image Twitter card.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://bistro.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm build

    or 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 same site. Add a public/robots.txt that points to it.

  • Restaurants benefit from Restaurant structured data: consider a JSON-LD block with your address, phone, hours and menu URL in index.astro and contact.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 get aria-invalid, and success panels receive focus.
  • The mobile menu updates aria-expanded and 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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /menu serves menu.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /menu to menu.html, change build.format to "directory" in astro.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 TIMEZONE in src/data/hours.ts to your restaurant's IANA zone name (for example "Europe/London").
  • No reservation times appear. Check HOURS for that weekday isn't null, and that opening hours minus LAST_SEATING_BEFORE_CLOSE leaves at least one slot.
  • A dish doesn't show up when filtering. It needs data-dish and the right codes in data-diet (v, vg, gf).
  • /menu#drinks doesn't open the Drinks tab. The hash must match the tab's aria-controls value.
  • The "Enquire" button doesn't preselect a package. Its data-package must exactly match an <option> in the package select.
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, and write internal links and asset paths with url() rather than plain "/..." strings.
  • /menu shows a 404 on my host. Set build.format: "directory" in astro.config.mjs and 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.

Search documentation

Search the guides and every template's documentation