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).
pnpm install
pnpm dev # http://localhost:4321With 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.jsonPages 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:
@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:
<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&family=Jost:wght@300..600&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>):
<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:
{
"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:
- Add three images to
public/img/:room-loft.svg,room-loft-terrace.svgandroom-loft-bath.svg(the room page builds the gallery from these names). - Copy one of the
<article … data-room-card …>blocks insrc/pages/rooms.astroand setdata-view,data-bedanddata-capacityto match, so the filters work. Point its links aturl("/room?room=loft"). - 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.
Navigation, header and footer#
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:
<a href={url("/rooms")} aria-current={current === "rooms" ? "page" : undefined} class="link-underline …">To add a page:
- Create
src/pages/spa.astrowith<Layout title="…" description="…" page="spa" current="spa">around its content. - Add a link to the desktop menu (
<nav aria-label="Main">) and the mobile menu (<nav aria-label="Mobile">) inSiteHeader.astrowith thearia-currentexpression above, and to the footer columns if you like. - 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 toMAX_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:
- Each night costs the room
rate, plusWEEKEND_UPLIFTon Fridays and Saturdays (rounded to whole euros). - Extra guests are added per night.
- Extras are added: breakfast is charged per guest per night; every other extra once per stay.
- A valid promo code takes its percentage off the room total only (not extras or extra guests).
- 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:
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():
- 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. - 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.
- Extras — the checkboxes in the page (
name="extra", values matching theEXTRASids). - Guest details — first and last name, email and phone (validated inline), plus an arrival time and special requests.
- 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:
// 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, updatesaria-expandedand its label, and Esc closes it and returns focus to the button. - Elements with the
revealclass 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 (thejsclass 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 |
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():
// 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-DDdates; adultsis clamped to 1–4 andchildrento 0–2, whole numbers only;roomandextrasmust be known ids (duplicates are dropped);promomust 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
titleanddescriptionto<Layout>, which writes<title>, the meta description,og:title,og:description, asummary_large_imageTwitter card and atheme-color. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://stay.example.com). Set it to your domain when you build:shSITE_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 apublic/robots.txt. ConsiderHotelstructured data (JSON-LD) inindex.astrowith your address, phone and price range. The room page is one URL with?room=…; to give each room its own indexable page, generate them withgetStaticPaths()fromROOMS.
Accessibility#
Built in:
- a "Skip to content" link and landmarks (
header,nav,main,footer) on every page; - visible focus rings (
:focus-visibleinglobal.css); - one
h1per page; - form errors linked with
aria-describedbyandaria-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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ 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 directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/roomsservesrooms.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/roomstorooms.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=/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 inpnpm dev,pnpm previewand on an HTTPS site. - The room page always shows the Sea View Room. The
roomvalue in the URL must match anidinROOMS; unknown ids fall back to the first room. - A promo code says it isn't valid. Check its
minNights,advanceDaysandroomsrules inPROMOS; 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[].rateplus 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:v1from 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 withurl()in pages, and withBASEinmain.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_URLto 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.