Freeframe

Documentation

Lens

v1.0.0Updated Nov 12, 2026An image-led portfolio for wedding, portrait and travel photographers.

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.

sh
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 texts

Customizing#

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

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

ts
{
  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="…" /> renders public/img/photos/<id>.svg (through usePhotoSrc()) with the photo's w, h and alt, lazy-loaded. Replace the SVGs with your own JPEG or WebP images (about 2000px on the long edge), update each photo's w and h, and change .svg in usePhotoSrc() in app/composables/useAsset.ts if 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-square tiles). 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 GALLERIES has an alt; pass alt="" to PhotoImg for decorative uses.
  • Social image: public/img/og-image.png (1200×630) comes from public/img/og-image.svg; edit the SVG and export a new PNG.
  • Favicon: public/img/favicon.svg, linked in nuxt.config.ts.

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

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

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:

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

sh
SITE_URL=https://www.yourstudio.com pnpm generate

The 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 h1 per page and aria-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-expanded and aria-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#

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

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

sh
DEMO_BASE_PATH=/lens 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#

  • pnpm generate fails with Unknown photo … or Unknown gallery …. An id in app/data/home.ts, stories.ts, proofing.ts or a page doesn't match a photo or gallery in GALLERIES; fix the spelling.
  • /gallery?g=my-slug shows the first gallery. The slug isn't in GALLERIES.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install (or pnpm typecheck) once; nuxt prepare writes it.
  • Links 404 after deploying. The host isn't mapping /portfolio to portfolio.html. Turn on clean URLs or add a rewrite.
  • Images or styles 404 in a sub-folder. Build with DEMO_BASE_PATH set to that folder, and use PhotoImg or useAsset() for files in public/.
  • Some proofing choices disappear on reload. Only ids in PROOFS are kept; add new photos there.
  • A new card stays invisible. Elements with the reveal class fade in from the reveal plugin; don't also give them a :class binding.
  • Shared links show no image. Set SITE_URL to 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.

Search documentation

Search the guides and every template's documentation