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).
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() hookCustomising#
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 |
@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:
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):
{
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) rendersasset("/img/photos/<id>.svg")with the photo'sw,handalt. Replace the SVGs with your own JPEG or WebP images (about 2000px on the long edge) and change.svginphoto.tsxand in the two paths inlightbox.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-squaretiles). Hero slides fill the screen, so use landscape images with the subject away from the bottom left, where the headline sits. - Alt text: every
Photoneeds a realalt; passalt=""for 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:
src/app/icon.svg.
Navigation, header and footer#
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"):
{ 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).
Gallery pages#
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.
Lightbox#
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):
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:
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.tsxsetsmetadataBase, a title template (%s — Ines Morrow Photography), the default description, Open Graph defaults and asummary_large_imageTwitter card. Each page exportsmetadata = pageMetadata(title, description)fromsrc/lib/metadata.ts, which sets its title, description and Open Graph tags withog-image.png.- Set
NEXT_PUBLIC_SITE_URLwhen building so Open Graph URLs are absolute (otherwise they point athttps://inesmorrow.example):
NEXT_PUBLIC_SITE_URL=https://your-domain.com pnpm build/galleryhas 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 assrc/app/gallery/[slug]/page.tsxwithgenerateStaticParams()andgenerateMetadata().- For a sitemap and robots file, add
src/app/sitemap.tsandsrc/app/robots.ts(both work with a static export when they exportdynamic = "force-static"). For local SEO, considerLocalBusinessorProfessionalServicestructured data with the studio address.
Accessibility#
Built in:
- Skip link, landmarks, one
h1per page andaria-current="page"on the current nav item. - Visible focus outlines (rust on paper, sand on dark sections) and AA contrast.
- Mobile menu with
aria-expandedandaria-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#
pnpm build # static site in out/
pnpm start # preview it locallynext.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_URLas an environment variable. - Netlify, Cloudflare Pages or any static host: build command
pnpm build, publish directoryout. - Sub-folder hosting: build with
DEMO_BASE_PATH=/your/path pnpm build. It setsbasePathandNEXT_PUBLIC_BASE_PATH, whichasset()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 …orUnknown gallery …. An id inHERO,STORIES,PROOF_IDSor a page doesn't match a photo or gallery inGALLERIES; fix the spelling. useSearchParams() should be wrapped in a suspense boundaryduring build. KeepGalleryFromQueryandEnquiryFormFromQueryinside<Suspense>, as the gallery and contact pages do./gallery?g=my-slugshows the first gallery. The slug isn't inGALLERIES.- Images 404 in a sub-folder. Use
asset()(or<Photo>) for files inpublic/, and build withDEMO_BASE_PATH. - Some proofing choices disappear on reload. Only ids in
PROOF_IDSare kept; add new ids there. pnpm typecheckcan't find route types. Run it through the script (next typegenruns first), nottscalone.- Shared links show no image. Set
NEXT_PUBLIC_SITE_URLto 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.