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 Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.1. It builds to static HTML files with no UI framework, and can be hosted anywhere. See it live at freeframe.dev/templates/lens.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/lens into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | Notable features |
|---|---|---|
/ |
src/pages/index.astro |
Full-bleed slideshow (data-hero) with captions, dots and pause, introduction and stats, recent work, testimonials, availability call to action |
/portfolio |
src/pages/portfolio.astro |
Category filter with counts, shareable as /portfolio#weddings |
/gallery |
src/pages/gallery.astro |
Masonry grid and lightbox; the gallery is chosen with /gallery?g=<slug>; previous/next gallery links |
/stories |
src/pages/stories.astro |
Story cards |
/story |
src/pages/story.astro |
A long-form photo story: full-width photos with captions, a pull quote and "Keep reading" |
/packages |
src/pages/packages.astro |
Package cards that double as the calculator's choice, extra hours, add-ons, total and deposit, FAQ |
/proofing |
src/pages/proofing.astro |
Client proofing (demo): choose up to 15 favourites, saved in localStorage; all/selected view, clear and send |
/contact |
src/pages/contact.astro |
Enquiry form with a conditional date rule and package prefill from ?package= |
| 404 | src/pages/404.astro |
Links back home and to the portfolio (most hosts show 404.html automatically) |
Project structure#
lens/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts, js-class script), skip link, header, footer, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro logo, desktop navigation, enquire button, mobile menu; transparent over the home hero
│ │ └─ SiteFooter.astro call to action, contact details, "Explore" links, copyright
│ ├─ data/
│ │ └─ lens.ts galleries, stories, packages, add-ons, extras, proofing set
│ ├─ lib/
│ │ └─ site.ts site name, theme colour and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts slideshow, filter, gallery pages, lightbox, calculator, proofing, enquiry form
│ │ └─ back-to-top.ts floating "Back to top" button
│ ├─ styles/
│ │ └─ global.css Tailwind theme (colours, fonts) and component classes (.masonry, .lightbox...)
│ └─ env.d.ts lets `<svg hidden>` type-check (used by the slideshow's play/pause icons)
├─ public/img/
│ ├─ photos/ 49 SVG photo stand-ins, named <gallery-slug>-NN.svg
│ └─ favicon.svg, og-image.png (and its og-image.svg source)
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonPages are rendered to HTML at build time. The two files in src/scripts/ (with the data they import) are the only
JavaScript sent to the browser, besides one inline line in the layout's <head> that adds the js class to
<html>. Layout.astro imports both scripts and Astro bundles them into one module.
Customising#
Brand and colours#
Design tokens are in the @theme block of src/styles/global.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 |
For example:
@theme {
--color-rust: #2f5d50;
--color-sand: #c8ddd3;
}Tailwind rebuilds automatically in pnpm dev and pnpm build. The browser theme colour is site.themeColor in
src/lib/site.ts; update it if you change --color-paper. Dark sections use the .on-dark class, which switches
focus outlines to sand.
Typography#
Bodoni Moda (headings, --font-display) and Hanken Grotesk (body, --font-sans) load from Google Fonts with a
<link> in src/layouts/Layout.astro. To swap, change that link and the font names in @theme:
--font-display: "Cormorant Garamond", ui-serif, Georgia, serif;Content#
Copy on most pages is markup in the .astro files. Everything the scripts render or calculate comes from
src/data/lens.ts:
| Export | Type / shape | Used by |
|---|---|---|
GALLERIES |
Gallery[]: { slug, title, category, location, date, summary, photos: Photo[] }; Photo is { id, w, h, caption, alt } |
/gallery and its lightbox |
PACKAGES |
Package[]: { id, name, price, hours, kind, featured?, items } (kind is "Wedding" or "Portrait") |
Calculator, contact prefill |
INCLUDED |
Record<package id, addon values[]> |
Add-ons a package already includes |
EXTRA_HOUR |
280 |
Price per extra hour |
DEPOSIT |
0.3 |
Deposit share of the total |
PROOF_LIMIT |
15 |
Maximum proofing selections |
PROOF_IDS |
photo ids | Ids accepted on the proofing page |
SITE |
"Ines Morrow Photography" |
Gallery page titles |
lens.ts also exports CATEGORIES, HERO, STORIES, TESTIMONIALS, ADDONS and the helpers photoById()
and photoAt(). The pages show the same content as written markup, so they're reference data here: use them if you
want to render those sections from data.
Add a gallery:
- Add an object to
GALLERIESwith a newslugand its photos. Each photoidis the file name inpublic/img/photos/without.svg;wandhare its pixel size (they set the aspect ratio and prevent layout shift).
{
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" },
],
},- Add a card to
src/pages/portfolio.astro(copy an<article data-category="portraits">and link it tourl("/gallery?g=lisbon-portraits")) and update the counts in the filter buttons. Thedata-categoryvalue must match adata-filterbutton (weddings,portraitsortravel).
src/pages/gallery.astro contains the first gallery as markup, so crawlers see real content; other galleries
replace it when the page loads. If you change the first gallery in GALLERIES, update that markup too.
Packages: the cards and the add-on list are in src/pages/packages.astro; each add-on is
<input type="checkbox" name="addon" value="album" data-price="680"> with a data-addon-note="album" line.
Prices in the calculation come from PACKAGES and the add-ons' data-price, but the prices shown on the cards, the
"(€280 each)" label, "Deposit to book (30%)" and the package options in src/pages/contact.astro are text, so update
them together.
Stories: src/pages/stories.astro lists the cards and src/pages/story.astro is the one story layout. Every
card links to /story; copy story.astro for each new story (for example src/pages/story-alentejo.astro) and
update the links.
Images and artwork#
- Photos: the SVGs in
public/img/photos/are illustrations, not photographs. Replace them with your own JPEG or WebP images (about 2000px on the long edge). The scripts build the path withphotoSrc()at the top ofsrc/scripts/main.ts(url(`/img/photos/${id}.svg`)), used by the gallery grid, the lightbox and preloading; change.svgthere if you switch format, and update the<img>tags in the pages. - Aspect ratios: galleries keep each photo's own ratio (masonry). Cards use fixed frames: portfolio cards
aspect-[4/3], the large home cardaspect-[4/5], square tilesaspect-square; photos are cropped withobject-cover. The hero slides fill the screen, so use landscape images with the subject away from the bottom left, where the headline sits. - Alt text: write a real
altfor every photograph, andalt=""only for decorative backgrounds. - 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.
Always write paths to files in public/ with url() from src/lib/site.ts (src={url("/img/photos/x.jpg")}) so
they work when the site is served from a sub-folder. For automatic resizing and WebP output, put photos in src/
and use Astro's <Image /> from astro:assets in the pages.
Navigation, header and footer#
src/components/SiteHeader.astroholds the desktop navigation, the enquire button and the mobile menu (#mobile-menu). A link getsaria-current="page"(which draws the underline) when the page'scurrentprop matches it;/storypassescurrent="stories".- On the home page
<Layout overlay>renders the header withdata-overlay: it sits transparently over the slideshow and turns solid once you scroll. Leaveoverlayout to make it solid from the start. src/components/SiteFooter.astrois the footer, including the "Explore" list.Layout.astrotakestitle,description,current,page(written to<body data-page>),overlayandnoindex.
To add a page, create src/pages/about.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="About — Ines Morrow Photography" description="Meet the photographer." current="about" page="about">
<section class="container-x pt-40 pb-24">
<h1 class="text-5xl">About</h1>
</section>
</Layout>Then add the link to both menus in SiteHeader.astro and the footer "Explore" list, written as
href={url("/about")}.
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 <span data-year-ahead="1"> and show next year; the footer copyright
is <span data-year> and shows this year. src/scripts/main.ts fills them in from today's date, so the demo never
advertises a season that has passed. To show fixed years, replace the spans with plain text.
Features#
Hero slideshow#
src/pages/index.astro (data-hero) and the "Home hero slideshow" block in src/scripts/main.ts. Each slide is a
<figure class="hero-slide" data-slide data-caption="…">; slides crossfade every 6.5 seconds, the caption
(data-hero-caption) follows the active slide, the dots (data-dot, aria-current) jump to a slide and
data-hero-toggle pauses or plays. Inactive slides are aria-hidden. With reduced motion the slideshow starts
paused. The caption is only announced to screen readers when the visitor picks a slide with a dot: show(n, announce) sets aria-live to polite for those changes and off while autoplaying. Change the interval in
setInterval(…, 6500). To add a slide, copy a <figure> and add a matching dot button.
Portfolio filter#
src/pages/portfolio.astro and the "Portfolio filter" block. Buttons with data-filter (all, weddings,
portraits, travel) show cards whose data-category matches, set aria-pressed, update the polite count
(data-filter-count) and show data-filter-empty when nothing matches. The choice is written to the URL hash, so
/portfolio#travel opens filtered. To add a category, add a button and use its value on the cards.
Gallery pages#
src/pages/gallery.astro and the "Gallery page" block. The script reads ?g=<slug>, finds the gallery in
GALLERIES (falling back to the first one), fills the heading, summary and details (data-g-title,
data-g-category, data-g-summary, data-g-location, data-g-date, data-g-count), updates the page title and
description, renders the photos into .masonry (data-gallery) with loading="lazy", and points the
previous/next links (data-g-prev, data-g-next) at the neighbouring galleries. The masonry is CSS columns: 1, 2
and 3 columns at the sm and lg breakpoints (.masonry in src/styles/global.css).
Lightbox#
A native <dialog class="lightbox" data-lightbox> in src/pages/gallery.astro. Clicking a photo
(data-lightbox-open) opens it with showModal(), so focus is trapped and Escape closes it; focus starts on Close
and returns to the photo afterwards. Left/Right arrows, the previous/next buttons and a 50px swipe (touch and pen)
move through the photos, wrapping round; the counter (data-lb-counter) is a live region; clicking the dark area
around the photo (data-lb-stage) closes it. The next photo is preloaded.
Package calculator#
src/pages/packages.astro (<form data-calculator>) and the "Package calculator" block. Choosing a package card (a
radio name="package") sets the base price and hours; the − / + buttons (data-hours-step) add 0–6 extra hours
(MAX_EXTRA) at EXTRA_HOUR; ticked add-ons add their data-price. Add-ons in INCLUDED[package] are ticked,
disabled and labelled "Included in …", and aren't charged. The summary, total (data-calc-total, live region) and
deposit (total × DEPOSIT) update on every change. Enquire with this estimate (data-calc-enquire) links to
/contact?package=…&hours=…&addons=….
To change prices, edit PACKAGES, EXTRA_HOUR, DEPOSIT and the add-ons' data-price, and the matching visible
text.
Client proofing#
src/pages/proofing.astro and the "Client proofing" block. Each photo is a
<figure class="proof-card" data-proof="<id>"> with a heart button (data-proof-toggle, aria-pressed). Up to
PROOF_LIMIT photos can be chosen; trying to add more shows a message in data-proof-message (a status region).
The sticky bar shows the count, a progress bar and "N left", and has All / Selected view buttons
(data-proof-view), Clear and Send selection, which shows a confirmation (data-proof-done) and moves focus
to it. Changing the selection afterwards (here or in another tab) hides the confirmation again. Choices are saved in
localStorage (see Saved data) and stay in sync across tabs through the storage
event.
To use your own shortlist, change the <figure> cards in proofing.astro and list the same ids in PROOF_IDS;
ids that aren't in PROOF_IDS are dropped when the selection is read. The "of 15" and "All 24" labels are text in
the page; update them if you change PROOF_LIMIT or the number of photos.
Scroll reveals#
Elements with .reveal fade up when they enter the viewport (.js .reveal and .is-in in
src/styles/global.css). The hidden starting state only applies once the inline script in the layout's <head>
adds the js class to <html>, so content is always visible with JavaScript turned off. When one reveals, every
.reveal before it does too, so jumping down the page never leaves hidden content. With reduced motion, or without
IntersectionObserver, everything is shown immediately.
Back-to-top button#
src/scripts/back-to-top.ts adds a square button after you scroll about one screen. It scrolls to the top
(instantly with reduced motion) and moves focus to #main. Mark your own fixed UI with data-back-to-top-avoid
(the button lifts above it) or data-back-to-top-hide (it hides while overlapping). Restyle it with the
.back-to-top rules in src/styles/global.css; to remove it, delete the file, its import in Layout.astro and
those styles.
Forms#
The enquiry form doesn't send anything until you connect it. src/pages/contact.astro has
<form data-enquiry novalidate>, validated by the "Enquiry form" block in src/scripts/main.ts:
| 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; must not be in the past (min is today) |
c-location (location) |
Optional |
c-budget (budget) |
Required |
c-package (package) |
Optional; preselected from ?package= when it's a package id in PACKAGES (and the occasion set to wedding or portrait) |
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; the
first invalid field gets focus. On success the data-success box is shown and focused; Send another enquiry
(data-reset) clears the form.
To receive enquiries with Formspree, replace the comment in the submit handler (after the if (!ok) block) and the
success lines that follow with:
fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error("Send failed");
const success = $("[data-success]", form);
success?.classList.remove("hidden");
success?.focus();
})
.catch(() => alert("Sorry, your enquiry could not be sent. Please email the studio."));Update the success text, which says it's a demo. Every field already has a name. The calculator's hours and
addons URL parameters aren't read by the form; if you want them in the enquiry, read them from params and add
hidden inputs. The site is static, so there is no server code; to handle the form in the same project, add an Astro
adapter and an endpoint.
The proofing page's Send selection only shows a confirmation. To send the choice to the studio, fetch the
selected array (photo ids) to your endpoint in the [data-proof-submit] click handler.
Saved data (localStorage)#
| Key | Shape | Validation |
|---|---|---|
lens-proofing-v1 |
JSON array of photo ids, for example ["amalfi-vows-01","fieldwork-03"] |
On every read: anything that isn't an array is ignored; only strings in PROOF_IDS are kept, without duplicates, up to PROOF_LIMIT. If the cleaned list differs, it's written back. |
Reads and writes are wrapped in try/catch, so blocked or full storage still works for the visit. The Clear
button empties the selection; to remove the key entirely, delete it in the browser's developer tools (Application →
Local Storage). Change the key (KEY in the proofing block, for example to lens-proofing-v2) to start everyone
fresh after replacing the shortlist.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>, which writes<title>, the meta description, Open Graph tags (og:type,og:site_name,og:title,og:description,og:url,og:image), asummary_large_imageTwitter card andtheme-color. On/gallerythe script also updates the title and description for the chosen gallery; Open Graph tags stay those of the first gallery, because crawlers don't run JavaScript. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://lens.example.com). Set it when you build:shSITE_URL=https://your-domain.com pnpm buildCanonical URLs have no
.html(https://your-domain.com/portfolio). The 404 page passesnoindex. -
For a sitemap, add
@astrojs/sitemapand list each/gallery?g=<slug>URL too, plus apublic/robots.txt. 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 link. - Visible focus outlines (rust on paper, sand on dark sections) and AA contrast.
- Mobile menu with
aria-expandedandaria-controls; focus moves into it, Escape closes it and returns focus. - 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), 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, write meaningful alt text for every photo, keep one h1 per page and label every new form field.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as portfolio.html rather than
portfolio/index.html (build.format: "file" in astro.config.mjs), and links point to /portfolio without a
trailing slash.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/portfolioservesportfolio.html. - GitHub Pages: use the official Astro GitHub Action. For a
project site (
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/portfoliotoportfolio.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building (DEMO_BASE_PATH=/lens pnpm build); every
link written with url() includes it. demo/ (if present) is the build used for the freeframe.dev demo; you can
delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides the
tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6 API that
astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a single
typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
/gallery?g=my-slugshows the first gallery. The slug isn't inGALLERIES; check the spelling in both the link and the data.- Photos are missing after switching to JPEG. Change
.svginphotoSrc()insrc/scripts/main.tsand in the<img>tags in the pages. - Some proofing choices disappear on reload. Only ids listed in
PROOF_IDSare kept; add the new ids there. - A new add-on doesn't change the total. It needs
name="addon", adata-priceand a matchingdata-addon-noteelement inside the calculator form. - The portfolio filter hides a new card. Its
data-categorymust match adata-filterbutton value. - Sections stay invisible.
.revealelements are shown bysrc/scripts/main.ts; make sureLayout.astrostill imports it and the console shows no errors. - Images or links break under a sub-folder. Build with
DEMO_BASE_PATHand write paths withurl(). - Shared links show the wrong domain. Set
SITE_URLat build time.
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.