Overview#
Vows is a wedding website for Amara & Theo, a fictional couple marrying at Hollowmere House on Saturday 19
June 2027. Guests can find their invitation by code or name and RSVP for everyone on it (meal choices, dietary
needs, plus-ones, song requests). It has a live countdown, a weekend schedule with add-to-calendar .ics files, a
travel page with an illustrated map and room blocks, a registry with reservations and a honeymoon fund, an
illustrated gallery with a lightbox and a searchable FAQ. All artwork is botanical SVG, no photos needed. This is the
Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.2. It builds to static HTML files with no UI
framework, and can be hosted anywhere. See it live at
freeframe.dev/templates/vows.
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. Try the RSVP with a demo code such as ROSE-24, or a name such as
Priya Shah.
| 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/vows into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | Notable features |
|---|---|---|
/ |
src/pages/index.astro |
Names and date, live countdown (data-countdown), save the date with an add-to-calendar button, "Plan your weekend" links, story teaser, weekend overview, RSVP call to action |
/story |
src/pages/story.astro |
Illustrated timeline |
/schedule |
src/pages/schedule.astro |
Friday, Saturday and Sunday events with dress codes; a calendar file per event and for the whole weekend |
/travel |
src/pages/travel.astro |
Illustrated map with a selectable legend, train/car/shuttle, room blocks with copyable booking codes, local tips |
/rsvp |
src/pages/rsvp.astro |
Find the invitation, reply for each guest, review, confirmation |
/registry |
src/pages/registry.astro |
Honeymoon fund with preset or custom amounts; gift list with category filter, "Hide reserved gifts" and reservations |
/gallery |
src/pages/gallery.astro |
Nine illustrations in a grid with a lightbox |
/faq |
src/pages/faq.astro |
Searchable questions in groups |
| 404 | src/pages/404.astro |
Page not found, links back home (most hosts show 404.html automatically) |
Project structure#
vows/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), skip link, header, footer, toast, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro names, desktop navigation, RSVP button, mobile menu
│ │ └─ SiteFooter.astro date and place, links, hashtag, copyright
│ ├─ data/
│ │ └─ vows.ts couple, events, meals, guest list, registry, fund, gallery, gift icons
│ ├─ lib/
│ │ ├─ site.ts site name, theme colour and the url() helper for links
│ │ └─ ics.ts buildIcs() and downloadIcs(): add-to-calendar files
│ ├─ scripts/
│ │ ├─ main.ts every interactive feature (menu, countdown, RSVP, registry, fund, lightbox, FAQ...)
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (colours, fonts) and shared component classes
├─ public/img/ gallery-*.svg, wreath/sprig/corner ornaments, favicon.svg, og-image.png (and .svg)
├─ 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/ (plus the data and ics.ts modules they
import) are the only JavaScript sent to the browser, besides one inline line in the layout's <head> that swaps the
no-js class on <html> for js. Layout.astro imports both scripts and Astro bundles them into one module.
Customising#
Brand and colours#
Design tokens are in the @theme block of src/styles/global.css:
| Token | Default | Used for |
|---|---|---|
--color-paper |
#fbf7f0 |
Page background |
--color-linen, --color-blush |
#f4ece1, #f1ddd0 |
Soft panels |
--color-ink, --color-muted, --color-line |
#2b2622, #6a5f55, #e3d8c9 |
Text, secondary text, hairlines |
--color-sage, --color-sage-deep, --color-sage-light, --color-forest |
greens | Buttons, accents, dark sections |
--color-terra, --color-terra-light |
#a4462d, #c46a4e |
Script names, focus outline, primary accents |
--color-gold |
#c9a464 |
Ornaments |
For example:
@theme {
--color-terra: #7a3e6b;
--color-sage-deep: #3e4f5a;
}Tailwind rebuilds automatically in pnpm dev and pnpm build. The SVG ornaments and gallery illustrations have
their colours inside the files; edit them or replace the files. The browser theme colour is site.themeColor in
src/lib/site.ts; update it if you change --color-paper.
Typography#
Cormorant Garamond (--font-serif), Jost (--font-sans) and Pinyon Script (--font-script, used by the .script
class for the names) load from Google Fonts with a <link> in src/layouts/Layout.astro. To swap one, change that
link and the font name in @theme:
--font-script: "Great Vibes", "Snell Roundhand", cursive;Content#
Page text is in the .astro files; search for "Amara" to find every mention of the names (including the header,
the footer and og:site_name in Layout.astro). The data the scripts use is exported from src/data/vows.ts,
with TypeScript types:
| Export | Type / shape | Used by |
|---|---|---|
KEEP_DEMO_CURRENT |
true |
Demo only: moves every date on a year once the weekend is over (see Demo dates) |
couple |
{ a, b, names, date_long, date_short, venue, place, ceremony_iso, rsvp_by, hashtag, email } |
Calendar file titles (names) |
events |
VowsEvent[]: { id, day, title, start, end, place, address, dress, body } |
.ics files |
days |
{ id, label, date, title }[] |
Reference only (the schedule page is written in markup) |
meals, kidsMeal |
Meal: { id, label, note } |
RSVP dinner choices (kidsMeal is offered first to guests with child: true) |
invites |
Invite[]: { code, label, plusOne, guests: { id, name, child? }[] } |
RSVP lookup |
gifts |
Gift[]: { id, name, category, price, needed, reserved, icon, note } |
Gift list (icon is a key of giftIcons) |
registryCategories |
category names | Reference only (the filter buttons are in registry.astro) |
fund |
{ title, destination, goal, raised, amounts: { value, label }[] } |
Honeymoon fund |
gallery |
Photo[]: { slug, title, caption, shape } (shape is "tall" or "wide") |
Lightbox |
giftIcons |
Record<string, string> of SVG path markup |
Gift card icons |
Some values also appear as text in the pages, so change both together:
- Ceremony date and time:
couple.ceremony_iso, thedata-countdownattribute insrc/pages/index.astro, and the dates written on the pages. - Schedule: each event's text in
src/pages/schedule.astroand its entry inevents(used for the calendar files). Each "Add to calendar" button hasdata-ics="<event id>". - Fund:
fund.goalandfund.raised, the "£1,840 raised of £3,000" text, the progress bar'saria-valuemax,aria-valuenowand width insrc/pages/registry.astro, and the amount buttons (data-amount). - Gallery: the grid in
src/pages/gallery.astro(each button'sdata-photois its index ingallery) and thegallerylist.
Add an invitation to invites. code is matched ignoring case, spaces and punctuation; id must be unique
within the invitation:
{
code: "BIRCH-05",
label: "The Novak family",
plusOne: false,
guests: [
{ id: "anna", name: "Anna Novak" },
{ id: "eli", name: "Eli Novak", child: true },
],
},Add a gift to gifts (needed is how many you'd like, reserved how many are already taken):
{ id: "teapot", name: "Stoneware teapot", category: "Kitchen", price: 48, needed: 1, reserved: 0, icon: "pot", note: "For long Sunday breakfasts." },Times include their UTC offset (+01:00 is British Summer Time), so calendar files show the right local time for
guests anywhere. Use your venue's offset on the wedding date.
Demo dates
The demo is written for the weekend of 18–20 June 2027. While KEEP_DEMO_CURRENT in src/data/vows.ts is true
and that weekend is over (after the last event in events ends), src/scripts/main.ts moves every date to the same
weekend of the next year, by whole weeks so weekdays never change (Saturday 19 June 2027 becomes Saturday 17 June
2028): the countdown, the events and calendar files, every <time datetime>, the text of data-date="2027-04-01"
spans ("1 April 2027"; with data-date-format="dm" "18 June"), data-edition="year" and data-edition="short"
("19.06.27"), and the date in the page title. The RSVP deadline and room-block dates move with it. Before the weekend
is over nothing changes. Set KEEP_DEMO_CURRENT = false once you enter your real dates.
Images and artwork#
- Gallery:
public/img/gallery-<slug>.svg. Replace them with photos and either keep the file names or changeurl(`/img/gallery-${p.slug}.svg`)in the lightbox block ofsrc/scripts/main.tsand the<img>tags insrc/pages/gallery.astro.tallimages are shown at 1200×1500 (4:5) andwideat 1200×900 (4:3). - Ornaments:
wreath.svg,sprig.svgandcorner.svgare decorative (alt=""). - Social image:
public/img/og-image.png(1200×630) comes frompublic/img/og-image.svg; edit the SVG (names, date) and export a new PNG. - Favicon:
public/img/favicon.svg(the couple's initials). - Map: the illustrated map is inline SVG in
src/pages/travel.astro; each place is a<g class="map-pin" data-pin="…">.
Always link files in public/ through url() from src/lib/site.ts (src={url("/img/wreath.svg")}) so they
keep working when the site is served from a sub-folder.
Navigation, header and footer#
src/components/SiteHeader.astroholds the desktop navigation, the RSVP button and the mobile menu (#mobile-menu). A link getsaria-current="page"when the page'scurrentprop matches it (current="schedule"highlights Schedule; the home page usescurrent="home").src/components/SiteFooter.astrois the footer.Layout.astrotakestitle,description,current,page(written to<body data-page>) andnoindex.
To add a page, create src/pages/accommodation.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Where to stay — Amara & Theo" description="Places to stay near Hollowmere." current="accommodation" page="accommodation">
<section class="container-x py-20">
<h1 class="font-serif text-5xl">Where to stay</h1>
</section>
</Layout>Then add the link to both menus in SiteHeader.astro and to the footer, written as href={url("/accommodation")}.
Features#
Countdown#
src/pages/index.astro has <div data-countdown="2027-06-19T15:00:00+01:00" role="timer"> with four
<span data-unit="days|hours|minutes|seconds">. The "Countdown" block in src/scripts/main.ts updates it every
second; at zero it stops and shows data-countdown-done ("Today's the day"). Without JavaScript the placeholders
stay.
Add to calendar#
Any element with data-ics="<event id>" downloads a calendar file for that event; data-ics="all" downloads the
whole weekend. buildIcs() in src/lib/ics.ts writes an RFC 5545 file from events:
DTSTART/DTENDin UTC (20270619T140000Z), converted from the offsets invows.ts.- Commas, semicolons, backslashes and new lines escaped; lines folded at 75 octets.
SUMMARYis "couple.names: event title",LOCATIONtheaddress, andDESCRIPTIONthebodyplus the dress code.
A toast confirms the download. The PRODID and the UID domain (@amara-and-theo.example) are written in
buildIcs(), and the file names (amara-and-theo-wedding-weekend.ics, amara-and-theo-<id>.ics) in
downloadCalendar() in src/scripts/main.ts; change them to your names.
RSVP#
src/pages/rsvp.astro (data-rsvp) and the "RSVP" block in src/scripts/main.ts, in four steps shown in a
progress list (data-step-label, aria-current="step"):
- Find (
data-rsvp-find): the guest types the code on their invitation or any guest's full name.findInvite()compares both ignoring case, accents, spaces and punctuation, sorose 24andInes Whitakermatch. - Details (
data-rsvp-details): one block per guest with attending yes/no, and when attending a dinner choice and dietary needs. Children (child: true) also getkidsMeal. Invitations withplusOne: trueshow "I'd like to bring a guest" with name, meal and dietary fields. Then an optional song request and note, and a required email. - Review (
data-rsvp-review): a summary with Edit reply and Send reply. - Done (
data-rsvp-done): a thank-you, plus "Add the weekend to your calendar" if anyone is coming.
Validation: every guest needs an answer, every attending guest a meal, a plus-one needs a name and a meal, and the
email must look valid. Problems are listed in an alert box at the top and marked on each field, and focus moves to
the first one. Text fields are capped (LIMITS: dietary 300, name 80, song 120, note 500, email 254 characters).
Focus moves to each step's heading. While the guest fills it in, the reply is saved as a draft (see
Saved data) and restored, with a notice, if they come back.
Privacy: src/data/vows.ts is bundled into the page's JavaScript, so anyone can read the whole guest list. For
a real wedding, look invitations up on a server (see Forms) and remove invites from the bundle.
Registry#
src/pages/registry.astro and the "Registry" block. Gifts from gifts render into data-gift-grid. Category
buttons (data-gift-filter, aria-pressed) and the "Hide reserved gifts" checkbox (data-gift-available) filter
the list, and data-gift-count (a live region) says how many are shown. Reserve marks a gift as reserved by
this visitor (shown as "Reserved by you", with Undo); when reserved plus the visitor's reservation reaches
needed, it's fully reserved. Reservations live only in the visitor's browser, so other guests don't see them; for
a shared list, store them on a server or link each gift to your gift-list provider.
Honeymoon fund#
src/pages/registry.astro (data-fund-form) and the "Honeymoon fund" block. Guests choose a preset amount
(data-amount, aria-pressed) or type a whole amount from £5 to £5,000, and give their name (required) and an
optional message. On success the raised total and progress bar update for this visit and a thank-you appears,
naming the treat that amount matches in fund.amounts. No payment is taken; send guests to your payment page or
provider in the submit handler.
Travel map#
src/pages/travel.astro. Legend buttons (data-pin-target, aria-pressed) highlight the matching map pin
(data-pin, class is-active); pressing again clears it, and clicking a pin selects it too. The legend is the
keyboard-accessible way to use the map. Places: venue, swan, mill, granary, station, oak.
Booking codes#
Buttons with data-copy="AMTHEO27" copy the code and confirm with a toast; where copying isn't allowed, the toast
shows the code instead.
Gallery lightbox#
src/pages/gallery.astro: each <button class="photo-btn" data-photo="N"> opens the native
<dialog data-lightbox> with showModal() (focus trapped, Escape closes). It shows the image, title, caption and
"N of 9"; Left/Right arrows and the Previous/Next buttons move through gallery, clicking the backdrop closes it,
and focus returns to the photo.
FAQ search#
src/pages/faq.astro: each question is a <details data-faq> inside a <section data-faq-group>. Typing in
data-faq-search hides non-matching questions and empty groups, opens the matches, and reports the count in
data-faq-status (a live region). Clearing the search restores which questions were open.
Toasts, reveals and back to top#
- Toast:
<div class="toast" role="status" data-toast>inLayout.astro, shown for 2.8 seconds bytoast(). - Reveals:
.revealsections fade in on scroll. The inline script in the layout swapsno-jsforjson<html>, so without JavaScript everything is visible; with reduced motion everything shows at once. - Back to top:
src/scripts/back-to-top.tsadds a button after about one screen; it scrolls up (instantly with reduced motion), moves focus to#main, lifts above toasts (data-toast,data-back-to-top-avoid), hides overdata-back-to-top-hideand while the mobile menu is open, and is hidden when printing. Delete the file, its import inLayout.astroand the.back-to-topstyles to drop it.
Forms#
None of the forms send anything until you connect them.
| Form | Where | Validates | Where to connect it |
|---|---|---|---|
| RSVP | src/pages/rsvp.astro |
See RSVP | The data-rsvp-send click handler in src/scripts/main.ts (the comment starting // Demo only) |
| Honeymoon fund | src/pages/registry.astro |
Amount (preset or £5–£5,000 whole), name | The data-fund-form submit handler |
| Gift reservations | src/pages/registry.astro |
Capacity (needed) |
The data-reserve click handler |
The RSVP sends state, a plain object (type RsvpState in main.ts):
{
"code": "ROSE-24",
"answers": { "priya": { "attending": "yes", "meal": "hake", "dietary": "" }, "daniel": { "attending": "no", "meal": "", "dietary": "" } },
"plusOne": { "bringing": false, "name": "", "meal": "", "dietary": "" },
"song": "September",
"note": "",
"email": "priya@example.com"
}With Formspree, replace the comment in the data-rsvp-send branch with a request, and only show the confirmation
when it succeeds:
if (target.closest("[data-rsvp-send]")) {
const s = state;
fetch("https://formspree.io/f/your-form-id", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(s),
})
.then((res) => {
if (!res.ok) throw new Error("Send failed");
store.remove(DRAFT_KEY);
renderDone(s);
showStep("done");
})
.catch(() => toast("Sorry, your reply could not be sent. Please try again."));
}Update the confirmation text in renderDone(), which says it's a demo. A Google Apps Script web app that appends
rows to a Google Sheet works the same way. To keep the guest list private, replace findInvite() with a request to
your server that returns one invitation for a code or name. The site is static, so there is no server code in the
template; if you want one in the same project, add an Astro adapter and an
endpoint.
Saved data (localStorage)#
All access goes through store in src/scripts/main.ts: if storage is blocked nothing is saved and the page keeps
working, and a value that isn't valid JSON is deleted.
| Key | Shape | Validation on read |
|---|---|---|
vows:rsvp-draft:v1 |
The RSVP state object above |
Must be an object whose code is a known invitation (otherwise removed); only applied when it matches the invitation just found. Answers are kept only for that invitation's guests, attending must be yes or no, meals must be allowed for that guest, plus-one data only if the invitation allows one, and every text is cut to LIMITS. The cleaned draft is saved back. Removed when the reply is sent. |
vows:reserved:v1 |
Array of gift ids, for example ["casserole","throw"] |
Must be an array; only known gift ids with room left (needed - reserved >= 1), without duplicates. Saved back if anything was dropped. |
To reset, delete the keys in the browser's developer tools (Application → Local Storage). Change the :v1 suffix
(DRAFT_KEY or KEY in main.ts) if you change the data shape, so old values are ignored.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>, which writes<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card.<html lang="en-GB">is set in the layout. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://vows.example.com). Set it when you build:shSITE_URL=https://your-domain.com pnpm buildCanonical URLs have no
.html(https://your-domain.com/schedule). The 404 page passesnoindex. -
A wedding site usually shouldn't be indexed. To keep it out of search results, pass
noindexto<Layout>on every page, or add apublic/robots.txtwithDisallow: /.
Accessibility#
Built in:
- Skip link, landmarks, one
h1per page andaria-current="page"in the navigation. - Visible focus outlines (terracotta) and AA contrast.
- Mobile menu with
aria-expanded; Escape closes it and returns focus, and links close it. - RSVP: labelled radio groups, an error summary (
role="alert"), errors linked witharia-describedby, focus moved to each step's heading or the first problem, andaria-current="step"in the progress list. - Native
<dialog>lightbox,<details>FAQ,aria-pressedtoggles,role="timer"countdown and polite live regions for counts and toasts. - Reduced motion turns off reveals and smooth scrolling.
When editing, keep one h1 per page, label every new field, and give new gallery images a meaningful caption (it's
used as the alt text in the lightbox).
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as schedule.html rather than schedule/index.html
(build.format: "file" in astro.config.mjs), and links point to /schedule without a trailing slash.
- 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/scheduleservesschedule.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/scheduletoschedule.html, changebuild.formatto"directory"inastro.config.mjs.
Serve it over https:// so copy buttons work. To serve the site from a sub-folder, set DEMO_BASE_PATH when
building (DEMO_BASE_PATH=/wedding pnpm build); every link written with url() 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#
- "We couldn't find that invitation." The code or full name doesn't match an entry in
invites; names must be the full name as written invows.ts(accents and case don't matter). - Calendar events are an hour out. Check the UTC offset on each
startandendinevents(+01:00in British summer,+00:00in winter). - The countdown shows the old date. Update the
data-countdownattribute insrc/pages/index.astroas well ascouple.ceremony_iso. - A guest's restored reply is missing answers. Drafts are validated against the current guest list; renamed guest ids or removed meals are dropped.
- Copy buttons only show the code. Clipboard access needs
https://orlocalhost. - Images or links break under a sub-folder. Build with
DEMO_BASE_PATHand write paths withurl(). /scheduleshows a 404 on my host. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Shared links show the wrong domain. Set
SITE_URLat build time.
License#
MIT, like every Freeframe template: free for personal and commercial projects, no attribution required. The
botanical illustrations and social image are included under the same license. See LICENSE.txt in the download.
Names, places and people are fictional.