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.
- Download
lens.zipfrom freeframe.dev/templates/lens (the WordPress tab). - 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.
- 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.
- 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).
- 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:
pnpm install
pnpm dev # builds the CSS, then starts WordPress with the theme at http://127.0.0.1:9400pnpm 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.txtCustomising#
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
.svgextension only if they are SVGs. For JPEG or WebP files, change.svginfreeframe_lens_photo_urls()ininc/setup.phpand inphotoUrl()inassets/js/main.js. - Update each photo's
wandhinLENS.galleries(they set the aspect ratio, so the masonry doesn't jump), and itscaptionandalt.
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.
Navigation, header and footer#
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:
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
revealclass 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.jsand the.back-to-topstyles insrc/theme.css, then runpnpm 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/:
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:
wp i18n make-pot wp-content/themes/freeframe-lens wp-content/themes/freeframe-lens/languages/freeframe-lens.potSave 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:
/*
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 buildafter 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 asluginLENS.galleries. - A card doesn't show up in a filter. Check its
ffd-category--…class: the value must match a filter button'sdata-filter. - A photograph is missing on a gallery page. Its file must be
assets/img/photos/<id>.svgin the theme (or updatefreeframe_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-overlayattribute, 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.