Freeframe

Documentation

Wander

v1.0.0Updated Nov 12, 2026A sun-warmed, editorial site for a small-group tour company, with a trip finder, filters and a booking inquiry.

Overview#

Wander is a website for a small-group tour company, built for Saltwind Journeys, a fictional travel company by Flagstaff's rail depot that runs trips of twelve travelers or fewer in Morocco, Peru, Norway, the Aegean, Japan and Tanzania. It has a trip finder, a filterable tours page, tour pages with a day-by-day itinerary, a route map, departure dates and a live-priced booking inquiry, a destinations page with a seasonality chart, a journal with an article, an about page and a contact page. This is the Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site. This guide covers version 1.0.0. See it live at freeframe.dev/templates/wander.

Quick start#

You need Node.js 24 or newer and pnpm.

sh
pnpm install
pnpm dev

Open http://localhost:3000. Edits to files in app/ reload the page.

Script What it does
pnpm dev Development server
pnpm generate Static site in .output/public/
pnpm build Node.js server build in .output/ (pages still prerendered)
pnpm preview Serve the last build locally
pnpm lint ESLint
pnpm typecheck Type check with vue-tsc

Pages#

Route File Purpose and notable features
/ app/pages/index.vue Hero with postcard art, trip finder, destination ticker, featured tours (from the data), why travel with us, destinations grid, traveler postcards, journal teaser, newsletter
/tours app/pages/tours.vue Region, style, duration, month and budget filters with live counts, sort, filter chips, result count, empty state, URL parameters
/tour app/pages/tour.vue One tour (/tour?id=atlas-sahara): key facts, highlights, itinerary accordion, route map, included and excluded, departure dates table, booking widget, inquiry form, related tours, mobile price bar
/destinations app/pages/destinations.vue Seasonality table (regions × months), a section per region with highlights and a best-time chart
/journal app/pages/journal.vue Featured story, topic filter (/journal?topic=food), story cards
/article app/pages/article.vue Reading progress bar, table of contents, drop cap, pull quote, packing checklist, related tours
/about app/pages/about.vue Numbers, timeline, guides with illustrated portraits, responsible travel commitments, "where your money goes" bar
/contact app/pages/contact.vue Contact form with trip prefill (/contact?tour=kumano-kodo), office details, emergency line, FAQ (/contact#faq)
404 app/error.vue Compass illustration and links back into the site (generated as 404.html)

Project structure#

wander/
  nuxt.config.ts               base path, head defaults, SITE_URL, prerendered routes, Tailwind
  app/
    app.vue                    layout and page; pages restart when a link opens the page already showing
    router.options.ts          query strings written like the HTML version (?region=morocco,japan)
    error.vue                  404 page
    layouts/default.vue        skip link, announcement bar, header, footer, toast, back to top
    pages/                     one file per page (index.vue, tours.vue, tour.vue ...)
    components/
      SiteHeader.vue           header, main menu and mobile menu
      SiteFooter.vue           footer
      TourCard.vue             trip card used on every page
      SeasonChart.vue          one region's best-time chart
      NewsletterForm.vue       home page sign-up
      LandscapeArt.vue         illustrated landscapes
      RouteMap.vue, AvatarArt.vue, PostMark.vue, LogoMark.vue   other SVG artwork
      ReadProgress.vue, BackToTop.vue, SiteToast.vue
      ArrowRight.vue, StarRating.vue, TickIcon.vue ...          small icons
    composables/
      useValidatedForm.ts      shared form validation and success state
      useToast.ts              the toast message
      usePageSeo.ts            title, description, canonical and Open Graph tags
      useCurrentPage.ts        aria-current for the menus
    data/
      tours.ts                 regions, styles, durations, settings and the 12 tours
      content.ts               destinations copy, reviews, journal posts, guides, timeline, money split
      site.ts                  phone, email and the menu
    utils/
      tours.ts                 lookups, departures and prices, filters and URL state
      art.ts                   the drawings: landscapes (SKIES, LAYERS), route maps and avatars
      validate.ts              form field rules and messages
      ref.ts                   short references for confirmations
    plugins/
      reveal.client.ts         scroll reveals
      same-page.client.ts      links to the page already showing
      stable-build.server.ts   reproducible builds
    assets/css/main.css        Tailwind CSS: fonts, brand tokens (@theme) and component classes
    assets/fonts/              woff2 font files and their license (OFL.txt)
  public/img/                  favicon.svg, og-image.png (social image) and its og-image.svg source
  template.json                gallery metadata (used by freeframe.dev)
  README.md, DOCS.md, CHANGELOG.md

Customizing#

Brand and colors#

The design tokens are in the @theme block of app/assets/css/main.css. Every utility such as bg-clay or text-muted comes from them.

Token Default Used for
--color-paper #fbf5ea Page background
--color-postcard #fffaf1 Cards, form fields, stamps
--color-sand / --color-dune #f4e6cc / #ecd5ad Panels, chart cells
--color-line #e2cfae Borders and dashed rules
--color-ink #23180f Text, outlines and the offset card shadows
--color-muted #66523f Secondary text
--color-clay / --color-clay-dark #b0431d / #8c3214 Brand color: primary buttons, links, eyebrows
--color-sun / --color-sun-deep #f4a62a / #b86e00 Stamps, ticker, highlights / stars and icons
--color-sea / --color-deep #0e5a5f / #0a3b3f Dark bands, "best" season, footer
--color-lagoon, --color-coral #7cc4b8, #ef7b5a Decoration and charts
--color-foam #fff9ef Text on dark backgrounds

To change the brand color, edit the tokens:

css
@theme {
  --color-clay: #2f6b4f;
  --color-clay-dark: #214d39;
}

A few colors are written directly into SVG: the landscape palettes (SKIES and LAYERS) and the route map in app/utils/art.ts, the logo in app/components/LogoMark.vue and public/img/favicon.svg. The browser theme color (theme-color) is in nuxt.config.ts. Search for the hex value to change them too.

Typography#

Three Google Fonts, self-hosted from the woff2 files in app/assets/fonts/ (SIL Open Font License, see app/assets/fonts/OFL.txt) and declared with @font-face at the top of main.css: Caprasimo for headings (font-display), Outfit for body text (font-sans) and Kalam for handwritten postcard notes (font-hand, the .hand class). Each family has a latin and a latin-ext file, so names such as Hongū and Kaş render in the same font. The tokens name the families:

css
--font-sans: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: "Caprasimo", "Cooper Black", ui-serif, Georgia, serif;
--font-hand: "Kalam", "Segoe Print", "Bradley Hand", cursive;

To swap a font, put its woff2 files in app/assets/fonts/, change the @font-face rules and the names in the tokens. Caprasimo has a single weight, so headings use font-normal. The SVG text in the route map, postmark and 404 compass names Outfit or Kalam directly.

Content#

Trip data is in app/data/tours.ts; everything the tours page, tour pages, filters, booking widget and contact form show is built from it.

Export What it holds
REGIONS Regions: id, name, label, the scene and time of their artwork and a season array (12 values, 0 avoid to 3 best)
STYLES Trip styles (hiking, culture, wildlife, coast) and their labels
DURATIONS Duration filter buckets with min and max days
TOURS The trips, one object each (type Tour)
INCLUDED, EXCLUDED Lines shown on every tour page, after or before each tour's own includes / excludes
PRICE_MIN, PRICE_MAX Range of the budget slider on /tours
PEAK_SUPPLEMENT Added per person to departures in a region's best months
DEPOSIT Deposit per person shown in the booking widget
MAX_TRAVELLERS Most travelers in one inquiry
BOOKING_CUTOFF Departures sooner than this many days from today are not offered
DEPARTURE_DAYS Days of the month after which each departure's Saturday is chosen

To add a tour, add an object to TOURS:

ts
{
  id: "dolomites-huts", name: "Dolomites Hut to Hut", region: "aegean", country: "Italy", style: "hiking",
  days: 8, price: 3090, single: 380, rating: 4.8, reviews: 0, group: 12, level: "Moderate", badge: "New",
  months: [6, 7, 8, 9], scene: "mountains", time: "day",
  summary: "Seven days between mountain huts, with your bags sent ahead.",
  highlights: ["Tre Cime at sunrise", "Nights in family-run huts", "Via ferrata taster"],
  includes: ["Hut-to-hut luggage transfers"],
  excludes: [],
  stops: [[20, 70, "Cortina"], [45, 45, "Tre Cime"], [75, 30, "Sesto"]],
  itinerary: [
    { d: "1", t: "Cortina", x: "Meet the group and walk to the first hut.", stay: "Rifugio Lago" },
    { d: "2–7", t: "Hut to hut", x: "Six days on high trails.", stay: "Mountain huts" },
    { d: "8", t: "Departure", x: "Transfers to Venice airport.", stay: "" },
  ],
},
  • id is used in URLs (/tour?id=dolomites-huts, /contact?tour=dolomites-huts): lower case, no spaces.
  • region must be the id of a REGIONS entry and style the id of a STYLES entry. TypeScript checks both. The tour then appears under those filters, in the trip finder and in the contact form's trip list.
  • To add a region, add its id to the RegionId type and an entry to REGIONS; the filters, trip finder, seasonality table and home page grid pick it up. Add a matching entry to DESTINATIONS in app/data/content.ts for its section on /destinations (and the home grid's "best" months). The home page grid's tile sizes (TILES in app/pages/index.vue) are laid out for six regions.
  • price is the lowest price per person and single the own-room supplement per person, in US dollars.
  • months (1–12) are the months the tour departs; they drive the month filter and the departure dates.
  • stops are route map points: [x, y, name], with x and y as percentages of the map's width and height.
  • itinerary entries are accordion rows: d is the day or range, t the title, x the text and stay the overnight (leave empty to hide it).
  • featured: 1 to 4 puts the tour on the home page in that order and first in "Recommended" sort; badge adds a label on the card.

Other content is in app/data/content.ts: DESTINATIONS (region copy, highlights and "best" months), REVIEWS, POSTS and TOPICS (journal), HOME_POSTS, GUIDES, TIMELINE and MONEY_SPLIT. Page copy that appears once is written in the page files in app/pages/. The phone number, email and menu are in app/data/site.ts.

Images and artwork#

There are no photos. Every landscape is an SVG drawn by LandscapeArt (app/components/LandscapeArt.vue) from artInner() and the colors in app/utils/art.ts. Scenes are dunes, mountains, fjord (with northern lights at night), coast, fuji and savannah; times are dawn, day, dusk and night. Put it inside an .art-frame box, which sets the size; pass label to give it alt text (without it the art is decorative):

html
<div class="art-frame aspect-[4/3] rounded-[2rem]"><LandscapeArt scene="coast" time="dusk" label="Sunset over Milos" /></div>

The art is drawn at 3:2 and cropped to fit its frame (preserveAspectRatio="xMidYMid slice"), so any aspect ratio works. To use photos, put an <img class="h-full w-full object-cover"> inside the same .art-frame box instead (cards use 4:3, region sections 4:3, the tour hero 21:9 on desktop), with the file in public/img/ and the base path written out: :src="`${$config.app.baseURL}img/milos.jpg`". The .art-frame class adds a subtle paper grain over whatever is inside.

Other artwork: RouteMap draws the tour page map from stops, AvatarArt draws portraits from a "skin,hair,style,background" string (style is short, long, bun, curly or bald), PostMark is the round company stamp and LogoMark the logo.

The social image is public/img/og-image.png (1200×630, drawn from og-image.svg); replace it with a PNG of the same size. The favicon is public/img/favicon.svg.

The menu items are NAV in app/data/site.ts, used by the desktop menu and the mobile menu (which adds Home). SiteHeader marks the current page with aria-current="page" (useCurrentPage()). The footer columns are TRIPS and COMPANY in app/components/SiteFooter.vue. The announcement bar is in app/layouts/default.vue. To add a page, create app/pages/<route>.vue with its own <main id="main" tabindex="-1" class="flex-1 focus:outline-none">, call usePageSeo(), add the route to routes in nuxt.config.ts and add it to NAV.

Features#

Trip finder#

In app/pages/index.vue. The form #trip-finder on the home page has four selects (region, month, style, duration). On submit it opens /tours with only the chosen values as URL parameters, for example /tours?region=japan&month=4. Without JavaScript the form still submits to /tours with GET.

Tour filters#

app/pages/tours.vue with the logic in app/utils/tours.ts. Checkboxes named region, style and duration (several in one group combine with OR, groups combine with AND), a month select and a max budget slider. #sort orders by recommended, price, length or rating (SORTS). Every change updates the grid, the live count #result-count, the removable chips in #active-filters and the small count next to each option (how many trips you'd see if you checked it), and writes the state to the URL with history.replaceState:

/tours?region=morocco,japan&style=hiking&duration=medium&month=5&max=4000&sort=price-asc

readState() reads the URL: names and values are case-insensitive, lists can be comma-separated or repeated, and unknown values are ignored; writeState() builds the query string. When nothing matches, #tour-empty appears with a reset button (clearing keeps the sort). Removing a chip moves focus to the next chip or the result count. On phones the filters are behind #filters-toggle (aria-expanded), with a badge showing how many are active. When a month is chosen, card links carry it (/tour?id=…&month=5) so the tour page preselects a departure in that month.

The generated HTML shows all tours; the filters in the URL apply once the page has loaded. Links to the tours page with other filters (the footer's "Hiking & trekking", say) open it afresh at the top, as a page load would: see app/plugins/same-page.client.ts.

Tour page#

app/pages/tour.vue. ?id= picks the tour (case-insensitive); a missing or unknown id shows the first tour, which is also what the generated HTML shows until the page has loaded. The tab title and meta description follow the tour shown. The itinerary is a list of buttons with aria-expanded and aria-controls; the first day starts open and the "Expand all days" button opens or closes every day.

Departures and prices#

departures(tour, today) in app/utils/tours.ts works out the next eight departures: in each month listed in tour.months, a departure leaves on the first Saturday on or after each day in DEPARTURE_DAYS, skipping any sooner than BOOKING_CUTOFF days from today. They are worked out in the browser (with today's date), so a site generated months ago still shows upcoming dates. Months rated 3 ("best") in the region's season cost PEAK_SUPPLEMENT more and are marked "Peak". Places left are demo data from a stable hash of the tour and date, so they are the same on every visit: 0 shows "Full" (and is disabled in the widget), 1–3 "Only N left", and four or more travelers booked shows "Guaranteed". Replace departures() with your real dates if you have them; the table and widget only need objects of the type Departure ({ start, end, month, price, peak, left, guaranteed }).

Booking widget and inquiry#

The #booking-widget card has a departure select (#dep-select), a travelers stepper (1 to MAX_TRAVELLERS, never more than the places left) and a room choice. quote(tour, departure, travelers, room) returns the price per person, the own-room supplement, the total and the deposit, shown live in an aria-live list. "Choose" buttons in the dates table select a departure, scroll to the widget and show a toast (useToast()). The current choice is written to the summary above the inquiry form and to its hidden fields (tour, departure, travellers, room, total), so a connected form receives everything. On phones a fixed bar at the bottom shows the total and links to the widget. ?date=YYYY-MM-DD, ?month=5 and ?travellers=3 preselect choices.

Seasonality#

app/pages/destinations.vue renders the #season-table table (a row per region, a cell per month, each with a screen-reader label such as "April: Best") and SeasonChart (app/components/SeasonChart.vue) a 12-bar chart with the list of best months for one region, all from REGIONS[].season. The table scrolls sideways on small screens with the region names kept in view.

Journal filter#

app/pages/journal.vue. Buttons with data-topic-btn and aria-pressed show only the stories with that topic; #journal-status announces the result and the URL keeps ?topic=. To add a story, add an entry to POSTS in app/data/content.ts. Every story links to the one demo article, /article.

Scroll reveal, ticker, progress and back to top#

Elements with .reveal fade in when scrolled into view (app/plugins/reveal.client.ts, including content rendered later; everything above is revealed too). Don't put .reveal on an element whose class changes with a :class binding, or Vue drops the is-visible class the plugin adds. The home page ticker scrolls with CSS (.ticker) and pauses on hover; the hero stamp floats (.float). On /article, ReadProgress grows with how far you have read through [data-article]. Reveals, the ticker and the float stop for visitors who prefer reduced motion. To remove the back-to-top button, delete <BackToTop /> from app/layouts/default.vue and the .back-to-top styles in main.css.

Forms#

All three forms use useValidatedForm() (app/composables/useValidatedForm.ts) with the rules in app/utils/validate.ts: errors appear on blur and on submit, update as you type once shown, the first invalid field is focused, and each error is in the element <field id>-error, linked with aria-describedby. The fields are plain inputs; the form's submit, focusout, input and change events call the composable.

Attribute Rule
required Must not be empty (checkboxes must be checked; message from data-msg)
type="email" Must look like an email address
type="tel" Digits, spaces, +, (, ) and - only, 7 to 20 characters
minlength Minimum length
data-label Words used in the "Enter …" message
Form Where Success state
Newsletter (#newsletter-form) app/components/NewsletterForm.vue #newsletter-success
Inquiry (#enquiry-form) app/pages/tour.vue (/tour#enquire) #enquiry-success, repeating the booking summary
Contact (#contact-form) app/pages/contact.vue #contact-success

The forms don't send anything until you connect them, although their success messages read as if they had: the newsletter one asks the reader to look out for a confirmation email, the contact one promises a reply within one business day and the inquiry one a reply from a travel expert within one business day, as the pages already say. The contact and inquiry messages show a short reference (SJ- and four characters) made by makeRef() (app/utils/ref.ts) in onValid(); show your form service's own reference instead if it returns one. To collect submissions, post the form in onValid() in useValidatedForm.ts before showing the success state, for example to Formspree:

ts
async function onValid(el: HTMLFormElement) {
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(el),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return useToast().show("Something went wrong. Please email us.");
  // ...then the existing lines: reference.value = makeRef("SJ"), sent.value = first, el.reset()
}

The submit function calls onValid(el); it can stay as it is, since an async onValid still runs. Then check the success text matches what really happens. Every field has a name, so FormData carries all values; keep a name on any field you add. The contact form's trip list is built from TOURS, and /contact?tour=<id> preselects a trip.

If you deploy with pnpm build to a host that runs Node.js, you can post to your own server route instead, for example server/api/inquiry.post.ts:

ts
export default defineEventHandler(async (event) => {
  const data = await readFormData(event);
  // send an email, save to a database...
  return { ok: true, tour: data.get("tour") };
});

SEO and sharing#

Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title, description, canonical link and Open Graph and Twitter tags. Canonical URLs and the social image URL are absolute: set your domain with SITE_URL when building.

sh
SITE_URL=https://www.example.com pnpm generate

The tour page's generated HTML describes the first tour; the browser updates the title and description for the tour in ?id=. If search visibility for each tour matters, generate one route per tour instead (for example app/pages/tours/[id].vue, with every TOURS id added to routes in nuxt.config.ts). Consider adding a sitemap, a public/robots.txt and TouristTrip structured data (JSON-LD, with useHead()). The 404 page is noindex.

Accessibility#

  • Skip link, landmarks, one h1 per page and a logical heading order; breadcrumbs use aria-current.
  • aria-current="page" in the menus; the mobile menu has aria-expanded, closes with Escape and returns focus to its button.
  • The itinerary and FAQ are buttons and <details>; filter options and room choices are real checkboxes and radios.
  • Live regions for the result count, the journal status, the booking total, the toast and form status; errors are linked to their fields and the first one is focused.
  • The seasonality chart is a real table with text for every cell; the region charts and route map have text alternatives; decorative art is hidden from screen readers.
  • Visible focus outlines, AA contrast, and prefers-reduced-motion turns off reveals, the ticker, the floating stamp and smooth scrolling.

When editing, keep one h1 per page, give new inputs a <label> and a name, pass label to art that carries meaning, and keep icon-only buttons' aria-label.

Building and deploying#

pnpm generate writes a static site to .output/public/, one HTML file per page (tours.html, tour.html and so on, plus 404.html). Upload that folder to any static host: Netlify, Cloudflare Pages, Vercel, GitHub Pages or your own server. Most hosts serve tours.html at /tours; on others, add a rewrite from extensionless paths to .html. The pages are listed in routes in nuxt.config.ts; add new pages there.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building (the live demo uses /demos/nuxt/wander):

sh
DEMO_BASE_PATH=/trips pnpm generate

NuxtLink adds the base path to links; files in public/ need it written out ($config.app.baseURL). pnpm build makes a Node.js server build in .output/ instead (run it with node .output/server/index.mjs), which you need for server routes.

Troubleshooting#

  • A new tour doesn't appear under a filter. Its region and style must match an id in REGIONS and STYLES, and its months must be numbers from 1 to 12.
  • /tour?id=… shows the wrong tour. Unknown ids fall back to the first tour; check the id matches TOURS.
  • A tour shows no departures. Departures are only generated for the next 15 months and never sooner than BOOKING_CUTOFF days away; check the tour's months.
  • Prices look higher than "from". Departures in a region's best months add PEAK_SUPPLEMENT; set it to 0 to turn peak pricing off.
  • The inquiry shows its confirmation but nothing arrives. The forms send nothing until you connect them in onValid() (see Forms).
  • A new page shows the 404 page after pnpm generate. Add its route to routes in nuxt.config.ts.
  • A character shows in a fallback font. The bundled fonts cover Latin and Latin Extended. For other scripts, add the matching woff2 file to app/assets/fonts/ and an @font-face rule to main.css.
  • pnpm lint fails with a missing .nuxt/eslint.config.mjs. Run pnpm install (or pnpm typecheck) once first; nuxt prepare writes it.

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. Caprasimo, Outfit and Kalam are included under the SIL Open Font License. Saltwind Journeys, its guides, travelers and contact details are fictional.

Search documentation

Search the guides and every template's documentation