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 a wedding, portrait and travel photographer, built as a WordPress block theme. The fictional photographer is Ines Morrow, based in Lisbon. The theme has a full-bleed slideshow home page, a portfolio of galleries filtered by category, gallery pages with a masonry layout and an accessible lightbox, photo stories, packages with a live price calculator, a client proofing page for choosing album favourites, an enquiry form and a 404 page. Minimal chrome, so the photographs lead. It is version 1.0.1 (see template.json and style.css), works with WordPress 7.1 and PHP 7.4 or newer, and is the WordPress version of the HTML template of the same name. See it live at freeframe.dev/templates/lens.

Everything you see is made of core WordPress blocks arranged in block patterns, so you edit text, links, buttons, lists and images in the block editor without touching code. Colours, the fonts and font sizes are design tokens in theme.json: change them in Appearance > Editor > Styles and the whole site follows. The 49 "photographs" are SVG illustrations standing in for your own.

Quick start#

Requirements: WordPress 7.1 or newer and PHP 7.4 or newer. No plugins are needed.

  1. Download lens.zip from freeframe.dev/templates/lens (the WordPress tab).
  2. In your WordPress admin go to Appearance > Themes > Add New Theme > Upload Theme, choose the zip and click Install Now, then Activate. The theme is listed as Freeframe Lens.
  3. Go to Appearance > Lens setup and click Create the demo pages. This creates the Home, Portfolio, gallery ("Amalfi Vows"), Stories, story, Packages, Client proofing and Contact pages from the theme's page patterns, sets Home as the front page (Settings > Reading) and creates the header menus. Pages that already exist with the same slug are left alone, so it is safe to click it again.
  4. In Settings > General, set the Site Title to your studio's name and the Tagline to a short description: the home page's browser title is "Title — Tagline", the other pages' "Page — Title". The studio name in the header is text in the header pattern (see Navigation, header and footer).
  5. Replace the photo stand-ins with your photographs (see Images and photographs).

You can also try the theme without installing anything: the Try it in WordPress button on freeframe.dev opens it in WordPress Playground (WordPress running in your browser) with the demo pages already set up.

To work on the theme's files locally you need Node.js 24 and pnpm; WordPress itself runs in WordPress Playground, so there is no PHP, web server or database to install:

sh
pnpm install
pnpm dev     # builds the CSS, then starts WordPress with the theme at http://127.0.0.1:9400

pnpm dev signs you in as admin (password password) and creates the demo pages. The site is rebuilt from scratch every time you start it; your edits to theme files are live, edits made in the WordPress admin are not kept.

Pages#

Each page is a page pattern (in Patterns > Lens pages) made of section patterns. Every section is also offered on its own in the block inserter under Patterns > Lens.

Page Slug Page pattern Sections (pattern files in patterns/)
Home / page-home.php home-hero (slideshow), hello (figures), recent-work, selected-frames, kind-words, availability-cta
Portfolio /portfolio/ page-portfolio.php portfolio-header, galleries (category filter and cards)
Amalfi Vows (the gallery page) /gallery/ page-gallery.php gallery-header, gallery-photos, gallery-nav, lightbox
Stories /stories/ page-stories.php stories-header, featured-story, more-stories
Eleven minutes of light in Glencoe /story/ page-story.php story (header, photographs with captions, text, pull quote, author), keep-reading
Packages /packages/ page-packages.php packages-header, package-calculator, packages-faq
Client proofing /proofing/ page-proofing.php proofing-header, proofing-bar, proofing-photos, proofing-done
Contact /contact/ page-contact.php contact-header, enquiry (form, portrait, studio details)
404 any missing URL templates/404.html 404

The gallery page shows every gallery: /gallery/?g=kyoto-in-rain shows Kyoto in Rain (see Galleries). The story is an example: duplicate its page for each story (copy all blocks into a new page) and point the cards' links at it. WordPress posts use templates/single.html; the blog, archives and search results use templates/index.html, templates/archive.html and templates/search.html. For a plain text page (terms, privacy), choose the Page with title template in the page's settings.

Project structure#

freeframe-lens/
  style.css                 theme header (name, version, requirements, license); no styles
  theme.json                design tokens: palette, fonts, font sizes, layout widths, button
  functions.php             loads the files in inc/
  inc/setup.php             stylesheet and script (with its strings, page and photo URLs), editor
                            styles, the `js` class script, theme colour, favicon, pattern categories
  inc/icons.php             registers assets/icons/*.svg for the Icon block (Lens has none yet)
  inc/icon-labels.php       the icons' names shown in the editor
  inc/block-styles.php      button block styles
  inc/site-setup.php        Appearance > Lens setup: creates the demo pages and the menus
  templates/                page.html (pages), page-with-title.html, single.html, index.html,
                            archive.html, search.html, 404.html
  parts/                    header.html and footer.html (they show the header and footer patterns)
  patterns/                 every section and page (PHP files with block markup)
  assets/css/theme.css      the compiled stylesheet (do not edit: built from src/)
  assets/css/editor.css     the same for the block editor (generated by src/editor-css.mjs)
  assets/js/main.js         the galleries, packages and proofing data (LENS) and the site's script
  assets/img/photos/        the 49 photo stand-ins (SVG)
  assets/img/               favicon, social image
  assets/fonts/             Bodoni Moda and Hanken Grotesk and their licenses (OFL.txt)
  src/theme.css             the Tailwind CSS source of assets/css/theme.css
  src/icons.css             icons drawn before or after text with CSS (generated)
  src/editor-css.mjs        writes assets/css/editor.css after each build
  languages/                freeframe-lens.pot, the template for translations
  blueprint.json            WordPress Playground setup used by `pnpm dev`
  screenshot.png            the picture shown in Appearance > Themes
  package.json              the CSS build and the local WordPress (Node.js tooling)
  readme.txt, README.md, DOCS.md, CHANGELOG.md, LICENSE.txt

Customising#

Brand and colours#

The colours are in theme.json (settings.color.palette) and in Appearance > Editor > Styles > Colors > Palette:

Colour Used for
Base (paper), Paper 2, Card the page background; image placeholders and sunken panels; the calculator and package cards
Contrast (ink), Ink 2 text, the dark sections and the solid button
Muted, Mist secondary text on paper and on ink
Line hairlines on paper
Rust the accent on paper (kickers, the selected proofing photos, hover)
Sand the accent on ink
Error form errors

The theme's stylesheet reads every colour from the palette (bg-ink, text-rust... are the presets), so changing Rust in Styles recolours every kicker at once. Keep text colours at a 4.5:1 contrast.

Typography#

The fonts are Bodoni Moda (headings, font-display, weights 400 to 700, roman and italic) and Hanken Grotesk (text, weights 400 to 700), bundled in assets/fonts/ and declared in theme.json (settings.typography.fontFamilies), so no request goes to Google. To use other fonts, install them in Appearance > Editor > Styles > Typography > Fonts (the Font Library) and select them, or replace the fontFace entries in theme.json. The utility classes use the families --wp--preset--font-family--hanken-grotesk (font-sans) and --wp--preset--font-family--bodoni-moda (font-display), so rename those slugs only together with src/theme.css.

Font sizes are the presets in theme.json (xs 0.75rem to 9xl 8rem, the Tailwind scale). The stylesheet uses them too: changing Large in Styles changes every text-lg in the design.

Content#

Edit pages in Pages: click any text to change it, select a button to change its link, select a photograph and use Replace. Sections are groups; open List View (the stacked lines icon at the top left) to select a section, move it, duplicate it or delete it. To add a section from another page, use the block inserter (+) > Patterns > Lens. Search for "Ines", "Morrow", "Lisbon" and "inesmorrow.example" when you make it yours: they appear in the pages, the header and the footer.

Some content is in Custom HTML blocks, because blocks can't express it: edit those in the block's HTML view.

Pattern Custom HTML What it is
header the menu button a button driven by assets/js/main.js
home-hero the slideshow's photographs, the two shading layers, the caption with its dots and pause button see Home slideshow
availability-cta, 404 the shading over the background photograph a decorative layer
galleries the category buttons and the count see Portfolio filter
gallery-photos, gallery-nav, lightbox the masonry photographs, the previous/next gallery links, the photo viewer see Galleries
package-calculator the package cards, extra hours, add-ons and the estimate see Package calculator
proofing-bar, proofing-photos the selection bar and the photographs with their heart buttons; the message line see Client proofing
enquiry the enquiry form and its thank-you message see Enquiry form

Blocks can't store data- attributes, so the theme writes them as classes on blocks and the first lines of the script (after the LENS data) turn them back into attributes before anything else runs: ffd-name--value becomes data-name="value", ffa-name--value becomes the attribute name="value" (ffa-aria-labelledby--hero-title), and ff-hidden hides a block until the script shows it. They are on:

Page Block Classes
Home the slideshow section ffd-hero (it also makes the header transparent over it)
Portfolio the list of cards ffd-filter-list; each card ffd-category--weddings (portraits, travel)
Portfolio "No galleries in this category yet." ffd-filter-empty ff-hidden
Gallery the title, summary, place, date and number of frames ffd-g-title, ffd-g-summary, ffd-g-location, ffd-g-date, ffd-g-count (the category is a data-g-category span in the breadcrumb)
Client proofing the progress bar, "nothing selected" and the confirmation ffd-proof-bar, ffd-proof-empty ff-hidden, ffd-proof-done ff-hidden with its text ffd-proof-done-text

Cards that link to a gallery or a story are groups with the class link-card: the link is on the card's heading and covers the whole card. Change it on the heading. The figures on the home page ("140+", "23", "2015") are plain text.

reveal fades a block in as it scrolls into view.

Images and photographs#

Every photograph is an SVG illustration in assets/img/photos/, named <gallery>-<number>.svg. In the pages they are Image blocks whose box (frame aspect-[4/3]...) crops the picture: select one and use Replace to pick a photo from the Media Library; the crop stays. Use landscape pictures for the slideshow and the call to action, with the subject away from the bottom edge where the text sits.

The gallery pages, the lightbox and the proofing page show photographs by their id: the script uses assets/img/photos/<id>.svg (the theme passes the list of files to the script). To use your own photographs there:

  • Simplest: replace the SVG files with your pictures under the same names, keeping the .svg extension only if they are SVGs. For JPEG or WebP files, change .svg in freeframe_lens_photo_urls() in inc/setup.php and in photoUrl() in assets/js/main.js.
  • Update each photo's w and h in LENS.galleries (they set the aspect ratio, so the masonry doesn't jump), and its caption and alt.

The masonry photographs of the first gallery (Amalfi Vows) and the proofing photographs are also written in their Custom HTML blocks, so the page shows them before the script runs; change them there too. The favicon (assets/img/favicon.svg) is used until you set a Site Icon in Settings > General; assets/img/og-image.png (1200 × 630) is the social image to give your SEO plugin.

The header and footer are template parts (parts/header.html, parts/footer.html) showing the header and footer patterns. Edit them in Appearance > Editor > Patterns > Header / Footer. The studio name in the header ("Ines Morrow" over "Photography") is a paragraph with a link: edit its text there.

The header has two Navigation blocks: the desktop menu ("Main menu": Portfolio, Stories, Packages, Client proofing, Contact) and the full-screen mobile menu ("Mobile menu": the same pages with their numbers, 01 to 05, as a small span after each label), both created by the setup. Edit them in Appearance > Editor > Navigation. WordPress marks the link to the current page; the gallery page marks Portfolio and the story marks Stories (their first section carries the class ff-section-portfolio or ff-section-stories, read by assets/js/main.js).

On a page with the slideshow section (ffd-hero), the header is transparent over the photographs and turns solid once the page scrolls or the mobile menu opens.

The footer's menu is a List block; the address is a group with the address element; the copyright year is added by PHP.

The years in "Now booking 2027 weddings" (the menu), "Now booking 2027" (the footer), "A few 2027 dates are still open" (the availability call to action) and "2027 availability" (the enquiry pattern) are <span data-year-ahead="1"> and show next year: assets/js/main.js 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 in the patterns.

Features#

Home slideshow#

The slideshow (data-hero) holds four photographs (<figure class="hero-slide" data-slide data-caption="…"> in a Custom HTML block). It moves on every 6.5 seconds, crossfading with a slow zoom; the dots jump to a photo, the button pauses and plays, and the caption below names the photo. The caption is only announced to screen readers when the visitor changes the photo, never on autoplay. With reduced motion it starts paused. To change a photo, edit the block's HTML: the src, alt and data-caption of a slide (and keep the first slide's caption in the caption line).

Portfolio filter#

The category buttons (data-filter="weddings"...) show the cards whose ffd-category--… class matches, update the count ("2 galleries") and the address (/portfolio/#weddings, so filtered views can be shared). A link to /portfolio/#travel opens the filter on Travel; any other value shows everything. The numbers on the buttons are text: update them when you add a gallery.

Galleries#

The galleries, their photographs, captions and alt text are the LENS.galleries list at the top of assets/js/main.js. The gallery page shows the one named in the address (/gallery/?g=fieldwork), or the first one (Amalfi Vows) when the name is missing or unknown; it fills in the title, summary, place, date and count, builds the masonry of photographs and links the previous and next galleries. To add a gallery, add an object to LENS.galleries with a new slug and its photos (each id is a file name in assets/img/photos/ without .svg), add a card on the Portfolio page linking to /gallery/?g=<slug> with the ffd-category--… class, and update the filter's counts. The gallery's text is translatable: add its strings to freeframe_lens_script_strings() in inc/setup.php.

Selecting a photograph opens the viewer (a native <dialog>): the arrow keys, the arrow buttons or a swipe move between photos, Escape, the close button or a click beside the photo close it, focus stays inside while it is open and returns to the photograph you opened.

Package calculator#

The package cards are radio buttons; the estimate adds extra hours (€280 each, up to six) and add-ons, shows the total and the 30% deposit, and marks add-ons a package already includes. The prices used in the calculation are in LENS.packages, LENS.extraHour, LENS.deposit and the add-ons' data-price; the prices written on the cards and labels are text in the block, so update both together. "Enquire with this estimate" opens the Contact page with the package, hours and add-ons in the address (/contact/?package=signature&hours=2).

Client proofing#

A demo of a proofing gallery: the client chooses up to 15 photographs (LENS.proofLimit) from the set in LENS.proofIds, sees them all or only the selected ones, clears the selection or sends it. The selection is saved in the browser (localStorage, key lens-proofing-v1) and is validated on every read: unknown ids, duplicates, anything that isn't a list of ids and anything over the limit is dropped and the stored value is repaired. Other open tabs follow changes. Nothing is sent to you: connect the "Send selection" button to your proofing service, and protect real client pages with a password (Page settings > Visibility).

Enquiry form#

The form checks names, email, the occasion, the budget and a message of 20 characters or more; the date is required for weddings and elopements and must be in the future. Errors appear under each field with aria-invalid and aria-describedby, focus moves to the first problem, and on success the thank-you message is shown and focused; "Send another enquiry" clears the form. A ?package= in the address preselects the package and the occasion, but only for real package ids (names such as constructor or toString are ignored). Nothing is sent until you connect the form.

Option 1: a form plugin

Install a form plugin (WPForms, Gravity Forms, Fluent Forms, Contact Form 7, Jetpack Forms...), build the form there, then replace the form's Custom HTML block with the plugin's block. Give its fields the class field, its labels label and its button btn btn-solid to match the theme.

Option 2: a form service

Keep the theme's form and send it to a service such as Formspree: in a child theme's copy of assets/js/main.js, replace the comment "To receive enquiries, send the data to your form service here" in the submit handler with a fetch() to your endpoint, and show the thank-you message when it succeeds:

js
fetch('https://formspree.io/f/your-id', { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } })
  .then(function (res) {
    if (!res.ok) throw new Error('Request failed');
    var success = $('[data-success]', form);
    success.classList.remove('hidden');
    success.focus();
  })
  .catch(function () { alert('Sorry, your enquiry could not be sent. Please email me instead.'); });

Then update the thank-you message, which says the form is a demo.

Header, mobile menu, scroll reveals and back to top#

  • The header gets a hairline once the page scrolls (is-scrolled).
  • The menu button (data-menu-button) opens the full-screen #mobile-menu, locks the page's scrolling and moves focus to the first link; Escape closes it and returns focus to the button.
  • Blocks with the reveal class fade in as they scroll into view; revealing one also reveals everything before it. With reduced motion, or without JavaScript, everything shows at once.
  • A "Back to top" button appears after about one screen of scrolling; it scrolls to the top (instantly with reduced motion) and moves focus to the main content. To remove it, delete the "Back to top" part of assets/js/main.js and the .back-to-top styles in src/theme.css, then run pnpm build.

Block styles#

In the block settings' Styles panel:

Block Styles
Button Fill (default, ink), Outline (the template's ghost button; it takes the text colour, so it works on paper and on ink), Light (on dark)

WordPress's own Outline style is removed in the editor. The FAQ's questions are Details blocks; the plus that turns into a cross is drawn by CSS (ff-plus).

Saved data (localStorage)#

Lens stores one thing in the browser: the client proofing selection (lens-proofing-v1, a list of photo ids). It is validated whenever it is read (see Client proofing).

SEO and sharing#

Page titles come from WordPress: the page title, an em dash and the site name (for example "Packages — Ines Morrow Photography"); the gallery page's title follows the gallery shown. The home page shows the site name and tagline (Settings > General). The theme doesn't output meta descriptions or Open Graph tags; use an SEO plugin such as Yoast SEO, Rank Math or The SEO Framework, and give it assets/img/og-image.png (or your own 1200 × 630 image) as the default social image. WordPress already generates a sitemap at /wp-sitemap.xml. The gallery page is one page for every gallery (/gallery/?g=…); the Portfolio cards link to each, so search engines can find them, but only the first gallery is in the page's own HTML.

Accessibility#

Built in: a skip link to the main content (added by WordPress, styled by the theme), landmarks (header, labelled navigation, main, footer), one h1 per page, visible focus outlines, the mobile menu with aria-expanded and focus management, a slideshow that pauses on request and only announces changes the visitor makes, filter buttons with aria-pressed and a live count, a native modal lightbox (focus kept inside, Escape, focus returned), proofing buttons with aria-pressed and labels naming the photo, form errors announced through aria-describedby and aria-invalid, alt text on every photograph (decorative copies are hidden), and reduced-motion support for every animation. The demo passes axe (WCAG 2.1 A and AA).

When you edit: keep one Heading 1 per page, give every photograph alt text (or mark it decorative), keep link text meaningful, and label any field you add to a form (<label for="...">).

Building and deploying#

You only need this if you change the theme's files. The stylesheet assets/css/theme.css is compiled with Tailwind CSS from src/theme.css and the classes used in patterns/, parts/, templates/, inc/ and assets/js/:

sh
pnpm install
pnpm build   # compile assets/css/theme.css and assets/css/editor.css
pnpm watch   # recompile assets/css/theme.css on every change (run pnpm build before you commit)

Run pnpm build after adding a utility class to a pattern or template, otherwise the class has no style. Classes you type in the editor's Additional CSS class(es) field are only styled if the stylesheet already contains them.

To deploy, zip the theme folder (named freeframe-lens, without node_modules/) and upload it in Appearance > Themes, or copy the folder to wp-content/themes/ on your server.

Translations#

The theme is translation-ready with the text domain freeframe-lens. All text in patterns, templates and PHP goes through WordPress's translation functions; the script's strings and the text of the galleries, photographs and packages in LENS are translated in PHP (freeframe_lens_script_strings() in inc/setup.php). Create a translation with Loco Translate, or with WP-CLI and Poedit:

sh
wp i18n make-pot wp-content/themes/freeframe-lens wp-content/themes/freeframe-lens/languages/freeframe-lens.pot

Save freeframe-lens-pt_PT.mo (for example) in wp-content/languages/themes/ or in the theme's languages/ folder. Page content created by Lens setup is copied into your pages in the site's language at that moment; translate it in the editor like any other content. The slideshow's captions are in its Custom HTML block (data-caption): translate them there.

Child themes#

Use a child theme for changes you want to keep when Lens is updated. Create wp-content/themes/lens-child/style.css:

css
/*
Theme Name: Lens Child
Template: freeframe-lens
*/

Templates, parts, patterns and theme.json in the child theme override the parent's files of the same name. The parent's stylesheet only contains the classes the parent uses: if your child theme's patterns use new utility classes, compile a stylesheet for the child (copy package.json and src/theme.css, point the @source lines at the child's folders) and enqueue it. To change the script (your galleries, packages and proofing set), copy assets/js/main.js into the child theme and load it in place of the parent's (wp_deregister_script( 'freeframe-lens' ) and wp_enqueue_script() on wp_enqueue_scripts, priority 30), keeping the data the parent prints before it (window.freeframeLensL10n, window.freeframeLensPages, window.freeframeLensPhotos, window.freeframeLensSiteName).

Troubleshooting#

  • The pages look unstyled or new classes do nothing. The stylesheet only contains classes it found when it was built. Run pnpm build after editing patterns, then reload.
  • "This block contains unexpected or invalid content". A pattern or page was edited by hand with markup that doesn't match the block. Click Attempt recovery, or re-insert the section from Patterns > Lens.
  • A new gallery shows Amalfi Vows. The ?g= value must be a slug in LENS.galleries.
  • A card doesn't show up in a filter. Check its ffd-category--… class: the value must match a filter button's data-filter.
  • A photograph is missing on a gallery page. Its file must be assets/img/photos/<id>.svg in the theme (or update freeframe_lens_photo_urls() for other formats).
  • The header menu shows other pages, or an old menu. Select each Navigation block and choose "Main menu" or "Mobile menu" (or yours) in the block settings.
  • Lens setup says the pages exist, but they are empty. It never overwrites existing pages. Delete (and empty the trash of) the pages with those slugs, or open each page and insert its page pattern from Patterns > Lens pages.
  • The enquiry form doesn't send anything. It is a demo form; see Enquiry form.

Differences from the HTML version#

The pages, copy, design and interactions are the same. What WordPress changes:

  • Straight apostrophes typed in text blocks are shown curly, as WordPress does everywhere (text in Custom HTML blocks and menu labels keeps them straight).
  • Cards that are links have the link on their heading, stretched over the card (the HTML version wraps the whole card in a link), so screen readers announce the heading as the link's name.
  • The header's transparent look over the slideshow follows the slideshow section instead of a data-overlay attribute, and the gallery page is titled after its first gallery.
  • Photographs in Image blocks load straight away; the HTML version marks those below the fold loading="lazy" (the gallery and proofing photographs in Custom HTML blocks still are).
  • Data and ARIA attributes on content blocks are written as classes and restored by the script (see Content).

License#

MIT, which is compatible with the GPL that WordPress requires (see LICENSE.txt in the theme folder). Free for personal and commercial use, including the photo illustrations. Bodoni Moda and Hanken Grotesk are licensed under the SIL Open Font License 1.1 (assets/fonts/*/OFL.txt). Ines Morrow and everyone in this theme are fictional.

Search documentation

Search the guides and every template's documentation