Freeframe

Documentation

Lens

v1.0.4Updated Oct 18, 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 Lisbon. It has a full-bleed slideshow home page, filterable galleries with a masonry layout and a lightbox, photo stories, packages with a live price calculator, a client proofing page for choosing album favourites and an enquiry form. The 49 "photographs" are original SVG illustrations ready to be swapped for your own. This is the Next.js 16 version (App Router, React 19, TypeScript, Tailwind CSS v4, static export), 1.0.1.

Live demo: freeframe.dev/templates/lens

Quick start#

Requirements: Node.js 24 and pnpm (npm works too).

sh
pnpm install
pnpm dev        # http://localhost:3000
Script What it does
pnpm dev Development server with hot reload
pnpm build Static export to out/
pnpm start Serve out/ locally (npx serve out)
pnpm lint ESLint (Next.js core-web-vitals and TypeScript rules)
pnpm typecheck next typegen then tsc --noEmit (TypeScript 7)
pnpm build:demo Build for the freeframe.dev demo (base path /demos/nextjs/lens) into demo/

Pages#

Route File Purpose and notable features
/ src/app/page.tsx <HeroSlideshow />, introduction and stats, recent work (GalleryCard), selected frames, testimonials, availability
/portfolio src/app/portfolio/page.tsx <PortfolioFilter />: category filter with counts, shareable as /portfolio#weddings
/gallery src/app/gallery/page.tsx One gallery chosen with ?g=<slug>: masonry grid, <Lightbox />, previous/next gallery
/stories src/app/stories/page.tsx Journal list from STORIES (featured first story, then cards)
/story src/app/story/page.tsx A long-form photo story with captions, a pull quote and "Keep reading"
/packages src/app/packages/page.tsx <PackageCalculator />: package cards, extra hours, add-ons, total and deposit; FAQ
/proofing src/app/proofing/page.tsx <ProofingBoard />: choose up to 15 favourites, saved in localStorage
/contact src/app/contact/page.tsx <EnquiryFormFromQuery />: enquiry form, package prefill from ?package=
404 src/app/not-found.tsx Not-found page (exported as 404.html)

Project structure#

lens/
├── next.config.ts              static export, basePath from DEMO_BASE_PATH
├── package.json                scripts and dependencies
├── eslint.config.mjs           ESLint flat config
├── postcss.config.mjs          Tailwind CSS v4 PostCSS plugin
├── public/img/
│   ├── photos/                 49 SVG photo stand-ins, named <gallery-slug>-NN.svg
│   ├── og-image.svg            source of the social image
│   └── og-image.png            social sharing image, 1200×630
└── src/
    ├── app/
    │   ├── layout.tsx          fonts, metadata defaults, skip link, header, footer, reveals, back-to-top
    │   ├── globals.css         Tailwind import, @theme tokens and component classes
    │   ├── icon.svg            favicon
    │   ├── page.tsx            home
    │   ├── not-found.tsx       404
    │   └── <route>/page.tsx    portfolio, gallery, stories, story, packages, proofing, contact
    ├── components/
    │   ├── site-header.tsx     header (transparent over the home hero), nav, mobile menu
    │   ├── site-footer.tsx     footer with NAV links, email and social links
    │   ├── hero-slideshow.tsx  home slideshow
    │   ├── portfolio-filter.tsx  category filter and gallery grid
    │   ├── gallery-card.tsx    gallery card used on home and portfolio
    │   ├── gallery-view.tsx    GalleryFromQuery (reads ?g=) and GalleryView
    │   ├── lightbox.tsx        <dialog> photo viewer
    │   ├── photo.tsx           <Photo id="…"> image with size and alt from the data
    │   ├── package-calculator.tsx  package cards and estimate
    │   ├── proofing-board.tsx  proofing grid, bar and actions
    │   ├── enquiry-form.tsx    EnquiryForm and EnquiryFormFromQuery
    │   ├── page-hero.tsx       kicker, h1 and intro used at the top of inner pages
    │   ├── reveal-controller.tsx  scroll reveals
    │   ├── back-to-top.tsx     back-to-top button
    │   └── icons.tsx           small SVG icons
    ├── fonts/                  Bodoni Moda and Hanken Grotesk (Latin .woff2) with their OFL licences
    ├── data/
    │   ├── lens.ts             galleries, hero slides, stories, testimonials, packages, add-ons, proofing set
    │   └── nav.ts              NAV items
    └── lib/
        ├── asset.ts            asset(): prefixes public/ paths with the base path
        ├── format.ts           euro() and longDate()
        ├── metadata.ts         pageMetadata()
        ├── proofing.ts         PROOF_KEY, loadSelection(), saveSelection()
        └── use-reduced-motion.ts  useReducedMotion() hook

Customising#

Brand and colours#

Design tokens are in the @theme block of src/app/globals.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 themeColor in the viewport export of src/app/layout.tsx. Dark sections use the .on-dark class, which switches focus outlines to sand.

Typography#

Bodoni Moda (headings) and Hanken Grotesk (body) are self-hosted with next/font/local in src/app/layout.tsx (bodoni with the --font-bodoni variable, hanken with --font-hanken), from the Latin .woff2 files in src/fonts/: the same 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 (and licence) to src/fonts/ and point src at it, or import another font from next/font/google with the same variable name:

tsx
import { Cormorant_Garamond } from "next/font/google";
const bodoni = Cormorant_Garamond({ variable: "--font-bodoni", subsets: ["latin"], weight: ["400", "600"], style: ["normal", "italic"] });

Content#

Almost all content is in src/data/lens.ts:

Export Shape Used by
SITE, EMAIL strings Titles, metadata, footer and contact
CATEGORIES Category[] ("Weddings" | "Portraits" | "Travel") Portfolio filter buttons
GALLERIES Gallery[]: { slug, title, category, location, date, summary, photos: Photo[] } Portfolio, gallery pages, lightbox
Photo { id, w, h, caption, alt } Every image
HERO [gallery slug, photo index][] Home slideshow slides
STORIES { slug, title, date, category, excerpt, cover, read } Stories list (date is ISO, cover a photo id)
TESTIMONIALS { quote, name, role } Home page
PACKAGES { id, name, price, hours, kind, featured?, items[] } Package cards, calculator, contact select
ADDONS { id, name, price, note } Calculator add-ons
INCLUDED Record<package id, addon id[]> Add-ons a package already includes
EXTRA_HOUR, DEPOSIT 280, 0.3 Calculator
PROOF_LIMIT, PROOF_IDS 15, photo ids Proofing board

photoById(id) and photoAt(slug, index) look photos up and throw on an unknown id, so typos fail the build.

Add a gallery: put the images in public/img/photos/ and add an object to GALLERIES. category must be one of CATEGORIES (add a new category there to get a new filter button):

ts
{
  slug: "lisbon-portraits",
  title: "Lisbon Portraits",
  category: "Portraits",
  location: "Lisbon, Portugal",
  date: "May 2026",
  summary: "Street portraits in Alfama on a bright spring morning.",
  photos: [
    { id: "lisbon-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=lisbon-portraits automatically, and joins the previous/next chain. The home page's "Recent work" picks galleries by slug in src/app/page.tsx.

Packages: edit PACKAGES, ADDONS, INCLUDED, EXTRA_HOUR and DEPOSIT; cards, labels, the deposit percentage and the contact form's package options all follow. The package with featured: true is preselected and badged "Most booked".

Stories: STORIES drives the list; every entry links to /story, whose text is written in src/app/story/page.tsx (it uses STORIES[0] for its title and metadata). For several stories, turn story into a dynamic route such as src/app/stories/[slug]/page.tsx with generateStaticParams() and give each story a unique slug.

Images and artwork#

  • Photos: <Photo id="…" /> (src/components/photo.tsx) renders asset("/img/photos/<id>.svg") with the photo's w, h and alt. Replace the SVGs with your own JPEG or WebP images (about 2000px on the long edge) and change .svg in photo.tsx and in the two paths in lightbox.tsx (the image and the preload) if you switch format. Images are plain <img> tags because the static export doesn't optimise images.
  • 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). Hero slides fill the screen, so use landscape images with the subject away from the bottom left, where the headline sits.
  • Alt text: every Photo needs a real alt; pass alt="" 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: src/app/icon.svg.

NAV in src/data/nav.ts drives the desktop nav, the mobile menu and the footer "Explore" links. Each item has match, the routes that mark it as the current page (aria-current="page"):

ts
{ href: "/journal", label: "Journal", match: ["/journal"] },

On / the header is transparent over the slideshow (data-overlay) and turns solid when you scroll; see overlay in src/components/site-header.tsx. Social links in site-footer.tsx point to #; replace them with your profiles.

The years in "Now booking 2027 weddings" (the menu), "Now booking 2027" (the footer), "A few 2027 dates are still open" (home) and "2027 availability" (contact) are <Year ahead={1} fallback={2027} /> and show next year; the footer copyright is <Year fallback={2026} /> (src/components/year.tsx). The static build shows the fallback and the browser shows the real year, so the demo never advertises a season that has passed. To show fixed years, replace them with plain text.

Features#

Hero slideshow#

src/components/hero-slideshow.tsx, fed by HERO. Slides crossfade every 6.5 seconds; the caption ("Gallery · Location") follows the active slide, the dot buttons (aria-current) jump to a slide and the toggle pauses or plays. Inactive slides are aria-hidden. The caption is only announced to screen readers when the visitor picks a slide with a dot (the announce state switches aria-live to polite); autoplay changes are silent so the slideshow doesn't interrupt every 6.5 seconds. useReducedMotion() starts it paused when the visitor prefers reduced motion; their own Play/Pause choice then wins. Change the interval in setInterval(…, 6500).

Portfolio filter#

src/components/portfolio-filter.tsx. Buttons for all and each of CATEGORIES (lowercased) show their counts, use aria-pressed and filter the cards; a polite live region gives the number shown. The choice is mirrored in the URL hash with history.replaceState, and /portfolio#travel opens filtered (it also follows hashchange).

src/components/gallery-view.tsx. GalleryFromQuery reads ?g=<slug> with useSearchParams() (inside <Suspense> in src/app/gallery/page.tsx) and falls back to the first gallery. The static page is pre-rendered with the first gallery, so it works and is indexable before the query is read. GalleryView renders the heading, details, a CSS-columns masonry grid (.masonry: 1, 2 and 3 columns) of lazy-loaded photos, previous/next gallery links and the lightbox, and updates document.title.

src/components/lightbox.tsx, built on <dialog> with showModal(), so focus is trapped and Escape closes it. Focus starts on Close and returns to the photo that opened it. Left/Right arrows, the previous/next buttons and a 50px swipe (touch and pen) move through the gallery, wrapping round; the counter is a live region; clicking the dark area around the photo closes it; the next photo is preloaded.

Package calculator#

src/components/package-calculator.tsx. The package cards are radio buttons; the − / + buttons add 0–6 extra hours (MAX_EXTRA) at EXTRA_HOUR; add-ons add their price. Add-ons listed in INCLUDED for the chosen package are ticked, disabled and labelled "Included in …", and aren't charged. The summary, total (live region) and deposit (total × DEPOSIT) update as you choose. Enquire with this estimate links to /contact?package=…&hours=…&addons=….

Client proofing#

src/components/proofing-board.tsx shows every id in PROOF_IDS with a heart toggle (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, All / Selected views, Clear and Send selection, which shows a confirmation and moves focus to it. Changing the selection afterwards (here or in another tab) hides the confirmation again, since it no longer matches. The selection is read after hydration (the pre-rendered page starts empty), saved on every change and kept in sync across tabs through the storage event. Storage is handled in src/lib/proofing.ts (see Saved data).

Scroll reveals#

src/components/reveal-controller.tsx adds is-in to .reveal elements as they enter the viewport; when one reveals, every .reveal before it does too. A MutationObserver picks up content rendered later (such as a gallery chosen from the URL), and it re-runs on every route change. With reduced motion everything shows immediately. The inline script in layout.tsx adds the js class so content is only hidden while JavaScript runs.

Back-to-top button#

src/components/back-to-top.tsx 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 layout.tsx to drop it.

Forms#

The enquiry form doesn't send anything until you connect it. src/components/enquiry-form.tsx validates in validate():

Field Rule
c-name (name) Required
c-email (email) Required, valid email
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; preselected by EnquiryFormFromQuery from ?package=, which also sets the occasion
c-message (message) Required, at least 20 characters

Errors appear under each field (aria-invalid, aria-describedby) on submit and when leaving a filled field, and the first invalid field gets focus. On success the data-success box is shown and focused.

To receive enquiries with Formspree, make onSubmit async and replace the comment above setSent(true):

tsx
const onSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  // …validation as before…
  const res = await fetch("https://formspree.io/f/your-form-id", {
    method: "POST",
    body: new FormData(form.current!),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return alert("Sorry, your enquiry could not be sent. Please email the studio.");
  setSent(true);
  requestAnimationFrame(() => success.current?.focus());
};

Every field already has a name. Update the success text, which says it's a demo. The calculator's hours and addons parameters aren't read by the form; read them with useSearchParams() in EnquiryFormFromQuery if you want them in the enquiry.

Server option: with static export removed (see Building and deploying), add src/app/api/enquiry/route.ts:

ts
export async function POST(request: Request) {
  const data = await request.formData();
  // send data.get("email"), data.get("message")… to your email service or CRM
  return Response.json({ ok: true });
}

and post to /api/enquiry instead. A Server Action works too.

The proofing board's Send selection only shows a confirmation; send the selected ids to your endpoint in its onClick in proofing-board.tsx.

Saved data (localStorage)#

Key Shape Validation
lens-proofing-v1 (PROOF_KEY) JSON array of photo ids, for example ["amalfi-vows-01","fieldwork-03"] loadSelection() 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's stored, it's written back.

Reads and writes are wrapped in try/catch, so blocked or full storage 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#

  • src/app/layout.tsx sets metadataBase, a title template (%s — Ines Morrow Photography), the default description, Open Graph defaults and a summary_large_image Twitter card. Each page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts, which sets its title, description and Open Graph tags with og-image.png.
  • Set NEXT_PUBLIC_SITE_URL when building so Open Graph URLs are absolute (otherwise they point at https://inesmorrow.example):
sh
NEXT_PUBLIC_SITE_URL=https://your-domain.com pnpm build
  • /gallery has one set of metadata for every ?g= value, because a static export can't vary it by query. For a separate title and description per gallery, move it to a dynamic route such as src/app/gallery/[slug]/page.tsx with generateStaticParams() and generateMetadata().
  • For a sitemap and robots file, add src/app/sitemap.ts and src/app/robots.ts (both work with a static export when they export dynamic = "force-static"). For local SEO, consider LocalBusiness or ProfessionalService structured data with the studio address.

Accessibility#

Built in:

  • Skip link, landmarks, one h1 per page and aria-current="page" on the current nav item.
  • Visible focus outlines (rust on paper, sand on dark sections) and AA contrast.
  • Mobile menu with aria-expanded and aria-controls; Escape closes it.
  • Slideshow with a pause button, labelled dots and hidden inactive slides; it starts paused with reduced motion.
  • Lightbox on native <dialog> (focus trap, Escape, focus return) with labelled 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; the date hint stays linked.
  • Reduced motion turns off reveals, crossfade zooms 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 build      # static site in out/
pnpm start      # preview it locally

next.config.ts sets output: "export" (plain HTML, CSS and JS in out/) and images: { unoptimized: true }.

  • Vercel: import the repository; no settings needed. Add NEXT_PUBLIC_SITE_URL as an environment variable.
  • Netlify, Cloudflare Pages or any static host: build command pnpm build, publish directory out.
  • Sub-folder hosting: build with DEMO_BASE_PATH=/your/path pnpm build. It sets basePath and NEXT_PUBLIC_BASE_PATH, which asset() adds to image paths.

Need server features (Route Handlers, Server Actions, a real proofing back end)? Remove output: "export" and images: { unoptimized: true } from next.config.ts and deploy to Vercel or a Node.js host (next start).

TypeScript setup: package.json installs @typescript/native (TypeScript 7, which provides tsc for pnpm typecheck) and typescript as an alias of @typescript/typescript6, the TypeScript 6 API that typescript-eslint and parts of the Next.js toolchain still need. When typescript-eslint supports TypeScript 7, replace both packages with a single typescript@7.

Troubleshooting#

  • The build fails with Unknown photo … or Unknown gallery …. An id in HERO, STORIES, PROOF_IDS or a page doesn't match a photo or gallery in GALLERIES; fix the spelling.
  • useSearchParams() should be wrapped in a suspense boundary during build. Keep GalleryFromQuery and EnquiryFormFromQuery inside <Suspense>, as the gallery and contact pages do.
  • /gallery?g=my-slug shows the first gallery. The slug isn't in GALLERIES.
  • Images 404 in a sub-folder. Use asset() (or <Photo>) for files in public/, and build with DEMO_BASE_PATH.
  • Some proofing choices disappear on reload. Only ids in PROOF_IDS are kept; add new ids there.
  • pnpm typecheck can't find route types. Run it through the script (next typegen runs first), not tsc alone.
  • Shared links show no image. Set NEXT_PUBLIC_SITE_URL to your domain when building.

License#

MIT, like every Freeframe template: free for personal and commercial projects, no attribution required. The SVG photo stand-ins and the social image are included under the same license. See LICENSE.txt in the download. The photographer, clients and places in the copy are fictional.

Search documentation

Search the guides and every template's documentation