Overview#
Vows is a wedding website for Amara & Theo, a fictional couple marrying at Hollowridge House in Rhinebeck, New
York, on Saturday, June 19, 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 Nuxt 4 version (Vue 3, TypeScript, Tailwind CSS v4, generated as a static site), 1.0.0.
Live demo: freeframe.dev/templates/vows
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm).
pnpm install
pnpm dev # http://localhost:3000Try the RSVP at /rsvp with a demo code such as ROSE-24, or a name such as Priya Shah.
| Script | What it does |
|---|---|
pnpm dev |
Development server with hot reload |
pnpm generate |
Static site in .output/public/ |
pnpm build |
Node.js server build in .output/ (pages still prerendered) |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint (@nuxt/eslint flat config) |
pnpm typecheck |
nuxt prepare, then vue-tsc |
pnpm build:demo |
Build for the freeframe.dev demo (base path /demos/nuxt/vows) into demo/ |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
app/pages/index.vue |
Names and date, <WeddingCountdown />, save the date with a calendar file, "Plan your weekend" links, story teaser, the weekend at a glance, RSVP call to action |
/story |
app/pages/story.vue |
Illustrated timeline |
/schedule |
app/pages/schedule.vue |
Friday, Saturday and Sunday events from events, with dress codes; a calendar file per event and for the whole weekend |
/travel |
app/pages/travel.vue |
Map with a selectable legend (places), train, car and shuttle, room blocks with copyable booking codes, local tips |
/rsvp |
app/pages/rsvp.vue |
<RsvpFlow />: find the invitation, reply for each guest, review, confirmation |
/registry |
app/pages/registry.vue |
<HoneymoonFund /> and <GiftRegistry /> (category filter, "Hide reserved gifts", reservations) |
/gallery |
app/pages/gallery.vue |
Nine illustrations with a <dialog> lightbox |
/faq |
app/pages/faq.vue |
Questions from app/data/faq.ts with a search box |
| 404 | app/error.vue |
Not-found page (generated as 404.html) |
Project structure#
vows/
├── nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
├── package.json scripts and dependencies
├── eslint.config.mjs ESLint flat config (@nuxt/eslint)
├── public/img/
│ ├── gallery-*.svg the nine gallery illustrations
│ ├── wreath.svg, sprig.svg, corner.svg botanical ornaments
│ ├── favicon.svg initials badge
│ ├── og-image.svg source of the social image
│ └── og-image.png social sharing image, 1200×630
└── app/
├── app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, keyed so a link to the open page restarts it
├── layouts/default.vue skip link, header, page, footer, toast, back-to-top button
├── error.vue 404
├── pages/ index, story, schedule, travel, rsvp, registry, gallery, faq
├── components/
│ ├── SiteHeader.vue main menu, RSVP button and the mobile menu
│ ├── SiteFooter.vue footer
│ ├── WeddingDate.vue a date that moves on with the demo dates
│ ├── WeddingCountdown.vue live countdown to the ceremony
│ ├── RsvpFlow.vue the RSVP flow (RsvpChoice.vue: one choice tile)
│ ├── GiftRegistry.vue gift list and reservations
│ ├── HoneymoonFund.vue fund form and progress
│ ├── ToastMessage.vue status messages
│ └── BackToTop.vue back-to-top button
├── composables/
│ ├── useEdition.ts the demo dates (dates, year, ceremony, events)
│ ├── useCalendar.ts add-to-calendar downloads
│ ├── usePageSeo.ts title, description, canonical and Open Graph tags
│ ├── useCurrentPage.ts aria-current for menu links
│ └── useAsset.ts files in public/ with the base path
├── data/
│ ├── vows.ts couple, events, days, meals, invites, gifts, fund, gallery, giftIcons
│ ├── faq.ts FAQ groups and answers
│ ├── places.ts map pins and legend
│ └── nav.ts header, mobile menu links
├── utils/ edition.ts (date shift), ics.ts, store.ts and safeStorage.ts, toast.ts, reference.ts
├── plugins/ edition, scroll reveals, same-page links, stable builds
├── router.options.ts query strings written like the HTML version
└── assets/
├── css/main.css Tailwind import, @font-face, @theme tokens and component styles
└── fonts/ Cormorant Garamond, Jost, Pinyon Script (Latin .woff2) and their OFL licensesCustomizing#
Brand and colors#
Design tokens are in the @theme block of app/assets/css/main.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 |
@theme {
--color-terra: #7a3e6b;
--color-sage-deep: #3e4f5a;
}The SVG ornaments, gallery illustrations and map have their colors inside the files (the map's pin colors are in
app/data/places.ts). If you change --color-paper, also change theme-color in app.head in nuxt.config.ts.
Typography#
Cormorant Garamond, Jost and Pinyon Script are self-hosted from app/assets/fonts/ with @font-face rules at the top
of main.css: the same Latin .woff2 files Google Fonts serves to the HTML version, so text sets exactly like it
(their SIL Open Font License texts are the *-OFL.txt files beside them). @theme maps them to --font-serif,
--font-sans and --font-script (the .script class used for the names). To swap one, add its .woff2 (and
license), change the @font-face rule and the family name in @theme. The map labels name Jost in their SVG
font-family attributes.
Content#
Page text is in each app/pages/<page>.vue; search for "Amara" to find every mention of the names. Data is in
app/data/vows.ts:
| Export | Type | 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 } |
Countdown, footer, calendar titles |
events |
VowsEvent[]: { id, day, title, start, end, place, address, dress, body } |
Schedule page and .ics files |
days |
{ id, label, date, title } |
Schedule day headings |
meals, kidsMeal |
Meal: { id, label, note } |
RSVP dinner choices (kidsMeal is offered first to guests with child: true) |
invites |
Invite[]: { code, label, plusOne, guests: Guest[] }, Guest = { id, name, child? } |
RSVP lookup |
registryCategories |
["Kitchen", "Home", "Garden", "Experiences"] |
Registry filter buttons |
gifts |
Gift[]: { id, name, category, price, needed, reserved, icon, note } |
Gift list (icon is a key of giftIcons) |
fund |
{ title, destination, goal, raised, amounts: { value, label }[] } |
Honeymoon fund |
gallery |
Photo[]: { slug, title, caption, shape: "tall" | "wide" } |
Gallery and lightbox |
giftIcons |
Record<string, string> of SVG path markup |
Gift card icons |
The schedule page is rendered from events and days, so the page and the calendar files always agree. Times
include their UTC offset (-04:00 is Eastern Daylight Time), so the countdown and calendar files are right for
guests anywhere; the schedule shows the local time written in each start and end. Use your venue's offset on the
wedding date.
Add an invitation to invites. code is matched ignoring case, spaces and punctuation; guest ids 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). Its category
should be one of registryCategories:
{ id: "teapot", name: "Stoneware teapot", category: "Kitchen", price: 48, needed: 1, reserved: 0, icon: "pot", note: "For long Sunday breakfasts." },FAQ questions are in app/data/faq.ts; write a date inside an answer as { date: "2027-03-01" } so it moves on with
the demo dates.
Demo dates
The demo is written for the weekend of June 18–20, 2027. While KEEP_DEMO_CURRENT in app/data/vows.ts is true and
that weekend is over (after the last event in events ends), every date moves to the same weekend of the next year, by
whole weeks so weekdays never change (Saturday, June 19, 2027 becomes Saturday, June 17, 2028): the countdown, the
calendar files, the RSVP and room-block deadlines, the schedule, the year and the date in the home page title.
app/utils/edition.ts works out the shift; app/plugins/edition.client.ts applies it once the page has hydrated, so
the generated HTML shows the dates as written and there is no hydration mismatch. Pages read it through
useEdition() and <WeddingDate iso="2027-04-01" /> (format="dm" for "June 18"). 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, referenced asasset(`img/gallery-${p.slug}.svg`)inapp/pages/gallery.vue. Replace them with photos and keep the file names, or change the extension there.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. - Map: the illustrated map is SVG in
app/pages/travel.vue; its pins and legend come fromplacesinapp/data/places.ts(xandyin the map's 900 × 520 coordinates).
Navigation, header and footer#
NAV in app/data/nav.ts lists the desktop links; MOBILE_NAV adds Home and RSVP for the mobile menu. The RSVP
button sits beside the nav. The footer is app/components/SiteFooter.vue. To add a page, create
app/pages/<page>.vue calling usePageSeo({ title, description }), add its route to routes in nuxt.config.ts,
and add it to NAV.
Features#
Countdown#
app/components/WeddingCountdown.vue counts down to the ceremony (couple.ceremony_iso) every second
(role="timer"). The generated HTML shows placeholders (000, 00); the browser fills in the time after hydration.
At zero it shows "Today's the day".
Add to calendar#
Buttons with data-ics call useCalendar(): "ceremony" (any event id) downloads one event, "all" the
weekend, and a toast confirms it. buildIcs() in app/utils/ics.ts writes an RFC 5545 file: DTSTART/DTEND in
UTC (20270619T190000Z), backslashes, semicolons, commas and new lines escaped, lines folded at 75 octets.
SUMMARY is "couple.names: event title", LOCATION the address and DESCRIPTION the body plus the dress code.
The PRODID and the UID domain (@amara-and-theo.example) in ics.ts and the file names in useCalendar.ts
(amara-and-theo-wedding-weekend.ics, amara-and-theo-<id>.ics) are written out; change them to your names.
RSVP#
app/components/RsvpFlow.vue, in four steps (find, details, review, done) shown in a progress list with
aria-current="step":
- Find: the guest types the code or any guest's full name.
findInvite()compares both ignoring case, accents, spaces and punctuation, sorose 24andInes Whitakermatch. - Details: per guest, attending yes/no and, when attending, a dinner choice and dietary needs (
mealsFor()addskidsMealfor children). Invitations withplusOne: trueoffer a guest with name, meal and dietary fields. Then an optional song request and note, and a required email. "Not you?" and Back start again. - Review: a summary with Edit reply and Send reply.
- Done: a thank-you that confirms the reply, names the email the confirmation goes to and shows a short
reference (
AT-and four characters, made in the browser bymakeRef()inapp/utils/reference.ts), and an "Add the weekend" calendar button if anyone is coming.
validate() requires an answer for every guest, a meal for every attending guest, a name and meal for a plus-one,
and a valid email (EMAIL). Problems are listed in a summary and on each field, and focus moves to the first one.
Text is capped by LIMITS (dietary 300, name 80, song 120, note 500, email 254). Every change is saved as a draft (see
Saved data) and restored, with a notice, when the guest finds the same invitation again.
Each step change moves focus to the step's heading; nothing is focused when the page loads.
Privacy: invites is bundled into the page's JavaScript, so the whole guest list is public. For a real wedding,
look invitations up on a server (see Forms).
Registry#
app/components/GiftRegistry.vue shows gifts with category buttons (registryCategories, aria-pressed), a
"Hide reserved gifts" checkbox and a live count. Reserve marks a gift as reserved by this visitor ("Reserved by
you", with Undo); when reserved plus the visitor's reservation reaches needed, it's fully reserved.
Reservations are kept 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#
app/components/HoneymoonFund.vue. Guests choose one of fund.amounts or type a whole amount from $5 to $5,000, add
their name (required) and an optional message. On success the raised total and progress bar update for this visit
and a thank-you names the matching treat, notes the gift under an AT- reference and says the payment details are on
the card inside the invitation. No payment is taken on the site; change that sentence to match how you collect
gifts, or send guests to your payment page or provider (the // Demo only comment in submit()).
Travel map#
On app/pages/travel.vue, the legend buttons (aria-pressed) select or clear a place and are the
keyboard-accessible control; the selected pin grows (is-active), and pins can be clicked too.
Booking codes#
The copy buttons beside each room block copy the code and confirm with a toast; where copying isn't allowed, the toast shows the code instead.
Gallery lightbox#
app/pages/gallery.vue: each photo button opens a native <dialog> with showModal() (focus trapped, Escape closes,
focus starts on Close). It shows the image, title, caption and "N of 9"; Left/Right arrows and the Previous/Next
buttons wrap round gallery, clicking the backdrop closes it, and focus returns to the photo.
FAQ search#
app/pages/faq.vue. Matching questions open, others and empty groups hide, and a live region reports the count;
clearing the search restores what was open.
Header, mobile menu and scroll reveals#
The header turns solid once the page scrolls. The mobile menu (aria-expanded) covers the page, which stops
scrolling behind it; Escape closes it and returns focus to its button, and it closes after a link and at desktop
width. .reveal elements fade in as they scroll into view (app/plugins/reveal.client.ts); a head script adds the
js class to <html>, so content stays visible without JavaScript, and reduced motion shows everything at once.
Links to the same page#
In the HTML version every link loads a page. Here links are client-side navigations, so a link to the page already
showing (Home on the home page, a footer link to the current page) restarts the page from the top
(app/plugins/same-page.client.ts and the page key in app/app.vue).
Toasts and back to top#
- Toasts: call
toast("…")(app/utils/toast.ts) from an event handler; shown for 2.8 seconds in arole="status"region. - Back to top:
BackToTop.vueappears after about one screen, scrolls up (instantly with reduced motion), moves focus to the main content, lifts above toasts anddata-back-to-top-avoid, hides overdata-back-to-top-hideand while the mobile menu is open.
Forms#
None of the forms send anything until you connect them, although their confirmations read as if they had.
| Form | File | Where to connect it |
|---|---|---|
| RSVP | app/components/RsvpFlow.vue |
send() (the comment starting // To connect it) |
| Honeymoon fund | app/components/HoneymoonFund.vue |
submit() (the comment starting // Demo only) |
| Gift reservations | app/components/GiftRegistry.vue |
reserve() |
The RSVP sends collect(), typed as Reply:
{
"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, make send post it and only continue when it succeeds:
async function send() {
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(collect()),
});
if (!res.ok) return toast("Sorry, your reply could not be sent. Please try again.");
store.remove(DRAFT_KEY);
reference.value = makeRef("AT");
showStep("done");
}The confirmation reads as if the reply was received: it says a confirmation is on its way to the guest's email and
shows the AT- reference (change the prefix to your initials, or show your form service's own reference if it returns
one). Make sure your service really sends that email, or reword it.
Server option: build with pnpm build instead of pnpm generate (see Building and
deploying) and add a server route, which also lets you keep the guest list private:
// server/api/rsvp.post.ts
export default defineEventHandler(async (event) => {
const reply = await readBody(event);
// check reply.code against your guest list, then save the reply (database, Google Sheet, email)
return { ok: true };
});Post to /api/rsvp from send(). Add a second route (for example server/api/invite.get.ts) that returns one
invitation, call it from onFind() instead of findInvite(), and remove invites from app/data/vows.ts.
Saved data (localStorage)#
Every read and write goes through store in app/utils/store.ts (JSON on top of safeStorage): blocked or full
storage falls back to memory for the page view, and a value that isn't valid JSON is deleted. Storage is only read in
the browser, after the page has loaded.
| Key | Shape | Validation on read |
|---|---|---|
vows:rsvp-draft:v1 (DRAFT_KEY in RsvpFlow.vue) |
Reply (above) |
readDraft(): must be an object whose code is a known invitation (otherwise removed) and 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 (KEY in GiftRegistry.vue) |
Array of gift ids | readReserved(): 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 if
you change the data shape, so old values are ignored.
SEO and sharing#
- Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts): the title and description, a canonical URL, Open Graph tags (withog-image.png, 1200×630) and asummary_large_imageTwitter card. The 404 page passesnoindex: true. - Set
SITE_URLwhen building so canonical and image URLs are absolute and point at your domain (otherwise they usehttps://amara-and-theo.example):
SITE_URL=https://your-domain.com pnpm generate- A wedding site usually shouldn't be indexed. Add
{ name: "robots", content: "noindex, nofollow" }tometainapp.headinnuxt.config.ts, or apublic/robots.txtthat disallows everything.
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 it closes after navigating. - RSVP: labeled radio groups, an error summary, errors linked with
aria-describedby, focus moved to each step's heading or the first problem, andaria-current="step"in the progress list. - Native
<dialog>lightbox,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).
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route in routes in nuxt.config.ts as <page>.html (story.html, linked as
/story), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt
loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub
Pages, S3, Vercel). Hosts that don't map /story to story.html on their own need that rewrite (Vercel:
"cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/wedding pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still
prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load;
replace it with typescript@7 once they support it.
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(-04:00in Eastern summer,-05:00in winter). - The dates show a year later than you wrote. The wedding weekend is over and
KEEP_DEMO_CURRENTis stilltrue(see Demo dates). - Images or links break in a sub-folder. Build with
DEMO_BASE_PATH, and write files inpublic/withuseAsset()(NuxtLinkadds the base path itself). - Copy buttons only show the code. Clipboard access needs
https://orlocalhost. - Shared links show the wrong domain. Set
SITE_URLto your domain when building. - A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and
at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Cormorant
Garamond, Jost and Pinyon Script are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt).
Names, places and people are fictional.