Freeframe

Documentation

Wander

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

Overview#

Wander is a website for a small-group tour company, built for Saltwind Journeys, a fictional travel company on Bristol's harbourside that runs trips of twelve travellers 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 enquiry, a destinations page with a seasonality chart, a journal with an article, an about page and a contact page. This is the Astro version: Astro 7, TypeScript and Tailwind CSS v4, with no UI framework, building to plain static files. It matches the HTML version page for page. This guide covers version 1.0.0. See it live at freeframe.dev/templates/wander.

Quick start#

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

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

Other scripts:

Script What it does
pnpm build Static site in dist/
pnpm preview Serves dist/ locally
pnpm check astro check: type-checks .astro files and TypeScript
pnpm typecheck astro sync && tsc --noEmit
pnpm lint ESLint with typescript-eslint and eslint-plugin-astro

Pages#

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

Project structure#

wander/
  astro.config.mjs            site URL, base path, file-style output (tours.html linked as /tours)
  src/
    pages/*.astro             one file per page (see Pages)
    layouts/Layout.astro      <html>, <head> metadata, fonts, skip link, header, footer, toast, scripts
    components/
      SiteHeader.astro        announcement bar, header, desktop nav and mobile menu
      SiteFooter.astro        footer columns and bottom row
      LogoMark.astro          the round sun-over-the-sea logo
    data/
      tours.ts                regions, trip styles, durations, tours and pricing settings
      nav.ts                  header, mobile menu and footer links
    lib/site.ts               site name, theme colour and the url() helper
    scripts/
      main.ts                 every page's features (filters, tour page, forms, menu, reveals)
      art.ts                  SVG landscapes, route maps and avatars
      back-to-top.ts          the back-to-top button
    styles/global.css         Tailwind CSS: brand tokens and component classes
  public/img/                 favicon.svg, og-image.svg (source), og-image.png (social image)
  template.json               gallery metadata for freeframe.dev
  README.md, DOCS.md, CHANGELOG.md

Customising#

Brand and colours#

The design tokens are in the @theme block of src/styles/global.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 colour: 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 colour, edit the tokens (pnpm dev picks the change up straight away):

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

A few colours are written directly into SVG: the landscape palettes (SKIES and LAYERS) and route map pins in src/scripts/art.ts, the logo in src/components/LogoMark.astro and public/img/favicon.svg. The browser theme colour is site.themeColor in src/lib/site.ts. Search for the hex value to change them too.

Typography#

Three Google Fonts, loaded by the <link> in src/layouts/Layout.astro: Caprasimo for headings (font-display), Outfit for body text (font-sans) and Kalam for handwritten postcard notes (font-hand, the .hand class). To swap them, change the family= parts of that link and the names in src/styles/global.css:

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;

Caprasimo has a single weight, so headings use font-normal. The route map labels use Outfit, set in routeMap() in src/scripts/art.ts.

Content#

Page copy is in the .astro files. Everything about the regions and tours lives in src/data/tours.ts, which the pages read when the site is built and the scripts read in the browser:

Export What it holds
REGIONS Regions: id, name, label, the scene and time of their artwork, a season array, and the destinations page copy (eyebrow, intro, highlights) and home page best months
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 (see below)
INCLUDED, EXCLUDED Lines shown on every tour page, before or after each tour's own includes / excludes
PRICE_MIN, PRICE_MAX Range of the budget slider on the tours page
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 travellers in one enquiry
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

The Tour and Region interfaces at the top of the file describe every field. To add a tour, add an object to TOURS:

ts
{
  id: "dolomites-huts", name: "Dolomites Hut to Hut", region: "alps", country: "Italy", style: "hiking",
  days: 8, price: 2290, single: 280, 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 (the example assumes you added an alps region) and style the id of a STYLES entry. The tour then appears under those filters automatically. A new region gets its filter checkbox, trip finder option, destinations section, seasonality row and home page tile from REGIONS too; the home page grid is laid out for six regions (TILE_LAYOUT in src/pages/index.astro), and extra regions get a square tile.
  • price is the lowest price per person and single the own-room supplement per person, in pounds.
  • 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.

src/pages/tour.astro contains Atlas to the Sahara as default text; the script replaces it with the tour from the URL. The journal stories, guides, testimonials and FAQ are plain markup in their pages.

Images and artwork#

There are no photos. Every landscape is an SVG drawn by art(scene, time, label) in src/scripts/art.ts. Scenes are dunes, mountains, fjord (with northern lights at night), coast, fuji and savannah; times are dawn, day, dusk and night. paintArt() fills any element with data-art="<scene>" and data-time="<time>" (add data-label to give it alt text; without it the art is decorative):

html
<div class="art-frame aspect-[4/3] rounded-[2rem]" data-art="coast" data-time="dusk" data-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 instead, put them in public/img/ and replace the element with an <img src={url("/img/milos.jpg")} …> inside the same frame (cards use 4:3, region sections 4:3, the tour hero 21:9 on desktop), or return an <img> from art() for the cards the scripts draw. The .art-frame class adds a subtle paper grain over whatever is inside.

Other generated art: routeMap(tour) draws the tour page map from stops, and avatar(spec) draws the guide and traveller portraits for elements with data-avatar="skin,hair,style,background" (style is short, long, bun, curly or bald). Replace either with images if you have them.

The social image is public/img/og-image.png (1200×630), rendered from og-image.svg. Edit the SVG and export a PNG of the same size; social sites don't display SVG. The favicon is public/img/favicon.svg.

The header, mobile menu and footer are components, so each change applies to every page:

  • src/data/nav.ts holds the links: mainNav (desktop header), mobileNav (Home plus the same pages), footerTrips, footerSaltwind and footerLegal. Add or remove an entry there.
  • A link's key matches the current prop a page passes to <Layout> (current="tours"); that link gets aria-current="page", which the styles highlight.
  • The announcement bar ("Next season's departures are open") is the first <p> in src/components/SiteHeader.astro.

Every internal link goes through url() from src/lib/site.ts, which adds the base path when the site is served from a sub-folder. Write paths without .html: url("/tours?style=hiking"), url("/about#guides").

To add a page, create src/pages/<name>.astro with <Layout title="…" description="…" page="<name>">, then add it to nav.ts.

Features#

All browser code is in src/scripts/main.ts, bundled by Astro and loaded once from the layout. Each feature starts only when its markup is on the page.

Trip finder#

initFinder(). The form #trip-finder on the home page has four selects (region, month, style, duration), with options rendered from REGIONS, MONTHS, STYLES and DURATIONS. On submit it opens the tours page 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#

initTours() on the tours page. The form #tour-filters has 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 re-renders #tour-grid, updates 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 ticked it), and writes the state to the URL with history.replaceState:

/tours?region=morocco,japan&style=hiking&duration=medium&month=5&max=3000&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. When nothing matches, #tour-empty appears with a reset button. 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.

Tour page#

initTourPage(). ?id= picks the tour (case-insensitive); a missing or unknown id shows the first tour. The page title and meta description, the elements marked data-tour="name" (and summary, days, price, rating and so on), the hero art, highlights, included and excluded lists, map and related tours are filled from the data. Tour cards are drawn by tourCard().

The itinerary is a list of buttons with aria-expanded and aria-controls; the first day starts open and [data-itinerary-all] expands or collapses every day.

Departures and prices#

departures(tour, limit) 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. 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 travellers booked shows "Guaranteed". Replace departures() with your real dates (for example a fixed array per tour) if you have them; the table and widget only need objects of the Departure shape { start, end, month, price, peak, left, guaranteed }.

Booking widget and enquiry#

The #booking-widget card has a departure select (#dep-select), a travellers stepper (1 to MAX_TRAVELLERS, never more than the places left) and a room choice. quote(tour, departure, travellers, 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. The current choice is written to the summary above the enquiry 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 (the tour page passes bodyClass="pb-20 lg:pb-0" to <Layout> to leave room for it). ?date=YYYY-MM-DD, ?month=5 and ?travellers=3 preselect choices.

Seasonality#

initSeasons() on the destinations page builds the #season-table table (a row per region, a cell per month, each with a screen-reader label such as "April: Best") and fills each [data-season="<region id>"] element with a 12-bar chart and the list of best months, all from REGIONS[].season. Levels are 0 (avoid), 1 (possible), 2 (good) and 3 (best). The table scrolls sideways on small screens with the region names kept in view.

Journal filter#

initJournal(). Buttons with data-topic-btn (and aria-pressed) show only the story cards whose data-topic matches; #journal-status announces the result and the URL keeps ?topic=. To add a story, copy an <article data-topic="…"> card in src/pages/journal.astro.

Scroll reveal, ticker, progress and back to top#

Elements with .reveal fade in when scrolled into view (everything above is revealed too). The layout adds the js class to <html> in the <head>, and reveals only hide content under .js, so nothing stays hidden without JavaScript. The home page ticker scrolls with CSS (.ticker) and pauses on hover; the hero stamp floats (.float). On the article page, [data-read-progress] grows with how far you have read through [data-article]. Reveals, the ticker and the float stop for visitors who prefer reduced motion. The back-to-top button is created by src/scripts/back-to-top.ts; delete that file, its import in src/layouts/Layout.astro and the .back-to-top styles to remove it.

Forms#

All forms with data-validate are checked by initForms() in src/scripts/main.ts: errors appear on blur and on submit, the first invalid field is focused, and each error is in the element <field id>-error, linked with aria-describedby.

Attribute Rule
required Must not be empty (checkboxes must be ticked; 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) src/pages/index.astro #newsletter-success
Enquiry (#enquiry-form) src/pages/tour.astro (#enquire) #enquiry-success, repeating the booking summary
Contact (#contact-form) src/pages/contact.astro #contact-success

The form's data-success attribute names its success box. Demo forms don't send anything, and the success messages say so. To collect submissions, post the form in onValid() in section 11 of src/scripts/main.ts, for example to Formspree:

ts
function onValid(form: HTMLFormElement): void {
  fetch("https://formspree.io/f/your-id", {
    method: "POST", body: new FormData(form), headers: { Accept: "application/json" },
  }).then((res) => {
    if (res.ok) showSuccess(form); else toast("Something went wrong. Please email us.");
  });
}

Then change the success text in each page to say the message was received. Every field has a name, so FormData carries all values; keep a name on any field you add. The site is static, so a form service (Formspree, Basin, Netlify Forms) or your own API endpoint has to receive the data. The contact form's trip list is filled from TOURS, and /contact?tour=<id> preselects a trip.

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 tour page also sets its title (e.g. "Atlas to the Sahara — 10 days in Morocco | Saltwind Journeys") and description from the tour shown, with the site name from site.name in src/lib/site.ts.

  • 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://wander.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/tours).

  • The 404 page passes noindex, which adds <meta name="robots" content="noindex"> and leaves out the canonical URL.

  • Tour pages are filled in the browser, so search engines that don't run JavaScript see Atlas to the Sahara for every /tour?id=…. If each tour matters for search, generate one static page per tour with a dynamic route (src/pages/tours/[id].astro with getStaticPaths() over TOURS), and consider @astrojs/sitemap and TouristTrip structured data (JSON-LD).

Accessibility#

  • Skip link, landmarks, one h1 per page and a logical heading order; breadcrumbs use aria-current.
  • aria-current="page" in the menus, set from the page's current prop.
  • Keyboard support everywhere: the mobile menu closes with Escape, 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, toasts 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, add data-label to art that carries meaning, and keep icon-only buttons' aria-label.

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 tours.html rather than tours/index.html (build.format: "file" in astro.config.mjs), and links point to /tours without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /tours from tours.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 /tours serves tours.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 /tours to tours.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=/wander pnpm build. It becomes Astro's base, and every link written with url(), including the tour links the scripts add, 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#

  • A new class has no effect. Tailwind scans src/, including the HTML strings in src/scripts/*.ts; write class names in full (not built from pieces) so it can find them, and restart pnpm dev if needed.
  • 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. pnpm check flags fields of the wrong type.
  • /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.
  • Links 404 after deploying to a sub-folder. Build with DEMO_BASE_PATH=/your-folder and write every internal link with url().
  • /tours returns 404 but /tours.html works. Your host doesn't map extensionless URLs to .html files: turn on clean URLs (Vercel cleanUrls) or switch build.format to "directory".
  • The enquiry says nothing was sent. That's the demo. Connect the form in onValid() (see Forms) and update the success text.

License#

MIT: free for personal and commercial use. See LICENSE.txt in the download. The SVG illustrations, logo and social image are part of the template and covered by the same license.

Search documentation

Search the guides and every template's documentation