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).
pnpm install
pnpm dev # http://localhost:4321Other 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.mdCustomising#
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):
@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:
--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:
{
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: "" },
],
},idis used in URLs (/tour?id=dolomites-huts,/contact?tour=dolomites-huts): lower case, no spaces.regionmust be theidof aREGIONSentry (the example assumes you added analpsregion) andstyletheidof aSTYLESentry. 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 fromREGIONStoo; the home page grid is laid out for six regions (TILE_LAYOUTinsrc/pages/index.astro), and extra regions get a square tile.priceis the lowest price per person andsinglethe 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.stopsare route map points:[x, y, name], withxandyas percentages of the map's width and height.itineraryentries are accordion rows:dis the day or range,tthe title,xthe text andstaythe overnight (leave empty to hide it).featured: 1to4puts the tour on the home page in that order and first in "Recommended" sort;badgeadds 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):
<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.
Navigation, header and footer#
The header, mobile menu and footer are components, so each change applies to every page:
src/data/nav.tsholds the links:mainNav(desktop header),mobileNav(Home plus the same pages),footerTrips,footerSaltwindandfooterLegal. Add or remove an entry there.- A link's
keymatches thecurrentprop a page passes to<Layout>(current="tours"); that link getsaria-current="page", which the styles highlight. - The announcement bar ("Next season's departures are open") is the first
<p>insrc/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-ascreadState() 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:
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
titleanddescriptionto<Layout>. The layout turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter 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 fromsite.nameinsrc/lib/site.ts. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://wander.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/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].astrowithgetStaticPaths()overTOURS), and consider@astrojs/sitemapandTouristTripstructured data (JSON-LD).
Accessibility#
- Skip link, landmarks, one
h1per page and a logical heading order; breadcrumbs usearia-current. aria-current="page"in the menus, set from the page'scurrentprop.- 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-motionturns 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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ 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 directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/toursservestours.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/tourstotours.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=/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 insrc/scripts/*.ts; write class names in full (not built from pieces) so it can find them, and restartpnpm devif needed. - A new tour doesn't appear under a filter. Its
regionandstylemust match anidinREGIONSandSTYLES, and itsmonthsmust be numbers from 1 to 12.pnpm checkflags fields of the wrong type. /tour?id=…shows the wrong tour. Unknown ids fall back to the first tour; check the id matchesTOURS.- A tour shows no departures. Departures are only generated for the next 15 months and never sooner
than
BOOKING_CUTOFFdays away; check the tour'smonths. - Prices look higher than "from". Departures in a region's best months add
PEAK_SUPPLEMENT; set it to0to turn peak pricing off. - Links 404 after deploying to a sub-folder. Build with
DEMO_BASE_PATH=/your-folderand write every internal link withurl(). /toursreturns 404 but/tours.htmlworks. Your host doesn't map extensionless URLs to.htmlfiles: turn on clean URLs (VercelcleanUrls) or switchbuild.formatto"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.