Overview#
Lens is an image-led portfolio for wedding, portrait and travel photographers, built for Ines Morrow Photography, a fictional photographer in Monterey, California. It has a full-bleed slideshow home page, filterable galleries with a masonry layout and a photo viewer, photo stories, packages with a live price calculator, a client proofing page for choosing album favorites and an inquiry form. The 49 "photographs" are original SVG illustrations ready to be swapped for your own. This is the Nuxt 4 version (Vue 3, TypeScript, Tailwind CSS v4, generated as a static site), version 1.0.0.
Template page and live demo: freeframe.dev/templates/lens
Quick start#
Requirements: Node.js 24 and pnpm.
pnpm install
pnpm dev # http://localhost:3000| 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 typecheck |
nuxt prepare then vue-tsc |
pnpm lint |
ESLint (@nuxt/eslint flat config) |
pnpm build:demo |
Build for the freeframe.dev demo (base path /demos/nuxt/lens) into demo/ |
pnpm install also runs nuxt prepare, which writes the generated types and ESLint config to .nuxt/.
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
app/pages/index.vue |
<HeroSlideshow />, introduction and key figures, recent work (GalleryCard), selected frames, kind words, availability |
/portfolio |
app/pages/portfolio.vue |
<PortfolioFilter />: category filter with counts, shareable as /portfolio#weddings |
/gallery |
app/pages/gallery.vue |
<GalleryView />: one gallery chosen with ?g=<slug>, masonry grid, <PhotoViewer />, previous and next gallery |
/stories |
app/pages/stories.vue |
The journal from STORIES: the featured newest story, then cards |
/story |
app/pages/story.vue |
A long-form photo story with captions, a pull quote and "Keep reading" |
/packages |
app/pages/packages.vue |
<PackageCalculator />: package cards, extra hours, add-ons, total and deposit; questions |
/proofing |
app/pages/proofing.vue |
<ProofingBoard />: choose up to 15 favorites, saved in localStorage |
/contact |
app/pages/contact.vue |
<InquiryForm /> with package prefill from ?package=, studio details |
| 404 | app/error.vue |
"Out of frame" page, generated as 404.html |
Project structure#
lens/
├── nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
├── package.json scripts and dependencies
├── pnpm-workspace.yaml a standalone project (not part of any parent workspace)
├── eslint.config.mjs ESLint flat config from @nuxt/eslint
├── tsconfig.json references the configs Nuxt writes to .nuxt/
├── public/img/
│ ├── photos/ 49 SVG photo stand-ins, named <gallery-slug>-NN.svg
│ ├── favicon.svg
│ ├── og-image.svg source of the social image
│ └── og-image.png social sharing image, 1200×630
└── app/
├── app.vue the layout and the page (keyed, so a link to the page showing restarts it)
├── error.vue 404 page
├── router.options.ts keeps query strings as written (?addons=second,prints)
├── layouts/default.vue skip link, header, page, footer, back-to-top button
├── pages/ one file per page (index, portfolio, gallery, stories, story, packages, proofing, contact)
├── components/
│ ├── SiteHeader.vue header (transparent over the home slideshow), nav, phone menu
│ ├── SiteFooter.vue footer with the page links, address and copyright year
│ ├── HeroSlideshow.vue home slideshow
│ ├── PortfolioFilter.vue category filter and gallery grid
│ ├── GalleryCard.vue a gallery in a list (home and portfolio)
│ ├── GalleryView.vue a gallery page, read from ?g=
│ ├── PhotoViewer.vue the <dialog> photo viewer
│ ├── PhotoImg.vue <PhotoImg id="…"> image with size and alt from the data
│ ├── StoryFigure.vue a captioned photo in the story
│ ├── PackageCalculator.vue package cards and estimate
│ ├── ProofingBoard.vue proofing bar, grid and confirmation
│ ├── InquiryForm.vue the inquiry form
│ └── BackToTop.vue back-to-top button
├── composables/
│ ├── usePageSeo.ts per-page title, description, canonical and Open Graph tags
│ ├── useCurrentPage.ts aria-current for the menus
│ ├── useAsset.ts useAsset() and usePhotoSrc(): public/ paths with the base path
│ └── useYear.ts this year, for the copyright and "Now booking" lines
├── data/
│ ├── galleries.ts GALLERIES (every photo with its size, caption and alt text)
│ ├── home.ts HERO_SLIDES, FEATURED, SELECTED_FRAMES, TESTIMONIALS
│ ├── stories.ts STORIES
│ ├── packages.ts PACKAGES, ADDONS, INCLUDED, prices and FAQS
│ ├── proofing.ts PROOFS, PROOF_LIMIT, PROOF_KEY
│ └── site.ts SITE, EMAIL, NAV
├── plugins/
│ ├── reveal.client.ts scroll reveals
│ ├── same-page.client.ts links to the page showing behave like a page load
│ ├── keep-hash.client.ts keeps the #hash exactly as opened (for the portfolio filter)
│ └── stable-build.server.ts reproducible builds
├── utils/
│ ├── format.ts money(), shortDate(), makeRef()
│ └── safeStorage.ts localStorage with an in-memory fallback
└── assets/
├── css/main.css Tailwind import, fonts, @theme tokens and component classes
└── fonts/ Bodoni Moda and Hanken Grotesk (Latin .woff2) with their OFL textsCustomizing#
Brand and colors#
Design tokens are in the @theme block of app/assets/css/main.css:
| Token | Default | Used for |
|---|---|---|
--color-paper |
#f5f2ec |
Page background |
--color-paper-2 |
#ebe6dd |
Sunken panels, image placeholders |
--color-ink |
#171512 |
Text, dark sections, solid buttons |
--color-ink-2 |
#24211d |
Raised dark surfaces |
--color-muted |
#5e574f |
Secondary text on paper |
--color-mist |
#b9b1a6 |
Secondary text on ink |
--color-line |
#dcd5ca |
Hairlines |
--color-rust |
#94492a |
Accent on paper (links, focus, selected proofs) |
--color-sand |
#e8c9a7 |
Accent on ink |
@theme {
--color-rust: #2f5d50;
--color-sand: #c8ddd3;
}If you change --color-paper, also change the theme-color meta tag in nuxt.config.ts and the translucent
header background (rgb(245 242 236 / .94)) in .site-header. Dark sections use the .on-dark class, which
switches focus outlines to sand.
Typography#
Bodoni Moda (headings, with its optical-size axis and italics) and Hanken Grotesk (text) are self-hosted from
app/assets/fonts/ with @font-face rules at the top of main.css: the same Latin 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-display and --font-sans. To swap one, add its .woff2 file (and license),
change the @font-face rule and the family name in the token:
@font-face {
font-family: "Cormorant Garamond";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("../fonts/CormorantGaramond-latin.woff2") format("woff2");
}
@theme {
--font-display: "Cormorant Garamond", Georgia, serif;
}Content#
Content lives in typed files in app/data/, imported with ~/data/<file>:
| File | Exports | Used by |
|---|---|---|
site.ts |
SITE, EMAIL, NAV |
Titles, header, phone menu, footer, contact |
galleries.ts |
GALLERIES (Gallery[]: { slug, title, category, location, date, summary, photos }), Photo ({ id, w, h, caption, alt }), photoById(), galleryBySlug() |
Portfolio, gallery pages, viewer, every photo |
home.ts |
HERO_SLIDES, FEATURED, SELECTED_FRAMES, TESTIMONIALS |
Home page |
stories.ts |
STORIES ({ title, date, category, excerpt, read, cover }) |
Stories, story page |
packages.ts |
PACKAGES, ADDONS, INCLUDED, EXTRA_HOUR, MAX_EXTRA_HOURS, DEPOSIT, FAQS, packageById() |
Packages page, inquiry form |
proofing.ts |
PROOFS, PROOF_IDS, PROOF_LIMIT, PROOF_KEY |
Proofing page |
photoById() and galleryBySlug() throw on an unknown id, so a typo fails pnpm generate instead of showing a
broken image.
Add a gallery: put the images in public/img/photos/ and add an object to GALLERIES. category is one of
"Weddings", "Portraits" or "Travel" (for another category, add it to the Gallery type and to CATEGORIES
in app/components/PortfolioFilter.vue):
{
slug: "monterey-portraits",
title: "Monterey Portraits",
category: "Portraits",
location: "Monterey, California",
date: "May 2026",
summary: "Street portraits on Cannery Row on a bright spring morning.",
photos: [
{ id: "monterey-portraits-01", w: 960, h: 1200, caption: "Ana, painter.", alt: "A woman in a doorway lit by morning sun" },
],
},It appears on /portfolio and at /gallery?g=monterey-portraits automatically, and joins the previous and next
chain. The first gallery in the list is the one /gallery shows without ?g=. The home page's "Recent work" picks
galleries and their covers in FEATURED in app/data/home.ts.
Packages: edit PACKAGES, ADDONS, INCLUDED, EXTRA_HOUR and DEPOSIT; the cards, the add-on notes, the
deposit percentage and the inquiry form's package list all follow. The package with featured: true is selected when
the page opens and has the "Most booked" badge.
Stories: STORIES drives the journal; every entry links to /story, whose text is written in
app/pages/story.vue (it takes its title, date and description from STORIES[0]). For several stories, make a
dynamic page such as app/pages/stories/[slug].vue, add a slug to each story and list every story's route in
routes in nuxt.config.ts.
Proofing: PROOFS in app/data/proofing.ts lists the shortlist (photo ids and the file names shown under them);
the intro text is in app/pages/proofing.vue.
Images and artwork#
- Photos:
<PhotoImg id="…" />renderspublic/img/photos/<id>.svg(throughusePhotoSrc()) with the photo'sw,handalt, lazy-loaded. Replace the SVGs with your own JPEG or WebP images (about 2000px on the long edge), update each photo'swandh, and change.svginusePhotoSrc()inapp/composables/useAsset.tsif you switch format. - Aspect ratios: galleries keep each photo's own ratio (masonry). Cards crop with fixed frames (
aspect-[4/3],aspect-[4/5]for the large home card,aspect-squaretiles). Slideshow photos fill the screen, so use landscape images with the subject away from the bottom left, where the headline sits. - Alt text: every photo in
GALLERIEShas analt; passalt=""toPhotoImgfor decorative uses. - Social image:
public/img/og-image.png(1200×630) comes frompublic/img/og-image.svg; edit the SVG and export a new PNG. - Favicon:
public/img/favicon.svg, linked innuxt.config.ts.
Navigation, header and footer#
NAV in app/data/site.ts drives the desktop nav, the phone menu (numbered 01–05) and the footer's "Explore" links.
Each item has section, the routes that mark it as the current page (aria-current="page"):
{ to: "/journal", label: "Journal", section: ["/journal"] },A new page is a new file in app/pages/ and a new entry in routes in nuxt.config.ts. On / the header lies
transparent over the slideshow (data-overlay) and turns solid once you scroll; see overlay in
app/components/SiteHeader.vue. The Instagram and Pinterest links in SiteFooter.vue point to #; replace them with
your profiles.
The years in "Now booking 2027 weddings" (the phone menu), "Now booking 2027" (the footer), "A few 2027 dates are
still open" (home) and "2027 availability" (contact) are {{ year + 1 }}, and the footer copyright is {{ year }},
from useYear() in app/composables/useYear.ts. The generated HTML shows 2026 and 2027; the browser's clock takes
over once the page has loaded, so the demo never advertises a season that has passed. Replace them with plain text to
show fixed years.
Features#
Hero slideshow#
app/components/HeroSlideshow.vue, fed by HERO_SLIDES. Slides crossfade every 6.5 seconds; the caption follows the
active slide, the dot buttons (aria-current) jump to a slide and restart the timer, and the round button pauses or
plays. Inactive slides are aria-hidden, and their photos load lazily until they first show. The caption is only
announced to screen readers when the visitor picks a slide (aria-live switches to polite); autoplay changes are
silent. Visitors who prefer reduced motion get it paused from the start; their own Play choice then wins. Change the
interval in setInterval(…, 6500).
Portfolio filter#
app/components/PortfolioFilter.vue. Buttons for all galleries and each category show their counts, use
aria-pressed and hide the other cards; a polite live region gives the number shown. The choice is written to the
URL hash with history.replaceState (no history entry), and /portfolio#travel opens filtered; it also follows
hashchange. An unknown hash shows every gallery. app/plugins/keep-hash.client.ts keeps the hash exactly as it was
opened, because Vue Router would otherwise decode it.
Gallery pages#
app/components/GalleryView.vue reads ?g=<slug> after the page loads and falls back to the first gallery. The
generated /gallery page shows the first gallery, so it works and is indexable before the query is read. It renders
the heading and facts, a CSS-columns masonry grid (.masonry: one, two and three columns) of lazy-loaded photos,
previous and next gallery links and the viewer, and sets the tab title and the description to the gallery shown.
Moving to another gallery starts the page afresh at the top, like a page load.
Photo viewer#
app/components/PhotoViewer.vue, built on <dialog> with showModal(), so focus stays inside it and Escape closes
it. Focus starts on the Close button and returns to the photo that opened it. The arrow keys, the previous and next
buttons and a 50px swipe (touch and pen) move through the gallery, wrapping around; the counter is a live region;
clicking the dark area around the photo closes it; the next photo is preloaded.
Package calculator#
app/components/PackageCalculator.vue. The package cards are radio buttons; the − and + buttons add 0–6 extra hours
(MAX_EXTRA_HOURS) at EXTRA_HOUR each; add-ons add their price. Add-ons listed in INCLUDED for the chosen
package are ticked, locked and labeled "Included in …", and aren't charged; switching to a package without them
unticks them again. The summary, total (a live region) and deposit (total × DEPOSIT) update as you choose.
Inquire with this estimate links to /contact?package=…&hours=…&addons=….
Client proofing#
app/components/ProofingBoard.vue shows every photo in PROOFS with a heart button (aria-pressed). Up to
PROOF_LIMIT can be chosen; one more shows a message in a status region. The sticky bar shows the count, a progress
bar and what is left, All / Selected views, Clear and Send selection, which shows a confirmation with a
short reference (IM- and four characters, from makeRef() in app/utils/format.ts) and moves focus to it. The
confirmation promises a first album layout within three weeks; change it to match how you work. Changing the selection
afterwards (here or in another tab) hides the confirmation again. The selection is read after the page loads (the
generated page starts empty), saved on every change and kept in sync across tabs through the storage event (see
Saved data).
Scroll reveals#
app/plugins/reveal.client.ts adds is-in to .reveal elements as they scroll into view; when one reveals, every
.reveal before it does too, so jumping down the page never leaves hidden content above. With reduced motion
everything shows at once. A head script in nuxt.config.ts adds the js class to <html>, so content is only hidden
while JavaScript runs. Never give a .reveal element a :class binding: Vue would drop the is-in class the plugin
adds.
Phone menu and header#
Below 1024px the menu button in SiteHeader.vue opens a full-screen menu: focus moves to its first link, the page
behind stops scrolling, Escape closes it and returns focus to the button, and widening the window or opening a page
closes it. The header gets a hairline border once the page scrolls.
Back-to-top button#
app/components/BackToTop.vue appears after you scroll about one screen, scrolls to the top (instantly with reduced
motion) and moves focus to the main content. Mark fixed UI with data-back-to-top-avoid (the button lifts above it)
or data-back-to-top-hide (it hides while overlapping). Remove <BackToTop /> from app/layouts/default.vue and the
.back-to-top styles from main.css to drop it.
Forms#
The inquiry form doesn't send anything until you connect it. app/components/InquiryForm.vue checks each field
in check():
| Field | Rule |
|---|---|
c-name (name) |
Required |
c-email (email) |
Required, a valid email address |
c-type (type) |
Required (wedding, elopement, portrait, travel, other) |
c-date (date) |
Required for weddings and elopements; not in the past (min is today) |
c-location (location) |
Optional |
c-budget (budget) |
Required |
c-package (package) |
Optional; ?package= preselects it and the occasion |
c-message (message) |
Required, at least 20 characters |
A field is checked when you leave it with a value, and again as you type once it shows an error; changing the
occasion re-checks the date. Messages appear under the field with aria-invalid and aria-describedby (the date
hint stays linked). Sending checks everything and moves focus to the first problem; on success the thank-you box is
shown and focused, with a short reference (IM- and four characters). It promises a reply within two business days,
like the intro on the page: change both if yours differs. Send another inquiry empties the form.
To receive inquiries with Formspree, post the form in onSubmit() before showing the thank-you box:
async function onSubmit() {
const ok = FIELDS.map(check).every(Boolean);
if (!ok) {
await nextTick();
form.value?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
return;
}
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(form.value!),
headers: { Accept: "application/json" },
});
if (!res.ok) return alert("Sorry, your inquiry could not be sent. Please email the studio.");
reference.value = makeRef("IM");
sent.value = true;
await nextTick();
success.value?.focus();
}Every field already has a name. The calculator's hours and addons parameters aren't read by the form; read them
from window.location.search in onMounted() if you want them in the inquiry. With pnpm build (a server build) you
can instead add a server route such as server/api/inquiry.post.ts and post to /api/inquiry.
The proofing board's Send selection only shows a confirmation; send selected.value to your endpoint in send()
in ProofingBoard.vue.
Saved data (localStorage)#
| Key | Shape | Validation |
|---|---|---|
lens-proofing-v1 (PROOF_KEY) |
JSON array of photo ids, for example ["catalina-vows-01","fieldwork-03"] |
load() in ProofingBoard.vue keeps only strings in PROOF_IDS, without duplicates, up to PROOF_LIMIT; anything else (not an array, corrupt JSON) gives an empty list. If the cleaned list differs from what is stored, it is written back. |
Storage goes through safeStorage (app/utils/safeStorage.ts), which keeps the selection in memory when storage is
blocked or full, so the page still works for the visit. Clear empties the selection; delete the key in the
browser's developer tools (Application → Local Storage) to remove it. Change PROOF_KEY (for example to
lens-proofing-v2) to start everyone fresh after replacing the shortlist.
SEO and sharing#
Each page calls usePageSeo({ title, description }) with its own title and description. It sets the description,
Open Graph and Twitter card tags, a canonical link and an absolute og:image. Absolute URLs are built from
SITE_URL (runtimeConfig.public.siteUrl in nuxt.config.ts) and the base path, so set it when you build:
SITE_URL=https://www.yourstudio.com pnpm generateThe generated /gallery page carries the first gallery's title and description; the browser tab follows the gallery
in ?g=. For a separate indexable page per gallery, make a dynamic page such as app/pages/gallery/[slug].vue and
list every gallery's route in routes. The 404 page is noindex. Consider adding a sitemap.xml and robots.txt to
public/, and LocalBusiness or ProfessionalService structured data with your studio's address.
Accessibility#
- A skip link, landmarks, one
h1per page andaria-current="page"on the current menu item. - Visible focus outlines (rust on paper, sand on dark sections) and AA contrast.
- A phone menu with
aria-expandedandaria-controls; Escape closes it and returns focus. - A slideshow with a pause button, labeled dots and hidden inactive slides; it starts paused with reduced motion.
- A photo viewer on native
<dialog>(focus kept inside, Escape, focus returned) with labeled buttons and a live counter. - Filter, view and proofing buttons use
aria-pressed; counts, totals and messages are live regions. - Form errors linked with
aria-describedby; focus moves to the first problem or the thank-you message. - Reduced motion turns off reveals, the crossfade zoom and smooth scrolling.
When editing, give every photo meaningful alt text, keep one h1 per page and label every new form field.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (portfolio.html,
linked as /portfolio), 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 /portfolio to portfolio.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=/lens 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#
pnpm generatefails withUnknown photo …orUnknown gallery …. An id inapp/data/home.ts,stories.ts,proofing.tsor a page doesn't match a photo or gallery inGALLERIES; fix the spelling./gallery?g=my-slugshows the first gallery. The slug isn't inGALLERIES.pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm install(orpnpm typecheck) once;nuxt preparewrites it.- Links 404 after deploying. The host isn't mapping
/portfoliotoportfolio.html. Turn on clean URLs or add a rewrite. - Images or styles 404 in a sub-folder. Build with
DEMO_BASE_PATHset to that folder, and usePhotoImgoruseAsset()for files inpublic/. - Some proofing choices disappear on reload. Only ids in
PROOFSare kept; add new photos there. - A new card stays invisible. Elements with the
revealclass fade in from the reveal plugin; don't also give them a:classbinding. - Shared links show no image. Set
SITE_URLto your domain when building.
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. Bodoni Moda and
Hanken Grotesk are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). The photographer, her
clients and the places in the copy are fictional.