Freeframe

Documentation

Vows

v1.0.0Updated Nov 12, 2026An elegant wedding website with RSVP, schedule, travel and registry.

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).

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

Try 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 licenses

Customizing#

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
css
@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:

ts
{
  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:

ts
{ 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 as asset(`img/gallery-${p.slug}.svg`) in app/pages/gallery.vue. Replace them with photos and keep the file names, or change the extension there. tall images are shown at 1200×1500 (4:5) and wide at 1200×900 (4:3).
  • Ornaments: wreath.svg, sprig.svg and corner.svg are decorative (alt="").
  • Social image: public/img/og-image.png (1200×630) comes from public/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 from places in app/data/places.ts (x and y in the map's 900 × 520 coordinates).

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":

  1. Find: the guest types the code or any guest's full name. findInvite() compares both ignoring case, accents, spaces and punctuation, so rose 24 and Ines Whitaker match.
  2. Details: per guest, attending yes/no and, when attending, a dinner choice and dietary needs (mealsFor() adds kidsMeal for children). Invitations with plusOne: true offer 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.
  3. Review: a summary with Edit reply and Send reply.
  4. 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 by makeRef() in app/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.

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.

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.

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 a role="status" region.
  • Back to top: BackToTop.vue appears after about one screen, scrolls up (instantly with reduced motion), moves focus to the main content, lifts above toasts and data-back-to-top-avoid, hides over data-back-to-top-hide and 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:

json
{
  "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:

ts
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:

ts
// 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 (with og-image.png, 1200×630) and a summary_large_image Twitter card. The 404 page passes noindex: true.
  • Set SITE_URL when building so canonical and image URLs are absolute and point at your domain (otherwise they use https://amara-and-theo.example):
sh
SITE_URL=https://your-domain.com pnpm generate
  • A wedding site usually shouldn't be indexed. Add { name: "robots", content: "noindex, nofollow" } to meta in app.head in nuxt.config.ts, or a public/robots.txt that disallows everything.

Accessibility#

Built in:

  • Skip link, landmarks, one h1 per page and aria-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, and aria-current="step" in the progress list.
  • Native <dialog> lightbox, aria-pressed toggles, 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#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt 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:

sh
DEMO_BASE_PATH=/wedding pnpm generate

pnpm 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 in vows.ts (accents and case don't matter).
  • Calendar events are an hour out. Check the UTC offset on each start and end in events (-04:00 in Eastern summer, -05:00 in winter).
  • The dates show a year later than you wrote. The wedding weekend is over and KEEP_DEMO_CURRENT is still true (see Demo dates).
  • Images or links break in a sub-folder. Build with DEMO_BASE_PATH, and write files in public/ with useAsset() (NuxtLink adds the base path itself).
  • Copy buttons only show the code. Clipboard access needs https:// or localhost.
  • Shared links show the wrong domain. Set SITE_URL to your domain when building.
  • A new page is missing from the generated site. Add its route to routes in nuxt.config.ts.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install or npx nuxt prepare first.

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.

Search documentation

Search the guides and every template's documentation