A Scrapbook on the Web: My Cousin's Wedding Site

My cousin Heilam (胡希琳) is getting married to Baoqi (黃寶琪) on November 1, 2026, at a hot-spring resort in Jiangxi. They needed an RSVP page. I wanted to make them a scrapbook — paper-grain backgrounds, masking-tape strips, two pug stickers wagging in the corners, the works.
This is the build log: how it started as one HTML mockup, grew into a full admin dashboard with drag-and-drop seating, picked up bilingual support along the way, and ended with a wedding planner that I'd actually want to use.
Live: https://sarahmichael.carlfung.dev
Source: https://github.com/carlfung1003/sarah-michael-site
The scrapbook aesthetic
The design started as a single self-contained HTML file in a handoff/ folder — every card with a 3px solid ink border and a chunky offset shadow in tomato, sage, or mustard. Cards rotated between −2.5° and +2.5°. Polaroid cards had yellow masking-tape strips made from ::before pseudo-elements. The body had a fixed radial-dot pattern plus paper-grain noise.
Nothing is straight. Everything has texture. The pugs wag asynchronously on different cadences so they never quite sync up:


The rule I held myself to: lift exact values from the mockup, not approximations. A 2.5° rotation reads cute; 3° reads off-balance. The wedding site has dozens of those small tuning decisions, and most of the magic is in not rounding them off.
The stack
- Next.js 16.2 (App Router, Turbopack) + React 19 + TypeScript
- CSS Modules — no Tailwind. The design needed too much custom rotation and offset-shadow work to fight a utility framework
@libsql/client→ Turso for RSVPs, seating, hotel rooms, and the wedding checklist- Resend for guest RSVP confirmation emails
- Vercel for hosting, git-connected so
push origin maindeploys - Playwright (added late) for regenerating the README screenshots — and, as it turned out, for capturing animated GIFs of the cool moments
Most of the heavy lifting was CSS. The JavaScript is reserved for things CSS alone can't do — scroll-triggered reveals, the countdown ticker, the invitation lightbox, the nav, the command palette.
The hot-spring venue

The word hot spring in the h2 is wrapped in a tomato-red highlight, and four white blurred steam puffs rise above it on a 3-second stagger. Steam wisps look like they're literally lifting off the highlighted text:

It's the kind of thing that takes 20 minutes to build and adds ten seconds of delight per visitor.
Travel guides — for guests flying in from the US

Most guests would be flying in from the US or coming up from Hong Kong, so the Travel section grew into five collapsible guides:
- From the US — SFO → HKG → high-speed rail to Ganzhou West
- From Hong Kong — MTR to West Kowloon, co-location customs, G902
- Alipay setup for US guests — Tour Pass, VISA binding, QR payments
- Explore Jiangxi — 7 destination cards (Wuyuan, Lushan, Jingdezhen…)
- Essential tips — currency, VPN, weather, train tickets, WeChat
The ★ "main event" stamp on the Schedule below it slams down on first reveal, then re-stamps itself on a 3.2s loop. A wedding-program stamp that won't stop stamping.
RSVP — joyfully YES

The joyfully YES button idles with a gentle bob. On hover, the whole button party-dances and the 🎉 popper goes from periodic shake into rapid blast mode:

The come? in the h2 has a continuous 1.8s bounce-and-rock. The form is fundamentally a POST endpoint to /api/rsvp — but it shouldn't feel like one.
Submitting saves a row to Turso. If the guest provided an email, a confirmation goes out via Resend a moment later, wrapped in a fire-and-forget try/catch so a delivery hiccup never fails the RSVP itself.
The email matches the site's palette — Caprasimo header, tomato/sage offset shadow border, a summary table of the answer, and a calendar reminder.
The admin dashboard
This is where the project quietly grew into something bigger than "a wedding page." Five tabs gated by HTTP Basic auth via src/proxy.ts (Next 16 renamed middleware to proxy, which I learned the day it broke).
RSVPs — stats + editable table

- Stat cards: replies received, heads confirmed, %-yes donut chart (pure SVG, no chart libs), progress bar against a 60-guest target
- Per-row ✏️ edit, 🗑 delete, ✉️ resend-confirmation buttons
+ Add RSVPbutton for manually-entered guests, with a "send email" checkbox- Optimistic UI everywhere — instant feedback, auto-revert on persist failure
Seating — banquet floor plan

Heilam sent me a mockup of how the actual venue lays out: 28 tables in two halves split by a central aisle, stage at top, gate at the bottom with a stepped path. So I rebuilt the seating chart to match.
Each table is a gold-ringed circle with the table number in serif. Guests drag in from a pool sidebar on the left. Drag-and-drop with optimistic state, overflow guards (alerts and rejects if a drop would exceed 15 seats), and a dropdown picker fallback on every card so it's mobile-friendly.
Hotel rooms — the same UX, simpler grid

Same drag-and-drop pattern as seating, but a flat auto-fill grid rather than a spatial layout — hotel rooms aren't geographic the way a banquet floor is. 20 rooms × 3 beds = 60 total. The pool sidebar shows each guest's hotel preference text from the RSVP form so you can tell who actually wanted a room.
Checklist — assignees and filters

Migrated from a static TypeScript file into Turso. Click a row to cycle Not Started → In Progress → Done. Eight assignees (the bride, groom, four bridesmaids, two more friends), each with a colored 2-letter avatar. Status pills + assignee chips both work as filters and compose with each other.
The race-safe seeding pattern was the most interesting plumbing — INSERT OR IGNORE INTO meta as a sentinel so concurrent SSR renders can't all run the initial seed in parallel. Self-healing dedup pass on every load picks up the pieces if something went sideways earlier (which it did, the first time).
Bilingual EN ↔ 中

Heilam's family is in mainland China and Hong Kong, but a chunk of the guest list is US-based. The site needed both English and Chinese.
I skipped next-intl and built a small React Context — single URL, instant swap, no per-locale routing. The dictionary lives in src/i18n/messages.ts with EN and ZH versions of every public string. Components call useLang() and reference m.hero.eyebrow, with TypeScript autocomplete on the keys.
A 中 / EN toggle pill sits in the Nav. First visit auto-detects browser language (zh-* → Chinese), then localStorage remembers the choice. <html lang> updates automatically.
The Travel section was the special case — those step-by-step guides contain embedded JSX (<strong>, <ol>, sub-lists) that doesn't fit a flat string dictionary. So EN and ZH versions of those arrays live inline in Travel.tsx, and the component picks based on lang. Imperfect, but pragmatic.
Command palette: / and ⌘K

A late addition. Press / (or ⌘K) anywhere to open a searchable palette listing every public section anchor and every admin page. Arrow keys to navigate, Enter to go, Esc to close. Two grouped sections (SITE and ADMIN) divided by a small dashed header — both selectable, the divider is just visual.
The spring-pop entrance + live filtering as you type:

A first-visit hint toast (PaletteHint.tsx) pops up in the bottom-right corner after page load, says / press to jump anywhere, and auto-dismisses after 8 seconds OR the moment the user discovers the shortcut. localStorage flag so it only shows once per browser. Hidden on touch-only devices.
The easter eggs
The parts I wouldn't surface reading the source top-down:
- 🥚 The lowercase "a" in Heilam on the hero is a real
<a href="/admin">anchor. No visual treatment — just sits in the middle of the name. Click it for the admin login. - 🌀 Each letter in
HeilamandBaoqiis individually hoverable — even-index tilts left, odd-index tilts right. Drag your cursor across the title for a wave:

- ⚡ The word fast in the Travel h2 leans into the wind:
skewX(-14deg), tomato-red, layered text-shadows trailing left as motion blur, vibrating on a 0.18s loop. - ❤️ The Story signature "love, H + B" and the Footer "see you in November ♡" both type out per character — each with its own inner
Revealwrapper so they fire when they enter the viewport, not when the parent does. Otherwise you'd scroll past a finished animation:

Documenting itself with Playwright
The README started bare. Once the project grew to ~15 routes + a half-dozen interactive features, words alone weren't enough.
First pass: scripts/screenshots.mjs. Launches headless Chromium, walks every section, saves PNGs to docs/screenshots/. Reads ADMIN_PASSWORD from .env.local so it can authenticate and capture the admin tabs. Honors prefers-reduced-motion so the CSS animations stop mid-cycle and the same frame gets captured every run.
Second pass — the more fun one — scripts/animations.mjs. For things that are an animation (letter wave, hovers, transitions), Playwright records the WebM video, then ffmpeg converts each clip to an optimized 12fps 64-color GIF at ~1MB each:
ffmpeg -i clip.webm -vf "fps=12,scale=560:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=64[p];[s1][p]paletteuse=dither=bayer:bayer_scale=4" -loop 0 clip.gif
The GIFs in this post are from that script. npm run animations regenerates the whole set in about 90 seconds, set REGEN=1 to redo individual clips. It's the closest thing to a video walkthrough that still renders inline in a GitHub README.
What I'd do differently
- Email template in Chinese: the confirmation email is still English-only. A guest who submits the form in 中 still gets the EN confirmation. Easy fix — pull the email copy into the same dictionary.
- The DNS wildcard gotcha: when Resend's Vercel auto-configure flow added the email DNS records on
send.sarahmichael.carlfung.dev, it shadowed the existing wildcard*.carlfung.devALIAS for the bare subdomain. The site went down briefly until I added back an explicitsarahmichaelCNAME. The DNS spec is clear here — a wildcard stops matching a name once any explicit record exists at or below it — but it's the kind of thing that bites once and you remember forever.
What's next
Heilam and Baoqi run the admin from here. Guests get the bilingual site and the styled confirmation emails. I keep an eye on the RSVP count and ship small things when the couple asks for them.
The full ship list is in the README, and the source is on GitHub if you want to lift any of the patterns — the race-safe seed, the banquet floor layout, the bilingual context, the command palette. Most of it's straightforward; the hard part was the rotations.
See you in November.