Freeframe

Documentation

Bistro

v1.0.1Updated Nov 11, 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 in Annapolis, Maryland, with tabbed menus and a diet filter, a reservation form whose time slots follow the opening hours, private dining packages with an inquiry form, and a live "Open now" badge. This version is built with Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, and is generated as a fully static site. This guide covers version 1.0.1.

Live demo: freeframe.dev/templates/bistro

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install      # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev          # http://localhost:3000

Open http://localhost:3000. Edits to app/ reload instantly.

Script What it does
pnpm dev Development server
pnpm generate Static site in .output/public/ (nuxt generate): every page prerendered to HTML
pnpm build A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server
pnpm preview Serve the last build locally
pnpm lint ESLint (@nuxt/eslint)
pnpm typecheck nuxt prepare and vue-tsc
pnpm build:demo Generate for freeframe.dev under /demos/nuxt/bistro into demo/

Pages#

Route File Purpose and notable sections
/ app/pages/index.vue Fire hero with the open-now strip, intro and figures, signature dishes, chef quote, reviews, opening hours and a booking call to action
/menu app/pages/menu.vue Lunch / Dinner / Drinks / Desserts tabs, vegetarian / vegan / gluten-free filter, allergen notes (#allergens)
/about app/pages/about.vue Timeline, values, team (#team), suppliers
/reservations app/pages/reservations.vue Reservation form with day-dependent time slots, opening hours and policies sidebar
/events app/pages/events.vue Three private dining packages, upcoming events (#upcoming), inquiry form (#enquire)
/contact app/pages/contact.vue Contact cards, opening hours with today highlighted, illustrated map, contact form, FAQ
404 app/error.vue Error page with links home, to the menu and to reservations (generated as 404.html)

Every page has the newsletter form in the footer.

Project structure#

bistro/
├── app/
│   ├── app.vue                 <NuxtLayout><NuxtPage /></NuxtLayout>
│   ├── layouts/default.vue     skip link, header, the page, footer, back-to-top button
│   ├── pages/                  one .vue file per page (index, menu, about, reservations, events, contact)
│   ├── error.vue               404 page
│   ├── components/
│   │   ├── SiteHeader.vue      fixed header and mobile menu
│   │   ├── SiteFooter.vue      footer with the newsletter form and copyright year
│   │   ├── OpenStatus.vue      "Open now" badge
│   │   ├── HoursTable.vue      printed opening hours with today highlighted
│   │   ├── MenuBrowser.vue     menu tabs and diet filter
│   │   ├── ValidatedForm.vue   validation, success message and reset for every form
│   │   ├── FormField.vue       labeled form field with its error message
│   │   ├── ReservationWhen.vue date, time slots and guests of the reservation form
│   │   ├── CountedTextarea.vue textarea with a character counter
│   │   ├── FormSuccessTick.vue check mark of the success messages
│   │   ├── PageHeader.vue      dark header of the inner pages
│   │   ├── DietBadge.vue       V / VG / GF badges
│   │   ├── TeamPortrait.vue    illustrated team portraits
│   │   ├── AnnapolisMap.vue    illustrated map on /contact
│   │   ├── LineIcon.vue, StarIcon.vue, SocialIcon.vue   SVG icons
│   │   └── BackToTop.vue       back-to-top button
│   ├── composables/
│   │   ├── useBusinessNow.ts   shared "now" in the restaurant's time zone
│   │   ├── useFormContext.ts   what a form shares with its fields
│   │   ├── usePageSeo.ts       title, description, canonical and Open Graph tags
│   │   ├── useCurrentPage.ts   aria-current for menu links
│   │   └── useAsset.ts         prefixes public files with the base path
│   ├── data/
│   │   ├── site.ts             name, address, phone, email, navigation, footer links, socials
│   │   ├── hours.ts            opening hours, time zone, slot settings, printed hours table
│   │   ├── menu.ts             the four menus and the diet codes
│   │   ├── home.ts             signature dishes, figures, reviews
│   │   ├── about.ts            timeline, values, team, suppliers
│   │   ├── reservations.ts     seating choices and occasions
│   │   ├── events.ts           packages, inquiry options, upcoming events
│   │   ├── contact.ts          contact topics and FAQ
│   │   └── icons.ts            line icon shapes
│   ├── utils/
│   │   ├── hours.ts            businessNow(), openStatus(), timeSlots(), date helpers
│   │   └── forms.ts            errorFor() validation messages, makeRef()
│   ├── plugins/
│   │   ├── reveal.client.ts    scroll reveals
│   │   ├── keep-hash.client.ts keeps the URL hash exactly as opened (menu tabs)
│   │   └── stable-build.server.ts  reproducible builds
│   ├── router.options.ts       query strings written like the HTML version
│   └── assets/
│       ├── css/main.css        Tailwind entry: fonts, brand tokens (@theme) and component classes
│       └── fonts/              Cormorant Garamond and Manrope (woff2) and OFL.txt
├── public/img/                 SVG dishes, chef, fire, wine, favicon, og-image.svg and og-image.png
├── nuxt.config.ts              base path, head defaults, SITE_URL, prerendered routes, Tailwind
├── eslint.config.mjs, tsconfig.json
├── package.json, pnpm-lock.yaml, pnpm-workspace.yaml
├── template.json               template metadata used by freeframe.dev
└── README.md, CHANGELOG.md, DOCS.md

Components, composables and utils are auto-imported by Nuxt; data files are imported by path (import { MENUS } from "~/data/menu").

Customizing#

Brand and colors#

The palette is a set of tokens in the @theme block of app/assets/css/main.css:

Token Used for
--color-char, --color-char-2, --color-char-3 Dark charcoal backgrounds (header, page headers, dark bands, footer)
--color-cream, --color-parchment, --color-sand Light backgrounds and borders
--color-ember, --color-ember-dark, --color-ember-light Primary accent: buttons, links, prices
--color-olive, --color-olive-light Secondary accent: menu group titles, vegan badges
--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;
}

Also update the theme-color meta tag in app.head in nuxt.config.ts (the browser bar color) and the hard-coded colors of the illustrations if you want them to match.

Typography#

Headings use Cormorant Garamond (--font-display, weights 500 to 700 and italic 500 to 600) and text uses Manrope (--font-sans, variable, 400 to 700). Both are self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css: the same Latin files Google Fonts serves, so text sets exactly like the HTML version (licenses in app/assets/fonts/OFL.txt). The map on /contact names the same families in its SVG font-family attributes.

To swap a font, put its .woff2 in app/assets/fonts/, change the @font-face rule and the family name in --font-display or --font-sans:

css
@font-face {
  font-family: "Lora";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../fonts/Lora-latin.woff2") format("woff2");
}

Content#

Text that appears once lives in the page files (app/pages/*.vue); everything that repeats or that you are likely to edit is in typed files in app/data/:

File What it holds
site.ts SITE (name, address, phones, emails, gratuity note), NAV, FOOTER_LINKS, SOCIAL
hours.ts HOURS, TIMEZONE, LAST_SEATING_BEFORE_CLOSE, SLOT_STEP, BOOKING_MONTHS_AHEAD, HOURS_TABLE
menu.ts MENUS (tabs, groups and dishes) and DIETS (badge codes)
home.ts SIGNATURE_DISHES, FACTS, REVIEWS
about.ts TIMELINE, VALUES, TEAM, SUPPLIERS
reservations.ts SEATING (the "Where would you like to sit?" cards), OCCASIONS
events.ts PACKAGES, SPACE_OPTIONS, EVENT_TYPES, UPCOMING
contact.ts TOPICS, FAQS

Opening hours are set once in app/data/hours.ts, in minutes after midnight per weekday (0 = Sunday):

ts
export const HOURS: Record<number, DayHours | null> = {
  0: { open: 12 * 60, close: 16 * 60 },
  1: null, // closed on Mondays
  2: { open: 17 * 60, close: 22 * 60 },
  // ...
};
export const TIMEZONE = "America/New_York";

Set TIMEZONE to your restaurant's IANA zone (for example "America/Chicago"). HOURS drives the "Open now" badges, the highlighted row in the hours tables and the reservation time slots. The printed table is HOURS_TABLE in the same file: keep it in step with HOURS. Each row lists the weekdays it covers so today's row can be highlighted:

ts
{ label: "Tuesday – Thursday", days: [2, 3, 4], hours: "5pm – 10pm" },

Menu dishes are in MENUS in app/data/menu.ts. Each menu is a tab (id is also its URL hash) with groups of dishes. To add a dish, add it to a group:

ts
{ name: "Grilled leeks", description: "Romesco, toasted hazelnuts", price: 14, diet: ["vg", "gf"] },

diet lists the codes "v" (vegetarian), "vg" (vegan) and "gf" (gluten-free); the badges and the filter follow automatically. A new menu (tab) is a new entry in MENUS.

Private dining packages are PACKAGES in app/data/events.ts. Each package's name is also an option of the inquiry form's "Space" select (SPACE_OPTIONS is built from them), so the card's Inquire button can preselect it. Upcoming events (UPCOMING) show their dates as written: update them as events pass.

Images and artwork#

All artwork in public/img/ is original SVG: dishes (dish-*.svg), chef.svg, hero-fire.svg, wine.svg and favicon.svg (also the logo in the header and footer). The team portraits are drawn in app/components/TeamPortrait.vue and the map in app/components/AnnapolisMap.vue. To use photos, put them in public/img/ and change the paths in app/data/*.ts or the pages; keep similar proportions (dish cards are 4:5 and square, package images 16:10, the hero is wide) and write descriptive alt text. Reference public files through useAsset(), without a leading slash, so they work under a base path:

<script setup lang="ts">
const asset = useAsset();
</script>

<template>
  <img :src="asset('img/dish-lamb.svg')" alt="" width="800" height="800">
</template>

The social image is public/img/og-image.png (1200×630, exported from og-image.svg); the favicon is public/img/favicon.svg, linked in app.head in nuxt.config.ts.

The header is app/components/SiteHeader.vue and the footer app/components/SiteFooter.vue; both are rendered once in app/layouts/default.vue. Menu items come from NAV in app/data/site.ts (the mobile menu adds Home first), and the footer's "Explore" links from FOOTER_LINKS. To add a page, create app/pages/<route>.vue (with its root <main id="main"> and a usePageSeo() call), add the route to routes in nuxt.config.ts, then add it to NAV or FOOTER_LINKS. The header marks the current page with aria-current="page" (useCurrentPage()).

Features#

Open now badge and today's hours#

<OpenStatus /> shows the live status from HOURS, for example "Open now · until 10pm" or "Closed · opens tomorrow at 5pm", and sets data-state="open" or "closed" for the colored dot. <HoursTable /> adds the is-today class to today's row. Both read "now" in TIMEZONE (businessNow() in app/utils/hours.ts) through useBusinessNow(), so every visitor sees the restaurant's local time, refreshed every minute. The generated HTML shows "See opening hours" until the page loads, because a static page can't know the current time. Pass placeholder to change that text (the mobile menu uses "Opening hours").

app/components/MenuBrowser.vue renders MENUS with the WAI-ARIA tabs pattern: role="tab" buttons with aria-selected and aria-controls, a roving tabindex, Left/Right arrows (wrapping) and Home/End. Clicking a tab updates the URL hash, and /menu#drinks opens the Drinks tab (any other hash opens the first tab). Links such as "See the full menu" on the home page use this. app/plugins/keep-hash.client.ts keeps the hash exactly as it was opened, so only an exact match selects a tab.

Diet filter#

The filter buttons (data-diet-filter: all, v, vg, gf, with aria-pressed) show only matching dishes in every tab; vegan dishes also count as vegetarian. Groups with no matches are hidden, a message appears if a whole tab is empty, and a polite live region announces the result ("Showing 33 matching dishes and drinks.").

Reservation time slots#

app/components/ReservationWhen.vue renders the date, time and guests fields of the reservation form. The time options come from timeSlots() in app/utils/hours.ts:

  • slots every SLOT_STEP minutes from opening until LAST_SEATING_BEFORE_CLOSE minutes before closing;
  • for today, only slots at least an hour from now ("No times left" when the day is over);
  • closed days (null in HOURS) show "Closed that day" and the date field explains why;
  • the date can't be in the past or more than BOOKING_MONTHS_AHEAD months ahead (the field's min and max are set from today in TIMEZONE);
  • choosing a new date starts the time list again, and a note shows that day's kitchen hours and last seating;
  • "9 or more" guests shows a note pointing to the private dining inquiry.

Character counter#

app/components/CountedTextarea.vue shows "12 / 300" under the special requests field, announced politely to screen readers, and goes back to 0 when the form is reset.

Package inquiries#

The "Inquire" buttons on /events jump to #enquire and preselect their package in the "Space" select (preselect() in app/pages/events.vue).

Header and mobile menu#

The fixed header (data-header) gets a dark background once you scroll or open the menu. Below desktop width the button with data-nav-toggle opens #mobile-nav, moves focus to the first link, locks page scrolling, 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 (app/plugins/reveal.client.ts, styles near the end of main.css); stagger them with style="--d:100ms". Content is only hidden while JavaScript runs, and reduced-motion settings show everything immediately. The plugin adds the is-visible class itself, so don't put data-reveal on an element that also has a :class binding (Vue would drop the class on its next update): wrap it instead.

Back to top button#

app/components/BackToTop.vue adds a button in the bottom-right corner that appears after about one screen of scrolling, scrolls to the top (instantly with reduced motion) and moves focus to the main content. Add data-back-to-top-avoid to anything you pin to the bottom of the screen, or data-back-to-top-hide to a sticky element it must never cover. To remove it, delete <BackToTop /> from app/layouts/default.vue and the .back-to-top rules in main.css.

Forms#

All forms use <ValidatedForm> from app/components/ValidatedForm.vue:

Form Where Fields
Reservation (data-validate="reservation") app/pages/reservations.vue date, time, guests, seating (radio cards), name, email, phone, occasion, notes, newsletter
Private dining inquiry (enquiry) app/pages/events.vue name, email, package, type, date, guests (8–60), message (20+ characters)
Contact (contact) app/pages/contact.vue name, email, topic, message (10+ characters)
Newsletter (newsletter) app/components/SiteFooter.vue email

Validation uses the browser's constraints (required, type="email", pattern, min/max, minlength) with messages from data attributes on each field: data-msg-required, data-msg-pattern, data-msg-min and data-msg-max (see errorFor() in app/utils/forms.ts). Fields are checked when they lose focus (once filled), again as you type once they are marked invalid, and all together on submit; errors appear in the element with id <field id>-error, and the first invalid field is focused. <FormField> renders the label and that error element, and gives its slot invalid for the control's aria-invalid:

<FormField id="res-name" v-slot="{ invalid }" label="Full name" required>
  <input id="res-name" name="name" type="text" class="input" required aria-describedby="res-name-error" :aria-invalid="invalid" data-msg-required="Please tell us your name.">
</FormField>

On success the form is replaced by its #success slot, which gets fill, count and reset: fill("name") repeats a submitted value (first name only; dates written out), count("guests", "guest", "guests") agrees with the number, and a button calling reset brings the form back, empty. fill("ref") shows a short reference made in the browser (OE- and four characters, from makeRef() in app/utils/forms.ts). If your booking system or form service returns its own reference, show that instead. The success messages say what happens next ("We reply within a day", "within two business days", "We'll email to confirm your table"); change them to match how you actually handle requests.

Nothing is sent until you connect the forms. In onSubmit() in app/components/ValidatedForm.vue, post the data before showing the success message, for example to Formspree:

ts
async function onSubmit() {
  // ...validation as before...
  const body = new FormData(form.value!);
  const res = await fetch("https://formspree.io/f/YOUR_ID", {
    method: "POST",
    body,
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return alert("Sorry, your request could not be sent. Please call us.");
  const data: Record<string, string> = {};
  body.forEach((value, key) => (data[key] = String(value)));
  data.ref = makeRef("OE"); // or the reference your service returns
  values.value = data;
  await nextTick();
  success.value?.querySelector<HTMLElement>('[tabindex="-1"]')?.focus();
}

Use the kind prop to send each form to a different endpoint, and check that the success messages still describe what happens next. With pnpm build (a Node.js server, see Building and deploying) you can post to your own server route instead, for example server/api/reservations.post.ts. 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 calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title, description, a canonical link, Open Graph tags (with img/og-image.png) and a large Twitter card. The 404 page passes noindex: true.

  • Canonical and social URLs are absolute: set your domain when building (the default is https://bistro.example.com):

    sh
    SITE_URL=https://www.your-restaurant.example pnpm generate
  • Add a sitemap.xml and robots.txt to public/ (or use a sitemap module) before launch.

  • Consider Restaurant structured data as JSON-LD with your address, phone, hours and menu URL on the home and contact pages, added with useHead({ script: [...] }).

Accessibility#

Built in:

  • a "Skip to content" link, labeled landmarks and one h1 per page;
  • visible focus styles (:focus-visible in main.css);
  • menu tabs with the ARIA tabs pattern and arrow keys; filter buttons with aria-pressed and a live result count;
  • diet badges with visible codes plus screen-reader text ("Vegetarian");
  • form errors linked with aria-describedby, aria-invalid on fields, focus on the first error and on the success heading;
  • a mobile menu with aria-expanded, Esc to close and focus management;
  • a labeled map illustration (role="img" with a title and description);
  • reveals, the flickering glow and smooth scrolling respect prefers-reduced-motion.

When editing, keep one h1 per page, label new inputs (use <FormField>), and keep the screen-reader text of the badges and meaningful alt text on images.

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (menu.html, linked as /menu), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /menu to menu.html on their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.

To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:

sh
DEMO_BASE_PATH=/bistro pnpm generate

pnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.

pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them). typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load.

Troubleshooting#

Problem Fix
The open/closed badge is an hour or more out Set TIMEZONE in app/data/hours.ts to your restaurant's IANA zone name.
No reservation times appear Check that HOURS for that weekday isn't null and that opening time to closing minus LAST_SEATING_BEFORE_CLOSE leaves at least one slot.
The highlighted "Today" row is wrong The days of the rows in HOURS_TABLE must match the weekdays (0 = Sunday).
/menu#drinks doesn't open the Drinks tab The hash must equal the menu's id in MENUS.
The Inquire button doesn't preselect a package The package name must match an option of the "Space" select; SPACE_OPTIONS does this for you unless you edit the options by hand.
Images are missing when the site lives in a sub-folder Build with DEMO_BASE_PATH=/your-folder and pass image paths through useAsset(), without a leading slash.
Social previews show no image Set SITE_URL to your live domain when building, so og:image is an absolute URL.
A new page is missing from the generated site Add its route to routes in nuxt.config.ts.
pnpm lint can't find .nuxt/eslint.config.mjs Run pnpm install or npx nuxt prepare first.
Forms "send" but nothing arrives Connect them as described in Forms.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Cormorant Garamond and Manrope are licensed under the SIL Open Font License (app/assets/fonts/OFL.txt). Olive & Ember, its team and its guests are fictional.

Search documentation

Search the guides and every template's documentation