Freeframe

Documentation

Stay

v1.0.3Updated Oct 9, 2026An elegant boutique hotel website with live prices, availability and booking.

Overview#

Stay is a complete website for a small hotel or resort, shown as Casa Marea, a fictional 22-room boutique hotel in a 1903 villa above the sea in Porto Lume, Liguria. It has an immersive home page with a booking bar, filterable rooms, a room page with live prices and an availability calendar, dining menus, experiences, offers with promo codes, a location page and a five-step booking flow. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.3. It builds to static HTML files with no UI framework and one small script, and gives the same prices and booking results as the HTML and Next.js versions for the same stay.

Live demo: freeframe.dev/templates/stay

Quick start#

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

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

With npm: npm install, then npm run dev.

Command What it does
pnpm dev Development server at http://localhost:4321 with hot reload
pnpm build Static site in dist/
pnpm preview Serve dist/ locally after a build
pnpm check astro check: type-checks .astro files and TypeScript
pnpm typecheck Generate Astro's types, then type-check with tsc (TypeScript 7)
pnpm lint ESLint (TypeScript and Astro rules)
pnpm build:demo Build for /demos/astro/stay into demo/ (only used for the demo on freeframe.dev)

Pages#

Every file in src/pages/ becomes one page.

URL File Notable sections and features
/ src/pages/index.astro Hero with the booking bar, intro, three featured rooms, pool/spa/boat cards, dining, offers, reviews, location
/rooms src/pages/rooms.astro Six room cards with filters by view, beds and guests; filters are kept in the URL
/room src/pages/room.astro One room chosen with ?room=<id>: photo gallery and lightbox, details and amenities, live price, availability calendar
/dining src/pages/dining.astro Restaurant, breakfast and bar, menus in tabs, table request form
/experiences src/pages/experiences.astro Spa, sea, land and events experiences with a category filter
/offers src/pages/offers.astro Packages with promo codes and a copy button
/contact src/pages/contact.astro Illustrated map, getting here, contact form (prefilled from the URL), FAQ
/booking src/pages/booking.astro Five steps: dates, room, extras, guest details, review; demo confirmation
404 src/pages/404.astro Friendly 404 page (most hosts show 404.html automatically)

Project structure#

stay/
├─ src/
│  ├─ pages/                 one .astro file per page (see above)
│  ├─ layouts/
│  │  └─ Layout.astro        <head> (title, meta, Open Graph, fonts), header, <main>, footer, scripts
│  ├─ components/
│  │  ├─ SiteHeader.astro    skip link, logo, desktop and mobile navigation, "Book a stay" button
│  │  └─ SiteFooter.astro    newsletter, footer links, toast element
│  ├─ data/
│  │  └─ hotel.ts            rooms, extras, promo codes and amenities used by the scripts
│  ├─ lib/
│  │  └─ site.ts             site name, theme colour and the url() helper for links
│  ├─ scripts/
│  │  ├─ main.ts             all interactivity and the pricing rules
│  │  └─ back-to-top.ts      floating "Back to top" button
│  └─ styles/
│     └─ global.css          Tailwind theme (@theme tokens) and component classes
├─ public/img/               SVG artwork, favicon.svg, og-image.svg (source) and og-image.png
├─ astro.config.mjs          site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.json

Pages are rendered to HTML at build time. The two files in src/scripts/ are the only JavaScript sent to the browser, plus one inline line in the layout that adds a js class to <html>. Layout.astro imports both scripts, and Astro bundles them (with src/data/hotel.ts) into one small module.

Customising#

Brand and colours#

All design tokens live in the @theme block at the top of src/styles/global.css:

Token Used for
--color-linen Page background
--color-shell, --color-sand Panels and deeper panels
--color-line Borders
--color-ink Text and dark sections
--color-sea Secondary dark (buttons, selected states, back-to-top)
--color-mist Muted text
--color-coral, --color-coral-dark Accent (eyebrows, focus rings, errors)
--color-olive, --color-sun Decorative details
--color-foam Light text on dark backgrounds

To change the accent:

css
@theme {
  --color-coral: #2f6f5e;
  --color-coral-dark: #245748;
}

Tailwind rebuilds automatically in pnpm dev and pnpm build. Keep text colours at WCAG AA contrast against the backgrounds they sit on. A few classes use hex values directly (for example accent-[#1d4b57] on checkboxes in booking.astro); search for the old hex when you change --color-sea. The browser theme colour is site.themeColor in src/lib/site.ts.

Typography#

Two Google Fonts are loaded by a <link> in src/layouts/Layout.astro:

html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400..600;1,6..96,400..500&amp;family=Jost:wght@300..600&amp;display=swap" />

--font-display (Bodoni Moda, headings) and --font-sans (Jost, body) in @theme map them to the font-display and font-sans utilities. To swap a font, change the link and the family name in the matching token.

Content#

Page copy is markup in each src/pages/*.astro file. The page title and description are props on <Layout>, along with current (the highlighted menu item) and page (written to <body data-page>):

html
<Layout title="Rooms & suites — Casa Marea" description="..." page="rooms" current="rooms">

The data that drives the interactive parts is in src/data/hotel.ts:

Export What it holds
ROOMS Each room: id, name, view (sea, garden or pool), bed (king or twin), capacity, size (m²), rate (per night, euro), short, description, highlights
EXTRAS Booking extras: id, name, price, unit, text
PROMOS Promo codes and their rules (see Promo codes)
AMENITIES Amenities listed on every room page after the room's own highlights
VIEW_LABEL, BED_LABEL Labels for the view and bed values

To add a room, add it to ROOMS:

ts
{
  "id": "loft",
  "name": "Garden Loft",
  "view": "garden",
  "bed": "king",
  "capacity": 2,
  "size": 34,
  "rate": 290,
  "short": "A split-level room under the eaves.",
  "description": "…",
  "highlights": ["Mezzanine bedroom", "Garden patio", "Rain shower"]
},

Then:

  1. Add three images to public/img/: room-loft.svg, room-loft-terrace.svg and room-loft-bath.svg (the room page builds the gallery from these names).
  2. Copy one of the <article … data-room-card …> blocks in src/pages/rooms.astro and set data-view, data-bed and data-capacity to match, so the filters work. Point its links at url("/room?room=loft").
  3. Optionally add it to the featured rooms on index.astro.

The room cards in rooms.astro and index.astro, the extras list in booking.astro and the offer cards in offers.astro are written in markup. When you change src/data/hotel.ts, update the matching pages so prices and names agree. The dining menus, experiences, reviews and FAQ are markup only.

Text containing literal curly braces must be escaped in .astro files ({"{"}), because braces start an expression.

Images and artwork#

All artwork is original SVG in public/img/:

Files Aspect ratio Where
hero-coast.svg 16:10 (1600 × 1000) Home hero
room-<id>.svg, room-<id>-terrace.svg, room-<id>-bath.svg 16:10 Room cards, room gallery, booking flow
pool.svg, spa.svg, boat.svg, dining.svg, breakfast.svg, bar.svg, events.svg 16:10 Feature cards and pages
map.svg 3:2 (1200 × 800) Location page
favicon.svg square Browser tab icon
og-image.svg / og-image.png 1200 × 630 Social sharing image (SVG is the editable source)

To use photos, export them at the same aspect ratio (1600 × 1000 works everywhere) as .jpg or .webp and change the src attributes, always through url() (src={url("/img/room-sea.jpg")}). The scripts build some image paths themselves: the room page gallery in initRoomPage(), the "Other rooms" cards in roomCardHTML() and the booking flow in renderRooms() and renderSummary() (BASE + "/img/room-" + room.id + ".svg" and the -terrace/-bath variants); change the extension there too. Keep meaningful alt text.

The header and mobile menu are in src/components/SiteHeader.astro, the footer (with the newsletter form and the toast element) in src/components/SiteFooter.astro. Every page gets them from Layout.astro. Menu links get aria-current="page" when the page's current prop matches; the styles underline the desktop link and colour the mobile one:

html
<a href={url("/rooms")} aria-current={current === "rooms" ? "page" : undefined} class="link-underline …">

To add a page:

  1. Create src/pages/spa.astro with <Layout title="…" description="…" page="spa" current="spa"> around its content.
  2. Add a link to the desktop menu (<nav aria-label="Main">) and the mobile menu (<nav aria-label="Mobile">) in SiteHeader.astro with the aria-current expression above, and to the footer columns if you like.
  3. Write internal links as href={url("/spa")} so they keep working under a base path.

Features#

All interactive code is in src/scripts/main.ts, in numbered sections (initBookingBar(), initRoomsFilter(), initRoomPage(), initTabs(), initExperiences(), initCopyCodes(), initContactPrefill(), initBooking(), initForms(), initChrome()). Each looks for its elements and does nothing on pages that don't have them. Links and images built in script start with BASE, the site's base path, so they work in a sub-folder.

Booking bar#

The form on the home page (<form data-booking-bar>) has arrival and departure dates, adults and children. In initBookingBar() it:

  • prefills itself from the saved draft (or two weeks from today for three nights);
  • keeps departure after arrival when the arrival date changes;
  • validates with checkDates(): arrival can't be in the past, departure must be after arrival, and stays are limited to MAX_NIGHTS (21);
  • on submit, saves the stay and opens /booking?checkin=…&checkout=…&adults=…&children=….

Errors are written into the element named by each field's aria-describedby, and focus moves to the first problem.

Room filters#

/rooms has three selects inside <form data-room-filters> with data-filter="view", "bed" and "guests". initRoomsFilter() hides cards that don't match (guests keeps rooms whose data-capacity is at least the chosen number), updates the live count ([data-room-count], announced to screen readers) and shows the empty state ([data-room-empty]) when nothing matches. The current filters are written to the URL, so /rooms?view=sea&guests=3 can be shared or linked to.

Room page#

/room?room=<id> shows one room. The page ships with the Sea View Room filled in as a fallback; initRoomPage() replaces every [data-room-field="…"] element (name, description, rate, size, bed, capacity, view and amenities) with the requested room's data and updates the page title. An unknown id falls back to the first room.

The page also lists up to three other rooms and disables adult counts above the room's capacity.

Photo lightbox#

The gallery buttons ([data-gallery-open="0|1|2"]) open a native <dialog data-lightbox> showing the bedroom, terrace and bathroom images. Arrow keys and the Previous/Next buttons move between photos, Esc or a click on the backdrop closes it, and the dialog traps focus while it's open.

Prices#

Prices are calculated by quote(stay) in src/scripts/main.ts. The rules are constants at the top of the file:

Constant Default Meaning
WEEKEND_UPLIFT 0.15 Friday and Saturday nights cost 15% more
EXTRA_ADULT 45 Per night, for each adult beyond two
EXTRA_CHILD 25 Per night, for each child once the room already has two guests
VAT_RATE 0.1 VAT included in all prices; shown as "Includes VAT"
CITY_TAX 3.5 Per adult per night for the first 7 nights; shown separately, paid at the hotel
MAX_NIGHTS 21 Longest stay bookable online

How a total is built:

  1. Each night costs the room rate, plus WEEKEND_UPLIFT on Fridays and Saturdays (rounded to whole euros).
  2. Extra guests are added per night.
  3. Extras are added: breakfast is charged per guest per night; every other extra once per stay.
  4. A valid promo code takes its percentage off the room total only (not extras or extra guests).
  5. The VAT share and city tax are calculated for display.

For example, the Sea View Room (€360) for three nights from a Wednesday with two adults is €1,134 (one Friday at €414), including €103.09 VAT, plus €21 city tax. breakdownHTML() renders the result as a list, used on the room page and in the booking summary. To change currency, edit money() (it formats with en-GB and a € sign).

Availability calendar#

The room page shows two months of availability ([data-calendar]). Booked nights are struck through and can't be chosen as arrival; click an arrival date, then a departure date, and the price updates. A stay that runs into a booked night is refused with a message in [data-cal-status], which is announced to screen readers.

Availability comes from isBlocked(roomId, iso), which returns a deterministic demo pattern. Replace it with a call to your reservations system or channel manager. Because the calendar renders synchronously, the simplest approach is to fetch the booked nights once, keep them in a Set, and have isBlocked() check it:

ts
const BOOKED = new Set<string>(); // fill from your API, e.g. "sea:2026-11-02"
function isBlocked(roomId: string, iso: string) {
  return BOOKED.has(roomId + ":" + iso);
}

firstBlocked() uses isBlocked() for every night of a stay, so the room page and the booking flow follow the same data.

Booking flow#

/booking is one <form data-booking-form> with five <fieldset data-step="1…5"> steps. initBooking():

  1. Dates and guests — validated with checkDates(). If the guests change and the chosen room no longer sleeps them, fitRoomToGuests() switches to the first room that does (the same rule as for guests from the URL) and the summary says so ("The Sea View Room sleeps up to 2 guests, so we've switched you to the Marea Suite …"). If no room sleeps that many (5 or 6 guests), the summary shows no price, only "No room sleeps 6 guests" and a note to choose fewer guests or contact the hotel, and the room step lists every room as too small.
  2. Room — rooms are listed with their price for the chosen dates; rooms that are too small or booked on any of the nights are disabled with the reason. The first free room is preselected.
  3. Extras — the checkboxes in the page (name="extra", values matching the EXTRAS ids).
  4. Guest details — first and last name, email and phone (validated inline), plus an arrival time and special requests.
  5. Review — a summary, the promo code field and the cancellation policy checkbox.

A live summary with the full price breakdown sits beside the form. The step list marks the current step with aria-current="step", and focus moves to each step's legend. Arriving with a room in the URL (from a room page) starts on step 2.

On the final submit the template shows a confirmation with a demo reference such as CM-K3X9QZ and clears the draft. Nothing is booked. The place to send the booking is marked in the submit handler:

ts
// Demo only: send `state` and the guest details to your reservations system here.
const ref = "CM-" + Date.now().toString(36).toUpperCase().slice(-6);

Replace it with a request to your booking engine and show the reference it returns. Validate prices and availability again on your server: anything calculated in the browser can be changed by the visitor.

Promo codes#

Each entry in PROMOS (src/data/hotel.ts) has:

Field Meaning
code What guests type (case-insensitive)
name Shown in messages and the price breakdown
percent Discount off the room total
minNights Minimum stay
advanceDays Minimum days between today and arrival (0 for none)
rooms Room ids the code applies to ([] for all rooms)
summary, terms Text for your offer cards

checkPromo(code, stay) returns a clear message when a code doesn't apply ("needs a stay of at least 4 nights"). The codes can be passed in the URL (/booking?promo=LONGSTAY) and are checked on the last step. The offer cards on offers.astro are markup: keep them in sync with PROMOS.

Dining menu tabs#

The menus on /dining are an accessible tab set ([data-tabs] with role="tab" buttons). Arrow keys move between tabs, Home and End jump to the first and last, and only the selected tab is in the tab order. To add a menu, add a tab button with aria-controls pointing at a new role="tabpanel" element.

Experiences filter#

The buttons with data-exp-filter="All|Spa|Sea|Land|Events" filter the cards by their data-type and update the count. A matching hash opens a category directly, for example /experiences#sea.

Copy offer codes#

Buttons with data-copy-code="LONGSTAY" copy the code to the clipboard and show a toast (#toast in SiteFooter.astro). The clipboard needs HTTPS (or localhost); elsewhere the toast shows the code instead.

Contact prefill#

/contact?topic=events&about=boat selects the topic and starts the message ("Hello, I'd like to ask about a private boat to San Fruttuoso."). The about keys and their wording are in initContactPrefill(). Topics must match the <option> values of the topic select.

Header, mobile menu and scroll reveals#

  • The header gains a border and shadow after you scroll.
  • The menu button (#menu-toggle) toggles #mobile-menu, updates aria-expanded and its label, and Esc closes it and returns focus to the button.
  • Elements with the reveal class fade in as they scroll into view. Revealing one element also reveals everything above it, so jumping to the end of a page never leaves hidden content. With reduced motion, or without JavaScript (the js class is added by a one-line inline script in <head>), everything is visible straight away.

Back to top#

src/scripts/back-to-top.ts adds a "Back to top" button after the visitor scrolls about one screen. It scrolls smoothly (instantly with reduced motion), moves keyboard focus to <main>, lifts itself above the toast, stays hidden while the mobile menu is open, and hides while it would cover elements marked data-back-to-top-hide (the sticky booking cards). Add data-back-to-top-avoid to your own fixed elements at the bottom of the screen to keep the button above them. Restyle it with the .back-to-top rules in global.css; to remove it, delete the file, its import in Layout.astro and those rules.

Forms#

Form Page Validates
Booking bar (data-booking-bar) / Dates (see above)
Room price (data-room-price) /room Dates, capacity, availability
Booking (data-booking-form) /booking Each step (see above)
Table request (data-form="table") /dining Required fields, email, date from today
Contact (data-form="contact") /contact Name, email, message of at least 10 characters
Newsletter (data-form="newsletter") Footer of every page Email

Every form[data-form] is handled by initForms(): errors appear under each field (in the element named by aria-describedby), focus moves to the first invalid field, and a success message ([data-form-success]) is shown. Demo forms don't send anything until you connect them.

To send them with a form service such as Formspree, replace the comment in the submit handler of initForms():

ts
// To send for real: fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(form) })
fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } })
  .then((res) => {
    if (!res.ok) throw new Error("Request failed");
    form.reset();
    success?.classList.remove("hidden");
    success?.focus();
  })
  .catch(() => alert("Sorry, your message could not be sent. Please try again or email us."));

and remove the lines after it that reset the form and show the success message straight away. Update the success messages in the pages, which currently say the form is a demo. To use different endpoints per form, branch on form.dataset.form (table, contact, newsletter).

To run your own server code instead (for example to create bookings), add an Astro adapter (@astrojs/node, @astrojs/netlify or @astrojs/vercel) and an API endpoint such as src/pages/api/booking.ts with export const prerender = false, then post to it.

Saved data (localStorage)#

Key Shape
casa-marea:booking:v1 { checkin, checkout, adults, children, room, extras, promo } — the booking in progress

The booking bar, room page and booking flow share this draft. Every read goes through sanitizeDraft():

  • dates must be real YYYY-MM-DD dates;
  • adults is clamped to 1–4 and children to 0–2, whole numbers only;
  • room and extras must be known ids (duplicates are dropped);
  • promo must be 3–20 letters or digits.

Anything invalid (including JSON that can't be parsed) is dropped and the stored value is repaired, so a corrupt or edited value never breaks a page. Values in the URL take priority over the draft. When the booking flow restores a room that can't sleep the restored guests, guests from the URL keep their number and get the first room that sleeps them; otherwise the guests are reduced to the room's capacity (adults first). The draft is cleared after a confirmed booking. To reset it by hand, run localStorage.removeItem("casa-marea:booking:v1") in the browser console. window.CasaMarea exposes quote, sanitizeDraft, readDraft and checkPromo for tests and debugging; remove that line at the end of main.ts if you don't want it.

SEO and sharing#

  • Each page passes title and description to <Layout>, which writes <title>, the meta description, og:title, og:description, a summary_large_image Twitter card and a theme-color.

  • 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://stay.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://www.your-hotel.com pnpm build
  • The 404 page passes noindex, which adds <meta name="robots" content="noindex"> and leaves out the canonical URL.

  • For a sitemap, add @astrojs/sitemap (pnpm astro add sitemap) and a public/robots.txt. Consider Hotel structured data (JSON-LD) in index.astro with your address, phone and price range. The room page is one URL with ?room=…; to give each room its own indexable page, generate them with getStaticPaths() from ROOMS.

Accessibility#

Built in:

  • a "Skip to content" link and landmarks (header, nav, main, footer) on every page;
  • visible focus rings (:focus-visible in global.css);
  • one h1 per page;
  • form errors linked with aria-describedby and aria-invalid, and focus moved to the first error;
  • tabs, dialog and step indicators using the standard ARIA patterns (role="tab", native <dialog>, aria-current);
  • live regions for filter counts, calendar status and toasts;
  • reduced motion respected by animations, smooth scrolling, the hero zoom and the back-to-top button;
  • text colours that meet WCAG AA contrast.

When editing, keep one h1 per page, give every new input a <label>, keep alt text meaningful, and check contrast if you change colours.

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 rooms.html rather than rooms/index.html (build.format: "file" in astro.config.mjs), and links point to /rooms without a trailing slash. Tailwind scans src/, including the class names written in main.ts.

  • 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 /rooms serves rooms.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 /rooms to rooms.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=/hotel pnpm build. It becomes Astro's base; links written with url() and the paths the scripts build with BASE include 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#

  • Copying an offer code shows the code instead of copying it. The clipboard API needs HTTPS or localhost. It works in pnpm dev, pnpm preview and on an HTTPS site.
  • The room page always shows the Sea View Room. The room value in the URL must match an id in ROOMS; unknown ids fall back to the first room.
  • A promo code says it isn't valid. Check its minNights, advanceDays and rooms rules in PROMOS; the message names the rule that failed.
  • Prices on the cards don't match the booking total. Card prices are markup; the booking uses ROOMS[].rate plus weekend, guest and extra charges. Update the pages when you change rates.
  • The booking flow starts with old dates or guests. It restores the saved draft. Clear casa-marea:booking:v1 from local storage (see Saved data).
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, write links and image paths with url() in pages, and with BASE in main.ts.
  • Two words run together after editing. Astro compresses whitespace in markup; when text continues on the next line before an inline element such as <em>, keep them on one line or add {" "}.
  • Social previews show the wrong domain or no image. Set SITE_URL to your domain at build time.

License#

MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this template, including the SVG artwork. Casa Marea, its staff and its guests are fictional. Bodoni Moda and Jost are licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation