Freeframe

Documentation

Lens

v1.0.2Updated Oct 17, 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 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).

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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.json

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

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

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

  1. Add an object to GALLERIES with a new slug and its photos. Each photo id is the file name in public/img/photos/ without .svg; w and h are its pixel size (they set the aspect ratio and prevent layout shift).
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" },
  ],
},
  1. Add a card to src/pages/portfolio.astro (copy an <article data-category="portraits"> and link it to url("/gallery?g=lisbon-portraits")) and update the counts in the filter buttons. The data-category value must match a data-filter button (weddings, portraits or travel).

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 with photoSrc() at the top of src/scripts/main.ts (url(`/img/photos/${id}.svg`)), used by the gallery grid, the lightbox and preloading; change .svg there 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 card aspect-[4/5], square tiles aspect-square; photos are cropped with object-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 alt for every photograph, and alt="" only for decorative backgrounds.
  • 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.

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.

  • src/components/SiteHeader.astro holds the desktop navigation, the enquire button and the mobile menu (#mobile-menu). A link gets aria-current="page" (which draws the underline) when the page's current prop matches it; /story passes current="stories".
  • On the home page <Layout overlay> renders the header with data-overlay: it sits transparently over the slideshow and turns solid once you scroll. Leave overlay out to make it solid from the start.
  • src/components/SiteFooter.astro is the footer, including the "Explore" list.
  • Layout.astro takes title, description, current, page (written to <body data-page>), overlay and noindex.

To add a page, create src/pages/about.astro:

html
---
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.

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).

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:

ts
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 title and description to <Layout>, which writes <title>, the meta description, Open Graph tags (og:type, og:site_name, og:title, og:description, og:url, og:image), a summary_large_image Twitter card and theme-color. On /gallery the 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:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://lens.example.com). Set it when you build:

    sh
    SITE_URL=https://your-domain.com pnpm build

    Canonical URLs have no .html (https://your-domain.com/portfolio). The 404 page passes noindex.

  • For a sitemap, add @astrojs/sitemap and list each /gallery?g=<slug> URL too, plus a public/robots.txt. 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 link.
  • Visible focus outlines (rust on paper, sand on dark sections) and AA contrast.
  • Mobile menu with aria-expanded and aria-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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /portfolio serves portfolio.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /portfolio to portfolio.html, change build.format to "directory" in astro.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-slug shows the first gallery. The slug isn't in GALLERIES; check the spelling in both the link and the data.
  • Photos are missing after switching to JPEG. Change .svg in photoSrc() in src/scripts/main.ts and in the <img> tags in the pages.
  • Some proofing choices disappear on reload. Only ids listed in PROOF_IDS are kept; add the new ids there.
  • A new add-on doesn't change the total. It needs name="addon", a data-price and a matching data-addon-note element inside the calculator form.
  • The portfolio filter hides a new card. Its data-category must match a data-filter button value.
  • Sections stay invisible. .reveal elements are shown by src/scripts/main.ts; make sure Layout.astro still imports it and the console shows no errors.
  • Images or links break under a sub-folder. Build with DEMO_BASE_PATH and write paths with url().
  • Shared links show the wrong domain. Set SITE_URL at 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.

Search documentation

Search the guides and every template's documentation