Overview#
Estate is an upscale website for an estate agency, built as a WordPress block theme. The demo
belongs to Harbor & Hill Realty, a fictional independent agency in the fictional coastal town of
Marlow Bay, with twelve listings (for sale and for rent) and six agents. The theme has a home page
with a property search, featured homes and neighbourhood guides, a listings page with a
buy/rent/all switch, filters, sorting, a grid or list view and saved homes, a property page with a
photo gallery and lightbox, floor plan, mortgage calculator (move-in costs for rentals), viewing
request form and similar homes, an agents page, a selling page with a valuation form and FAQ, an
about page, a contact page with a map, and a 404 page. It is a light design in cream, forest green
and brass. 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/estate.
The pages are 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. The listings themselves are
data in assets/js/main.js: the featured homes, the listings results, the property page and the
similar homes are built from it in the browser, with generated SVG illustrations instead of photos.
Colours, the two fonts and font sizes are design tokens in theme.json: change them in
Appearance > Editor > Styles and the whole site follows.
Quick start#
Requirements: WordPress 7.1 or newer and PHP 7.4 or newer. No plugins are needed.
- Download
estate.zipfrom freeframe.dev/templates/estate (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 Estate.
- Go to Appearance > Estate setup and click Create the demo pages. This creates the Home, Listings, Property, Agents, Sell, About and Contact pages from the theme's page patterns, sets Home as the front page (Settings > Reading) and creates the header menu. 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 and Tagline (the demo uses "Harbor & Hill" and "Realty · Marlow Bay"): the header logo shows both. Use pretty permalinks (Settings > Permalinks, any option except Plain; see One page for every listing). Then visit your site. Edit any page in Pages, and the header, footer and templates in Appearance > Editor.
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), sets the site title to "Harbor & Hill"
and the tagline to "Realty · Marlow Bay", switches on pretty permalinks and creates the demo pages
(blueprint.json). 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 > Estate pages) made of section patterns. Every section is also offered on its own in the block inserter under Patterns > Estate.
| Page | Slug | Page pattern | Sections (pattern files in patterns/) |
|---|---|---|---|
| Home | / |
page-home.php |
home-hero (headline over property artwork), home-search (search form), track-record (four numbers), featured-homes (six homes from the listings data), neighbourhoods (six neighbourhood cards), services (buy, sell, rent, let and manage), founder (portrait and quote), reviews (three reviews with a star rating), valuation-cta |
| Listings | /listings/ |
page-listings.php |
listings-header (breadcrumb, title, Buy/Rent/All switch), listings-results (filters, sort, grid/list switch, results, empty state) |
| Property | /property/?id=3 |
page-property.php |
property-detail (everything about one listing, filled in from the data), property-lightbox (the full-screen gallery) |
| Agents | /agents/ |
page-agents.php |
agents-hero, agents-team (six agent cards), agents-join (careers) |
| Sell | /sell/ |
page-sell.php |
sell-hero, sell-process (four steps), sell-fees, sell-valuation (valuation form, anchor #valuation), sell-faq |
| About | /about/ |
page-about.php |
about-hero, values, about-numbers, about-story (timeline), offices |
| Contact | /contact/ |
page-contact.php |
contact-hero, contact-form (form, map, offices, opening hours) |
| 404 | any missing URL | templates/404.html |
404 |
Every page has the same header and footer; the footer holds a newsletter sign-up form. Posts use
templates/single.html; the blog, archives and search results use templates/index.html,
templates/archive.html and templates/search.html.
Project structure#
freeframe-estate/
style.css theme header (name, version, requirements, license); no styles
theme.json design tokens: palette, fonts, font sizes, spacing, layout widths, button;
the custom template and the template parts
functions.php loads the files in inc/
inc/setup.php stylesheet and script, the data passed to the script (translations, page
and image URLs), editor styles, head meta (theme colour, favicon),
pattern categories
inc/block-styles.php the Brass and Light button styles
inc/icons.php registers assets/icons/*.svg for the Icon block
inc/icon-labels.php the icons' names shown in the editor
inc/site-setup.php Appearance > Estate setup: creates the demo pages and the menu
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 site's JavaScript: listings data, artwork, listings, property page,
forms, back to top
assets/icons/ SVG icons for the Icon block (rating-5, the five-star rating)
assets/img/ logo, agent portraits (agent-1.svg to agent-6.svg), favicon, social image
assets/fonts/ DM Sans and DM Serif Display files 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; none used)
src/editor-css.mjs writes assets/css/editor.css after each build
languages/ freeframe-estate.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.txtassets/js/main.js starts with a short "WordPress:" adapter, then the HTML template's script in
four numbered parts marked by comment banners: 1. Data, 2. Artwork, 3. Shared UI and
4. Pages, and finally the back-to-top button.
Customising#
Brand and colours#
The colours are in theme.json (settings.color.palette) and in Appearance > Editor > Styles >
Colors > Palette. The stylesheet reads them through Tailwind tokens (in src/theme.css), so a
change in Styles recolours every block that uses the token:
| Colour (slug) | Tailwind token | Used for |
|---|---|---|
Cream (base) |
cream |
page background, the header |
Ink (contrast) |
ink |
main text, the dark overlays on artwork |
Forest (forest) |
forest |
primary buttons, prices, the footer, "For sale" badges |
Forest 600 (forest-600) |
forest-600 |
primary button hover |
Sand (sand) |
sand |
section and picture backgrounds, the calculator panel |
Paper (paper) |
paper |
cards |
Line (line) |
line |
borders and dividers |
Muted (muted) |
muted |
secondary text |
Brass (brass) |
brass |
accents, focus rings, the saved-homes count, star rating |
Brass 600 (brass-600) |
brass-600 |
eyebrows, text links, the Brass button |
Brass 700 (brass-700) |
brass-700 |
Brass button hover |
Brass soft (brass-soft) |
brass-soft |
form thank-you panels, accents on dark backgrounds |
White (white) |
(Tailwind's white) |
text on dark backgrounds |
For a navy brand, change Forest and Forest 600 in Styles, or in theme.json:
{ "slug": "forest", "name": "Forest", "color": "#1f2f4a" },
{ "slug": "forest-600", "name": "Forest 600", "color": "#2b3f61" }Estate is designed light only; there is no dark mode. If you change Forest, also change the
browser UI colour printed by freeframe_estate_head_meta() in inc/setup.php
(<meta name="theme-color" content="#22362c">).
Some colours are written outside the palette: the form error red (.field-error,
.field[aria-invalid="true"]) and the pressed heart (.heart[aria-pressed="true"]) in
src/theme.css; the property illustrations' colours (TIMES and each listing's look) in
assets/js/main.js; and the contact page's map, an inline SVG in the contact-form pattern.
Typography#
Two fonts are bundled in assets/fonts/ and declared in theme.json
(settings.typography.fontFamilies), so no request goes to Google:
| Font | Slug | Used for |
|---|---|---|
| DM Sans (variable, weights 400 to 700) | dm-sans |
all text (the site's Text font) |
| DM Serif Display (regular and italic) | dm-serif-display |
headings, prices and numbers (the h-display and font-display classes) |
| Monospace (system fonts) | mono |
code in posts |
To use another font, install it in Appearance > Editor > Styles > Typography > Fonts (the Font
Library) and select it for Text, or replace the fontFace entries in theme.json. The
theme's utility classes use the fonts through their slugs (--font-sans is
--wp--preset--font-family--dm-sans, --font-display is
--wp--preset--font-family--dm-serif-display in src/theme.css), so rename a slug only together
with src/theme.css. The labels of the generated floor plan and of the contact map name
"DM Sans" in their SVG; change them in floorPlan() in assets/js/main.js and in the map's
Custom HTML block.
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. The
home page headline is a fluid size written as a class on its Heading block
(text-[clamp(2.7rem,6.5vw,5.4rem)]): change it there.
Content#
Edit pages in Pages: click any text to change it, select a button to change its link, select an image 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 > Estate.
The agency's name, town, email address (hello@example.com), phone numbers and office addresses
appear in many places: the header, the footer (patterns/footer.php, including the copyright line
"© 2026 Harbor & Hill Realty..."), the about, sell, contact and listings pages, and in
assets/js/main.js (the property page's title and its "Marlow Bay" address line, the HH-
reference numbers). Search for them when you rebrand.
Some content is in Custom HTML blocks, because blocks can't express it: edit those in the block's HTML view.
| Pattern | Custom HTML |
|---|---|
header |
the saved-homes link with its count, the menu button |
home-search |
the search form |
listings-header |
the breadcrumb, the Buy/Rent/All switch, the Filters button |
listings-results |
the filters form, the "Listed by" agent note, the sort select and grid/list switch, the Clear filters button |
property-detail |
the breadcrumb, the share button, the "View all photos" button, the facts list, the mortgage calculator, the agent card, the viewing form |
property-lightbox |
the lightbox |
sell-valuation |
the valuation form |
contact-form |
the contact form, the map |
footer |
the newsletter form |
Listings and agents#
The listings and agents are data in part 1. Data of assets/js/main.js, as in the HTML
version. Agents are in the AGENTS array:
{ id: 1, name: 'Eleanor Vance', role: 'Founding Partner', phone: '(555) 014-2280', email: 'eleanor@example.com', img: 'assets/img/agent-1.svg' }Listings are in the PROPERTIES array. To add one, add an object with a new id:
{ id: 13, title: 'Garden Flat on Mill Road', address: '22 Mill Road', area: 'Linden Park', status: 'rent', type: 'Apartment',
price: 2400, beds: 2, baths: 1, sqft: 900, year: 1998, parking: 'Street', lot: '—', listed: 1, agent: 3, badges: ['New'],
look: { kind: 'townhouse', time: 'day', wall: '#ece4d6', trim: '#3b3a36', accent: '#7d8f7a', room: '#efe8dc' },
summary: 'A bright two-bedroom garden flat a short walk from the park.',
features: ['Private garden', 'Bike storage', 'Pets considered'] }| Field | Meaning |
|---|---|
id |
Unique number, used in the property page's address (/property/?id=13) |
status |
'buy' or 'rent' (rent prices are per month) |
area |
Neighbourhood; must match a value of the neighbourhood options (see below) |
type |
'House', 'Apartment', 'Townhouse' or 'Cottage' |
price, beds, baths, sqft, year, parking, lot |
Facts shown on cards and the property page |
listed |
Days since listing; "Newest first" sorting uses it |
agent |
An id from AGENTS |
badges |
Short labels such as 'New' or 'Open house Sat' |
look |
Illustration settings (see Images and artwork) |
summary, features |
Description and bullet list on the property page |
The script is part of the theme, so edit it in a child theme to keep your listings through theme updates (see Child themes).
Featured homes on the home page are chosen by id in part 4:
featured.innerHTML = [1, 3, 12, 4, 5, 6].map(function (id) { return card(byId(id), 'grid'); }).join('');Agents page. The agent cards on the Agents page are blocks (Image, Heading, paragraphs, a List
of specialities and two Buttons), not generated from AGENTS. When you change an agent, change
the card and the AGENTS entry (used by the agent card on the property page and the "Listed by"
note on the listings page). Each card's Listings button links to /listings/?status=all&agent=N
with the agent's id.
Neighbourhoods (Old Harbor, The Docklands, Linden Park, Westshore, Cedar Heights,
Hillcrest) appear in several places. If you rename or add one, update:
- the
locationselect in the home search (home-search) and in the filters (listings-results), and theareaselect of the valuation form (sell-valuation). The option text is translatable, but thevaluemust stay exactly the listing'sarea; - the neighbourhood cards on the home page (
neighbourhoods), whose links are/listings/?status=all&location=Old+Harbor; - the short area descriptions used on the property page: the object literal in the
$('[data-p-more]').textContent = ...line in part 4 ofassets/js/main.js.
Price options of the listings filter are PRICE_OPTIONS in assets/js/main.js (one list for
buying, one for renting). The home search has its own max select with sale prices.
Classes the script reads#
Blocks keep classes, not data-* attributes, so the theme writes the HTML template's data
attributes as classes: data-x="y" is the class ffd-x--y (ffd-x when the attribute is empty;
an underscore in the value stands for a space), and hidden is ff-hidden. The adapter at the top
of assets/js/main.js turns them back into attributes before anything else runs. You see them in
Block settings > Advanced > Additional CSS class(es). Keep them when you edit, and don't delete
the empty Group blocks that carry them: the script fills them in on the site (the editor shows them
as empty boxes with a dashed outline).
| Class | Where | What the script does |
|---|---|---|
ffd-art--exterior ffd-id--3 ffd-time--dusk ffd-decorative |
empty picture Groups (home hero, neighbourhoods, about, sell, valuation call to action, 404) | draws property 3's exterior at dusk, hidden from screen readers |
ffd-featured |
the grid in featured-homes |
the six featured homes |
ffd-results, ffd-count, ffd-empty, ffd-listings-title |
listings-header, listings-results |
the results, their count, the empty state and the page title |
ffd-property, ffd-p-title, ffd-p-price, ffd-p-gallery... |
property-detail |
the whole property page |
ffd-similar |
property-detail |
three similar homes |
ffd-back-to-top-hide |
the property page's agent column | the back-to-top button never covers it |
ff-hidden |
the mobile menu, the empty state | hidden until the script shows it (shown with a dashed outline in the editor) |
reveal |
many blocks | fades the block in as it scrolls into view |
link-card |
the logo, neighbourhood and service cards | makes the whole group a link (see Link cards) |
To change a neighbourhood card's picture, select the card's picture (the empty Group at the top
of the card, classes media aspect-[5/4] ffd-art--exterior ffd-id--5 ffd-decorative) in List View
and, in Advanced > Additional CSS class(es), change ffd-id--5 to the id of the listing whose
house it should show. Add ffd-time--day, ffd-time--golden, ffd-time--dusk or
ffd-time--night to choose the time of day (without one, the listing's own look.time is used),
or use ffd-art--living, ffd-art--kitchen or ffd-art--bedroom for an interior. An unknown id
shows listing 1. The same classes work on any empty Group you add.
One page for every listing#
There is one WordPress page for every listing: the Property page (/property/) shows the listing
whose id is in its address, /property/?id=3, as property.html?id=3 does in the HTML version.
Without an id, or with an unknown one, it shows the first listing. Everything on it (title, badges,
price, gallery, facts, features, floor plan, calculator, agent, viewing form, similar homes) is
filled in by part 4 of assets/js/main.js; the page itself only holds the empty Groups and Custom
HTML blocks it fills. Edit the page for the headings, the floor plan note, the move-in cost labels
and the viewing form.
The script builds its links from the HTML template's file names, for example
link('property.html?id=' + p.id) on every card and link('listings.html?status=all&agent=3') on
the agent card. link() looks the file name up in window.freeframeEstate.pages, which
freeframe_estate_page_urls() in inc/setup.php prints: index is the home URL, and listings,
property, agents, sell, about and contact are the permalinks of the published pages with
those slugs (found with get_page_by_path()), or /<slug>/ when no such page exists. The rest of
the address (?id=3) is appended. So the links follow your permalink settings and your site's
address (a WordPress in a subfolder, for example), and you can rename the pages' titles freely, but
the pages must keep the slugs listings and property, as top-level pages. Use pretty permalinks:
with the Plain setting the page URLs already contain ?page_id=, and the appended ?id= would not
be read.
The links written in the patterns (menu, footer, buttons, neighbourhood cards) are plain
addresses such as /listings/?status=rent; update them too if you change a slug.
The agent portraits go through asset(), which maps the img paths of AGENTS
(assets/img/agent-1.svg) to the theme's file URLs printed by freeframe_estate_image_urls()
(every SVG in assets/img/). Any other value, such as the full URL of an image in your Media
Library, is used as it is.
Images and artwork#
Property pictures are SVG illustrations generated in part 2 of assets/js/main.js from each
listing's look:
look field |
Values |
|---|---|
kind |
modern, townhouse, cottage, apartment, coastal (exterior style) |
time |
day, golden, dusk, night (sky and lighting) |
wall, trim, accent, room |
Hex colours for walls, window frames, details and interior walls |
Each property gets five gallery views from gallery(p): the exterior, living room, kitchen,
primary bedroom and the exterior at another time of day. The floor plan comes from floorPlan(p)
and has a room per bedroom.
To use photos, upload them to the Media Library (or a folder of your child theme) and return images instead of SVG, in a child theme's copy of the script:
- In
gallery(p), change eachhtmlfunction to return an<img>tag, for examplereturn '<img src="https://example.com/wp-content/uploads/listing-' + p.id + '-living.jpg" alt="Living room">'; - In
card(p, view), replace the calls toexterior(p)with the listing's cover image.
Use 4:3 photos: cards and gallery thumbnails are 4:3 and the lightbox 3:2 (both crop to fill). Neighbourhood cards are 5:4.
Other images:
- Agent portraits (
assets/img/agent-1.svgtoagent-6.svg, 600 × 700) are Image blocks on the Agents page (in a 6:7 frame), the founder section and the valuation section, and come fromAGENTSon the property page. Replace them with photos of the same proportions, in the Image blocks and inAGENTS(img, a full URL). - Logo: the header and footer show
assets/img/logo.svg(an Image block) next to the Site Title block; the header also shows the Site Tagline. Replace the image in Appearance > Editor > Patterns > Header (and Footer); the name and tagline come from Settings > General. - Star rating: the reviews show the
rating-5icon from the Estate collection in an Icon block. - Contact map: an inline SVG in a Custom HTML block, so its labels use the page's font. Replace the block with an Image block or a map embed for a real map.
- Social image:
assets/img/og-image.png(1200 × 630, sourceog-image.svg). WordPress doesn't output Open Graph tags by itself; see SEO and sharing. - Favicon:
assets/img/favicon.svgis used until you set a Site Icon in Settings > General.
WordPress doesn't allow SVG uploads by default; upload your replacements as JPG, PNG or WebP.
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 header holds the logo, the desktop menu, the saved-homes link, a Book a valuation button (from 640 px wide) and, below 1024 px, the menu button that opens the mobile menu (the same menu and a Book a free valuation button). Its two Navigation blocks, desktop and mobile, show the same menu. Estate setup creates it as "Main menu":
| Link | Kind | Address |
|---|---|---|
| Buy | custom link | the Listings page + ?status=buy |
| Rent | custom link | the Listings page + ?status=rent |
| Sell, Agents, About, Contact | pages | the pages |
Edit it once in Appearance > Editor > Navigation. A Navigation block that doesn't point at a
menu shows the most recently created one; to pin a menu, select the Navigation block and choose it
in the block settings, for both blocks. WordPress marks the current page's link with
aria-current="page" (drawn as a brass underline). Buy and Rent are custom links, so the script
handles them: the adapter gives every header link whose address contains status=buy or
status=rent a data-nav-status, and on the listings page the link matching the current mode is
marked as current.
The footer has the logo and address, two link columns (List blocks: click a link to change it, press Enter to add one), the newsletter form and the legal line. The copyright line prints the current year when the footer comes from the theme's files. After you edit and save the footer in the Site Editor, the year is stored as text; update it there each year.
Features#
Header and mobile menu#
The header sticks to the top of the window and gets a soft shadow once the page scrolls
(is-scrolled, set by assets/js/main.js). The menu button (data-menu-toggle) opens
#mobile-menu below the header, updates aria-expanded and its label ("Open menu" / "Close
menu") and swaps its icon. Esc closes the menu and returns focus to the button, and the menu
closes when the window crosses 1024 px.
Saved homes#
The heart buttons on cards and on the property page save a home in the browser (see
Saved data), with aria-pressed, a label naming the home ("Save Rose
Cottage to saved homes") and a toast ("Saved to your homes"). The header's heart link shows the
number of saved homes ([data-saved-count], hidden at zero) and opens /listings/?saved=1, which
lists them. Changes sync between open tabs.
Home search#
The search card on the home page is a GET form whose action is the Listings page. Its fields
(status, location, type, max, beds) become address parameters, which the listings page
reads to set its filters.
Listings filters, sort and views#
The listings page renders cards into the ffd-results Group from the PROPERTIES data:
- Buy / Rent / All buttons (
data-status, witharia-pressed). - Filters form (
data-filters):location,type,max(price),beds,baths(minimums) andsaved. The price options switch between sale and rent ranges, and price is disabled for "All". - Sort:
newest,price-asc,price-desc,size. - Grid / list view (
data-view), remembered in the browser underestate-view. - A live count ("8 homes for sale"), a title that follows the mode, an empty state and Clear
filters (
data-reset). - Every change updates the address (
/listings/?status=rent&location=Westshore&beds=2&sort=price-asc), so searches can be shared or bookmarked.?agent=3lists one agent's homes and shows a note with their name. Values that aren't one of a filter's options (such as?type=banana) or an unknown agent are ignored. - Below 1024 px the filter panel (
#filter-panel) is hidden behind the Filters button (data-filter-toggle,aria-expanded).
To add a filter, add a control with a new name to the filters form (Custom HTML in
listings-results), add a property to the state object, test it in filtered(), and add its
name to the list of keys written to the address in render().
Property page#
See One page for every listing for how the page finds its listing. It also sets the browser
tab's title ("Rose Cottage — Linden Park | Harbor & Hill Realty"; the agency name is written in
assets/js/main.js), the breadcrumb, the facts, the features, the floor plan, the agent card (call,
email with the listing's reference in the subject, and "More homes from this agent"), the viewing
form's hidden property field and three similar homes (same status, closest in price). The share
button copies the page's address and shows a toast.
Gallery and lightbox#
The gallery shows one large view and four thumbnails (one view and a "View all photos" button on
phones); every view opens the full-screen lightbox ([data-lightbox], in the property-lightbox
pattern) with a caption ("2 / 5 · Living room"). Keyboard: Left/Right move between views,
Esc closes, Tab stays inside the lightbox, and focus returns to the view you opened. The
page doesn't scroll behind it.
Mortgage calculator and move-in costs#
For listings with status: 'buy' the page shows a mortgage calculator ([data-calc]) with the
price filled in:
| Input | Default |
|---|---|
Home price (price) |
the listing price |
Deposit (deposit, range) |
20% (5 to 50%) |
Interest rate (rate) |
6.25% |
Term (term) |
30 years (15, 20, 25 or 30) |
It updates as you type and shows the deposit, monthly payment (announced with aria-live), loan
amount, total interest, total cost and a principal/interest bar, using the standard amortisation
formula. Change the defaults in the inputs' value attributes in the calculator's Custom HTML
block. Amounts are shown in US dollars (money() in assets/js/main.js).
For rentals it shows move-in costs instead: the first month plus a deposit of 1.5 × the monthly
rent. Change the multiplier in var deposit = p.price * 1.5; in assets/js/main.js, and the
"Security deposit (1.5 months)" label on the page.
Link cards#
A Group with the class link-card is a link as a whole: its link (the neighbourhood name, the
service title, the site title in the logo) is stretched over the group with CSS
(.link-card a::after), and the adapter in assets/js/main.js also follows the link when you
click anywhere on the card, which covers cards whose text sits in an absolutely positioned panel
over the picture (the neighbourhood cards). The focus ring is drawn around the whole card. In the
editor the links aren't stretched, so you can select every block inside.
Scroll reveal#
Blocks with the reveal class fade and rise when they enter the viewport; reaching one also
reveals everything above it, so jumping down a page never leaves hidden content. Cards added by the
script reveal the same way. With reduced motion everything is shown immediately; without JavaScript
(no js class on <html>) and in the editor everything is always visible.
Selling questions#
The questions on the Sell page (sell-faq) are core Details blocks: click the summary to edit the
question, and the paragraph inside for the answer; duplicate one to add a question. They open with
the keyboard and screen readers like any <details> element. The "+" that turns into a "×" is a
<span> at the end of the summary text.
Back to top#
A button appears after scrolling about one screen down; it scrolls to the top (instantly with
reduced motion), moves keyboard focus to the main content and lifts itself above toasts. Add
data-back-to-top-avoid to anything you pin to the bottom of the screen (a cookie bar, a chat
launcher) and the button sits above it. It is created by assets/js/main.js; to remove it, delete
the "Back to top" part of that file and the .back-to-top styles in src/theme.css, then run
pnpm build.
Buttons#
The Button block's default style is the primary forest button. In the block settings' Styles
panel there are also Brass and Light (on dark) (registered in inc/block-styles.php), and
WordPress's own Outline style, restyled as the template's outlined button. The class
ff-btn-px-3 (Advanced > Additional CSS class(es)) narrows a button's side padding, as on the
agent cards. It stands for the HTML template's px-3!, which can't be used in a block class:
WordPress's wptexturize turns !" into a curly quote.
Forms#
Every form marked data-validate shares one validator in assets/js/main.js that uses the
browser's own constraints (required, type="email", pattern, minlength, min/max) and
shows friendly messages under each field. A field's data-pattern-message attribute sets its
pattern message (the phone fields use it). Date inputs with data-future can't be set earlier
than tomorrow, on the visitor's calendar.
| Form | Pattern | Fields |
|---|---|---|
Request a viewing (#viewing) |
property-detail |
name, email, phone (optional), date, time, message (optional); a hidden property field with the listing and reference (e.g. "Rose Cottage (HH-1003)") |
| Free valuation | sell-valuation |
address, neighbourhood, type, bedrooms (0 to 12), size (optional, 100 to 20,000 sq ft), condition, timeline, name, email, phone, consent box |
| Contact | contact-form |
name, email, phone (optional), topic, message (at least 10 characters) |
| Newsletter | footer |
Errors appear under each field, fields get aria-invalid and aria-describedby, and focus moves to
the first invalid one. When everything is valid, the form's [data-form-body] is hidden and its
[data-success] message shown; a [data-form-reset] button brings the form back. Nothing is
sent anywhere: they are demo forms until you connect them. When you do, also rewrite the
thank-you messages, which say that nothing was sent.
The home search, the listings filters and the mortgage calculator are forms too, but they don't send anything: the search opens the listings page and the others update the page.
Option 1: a form plugin#
The usual way on WordPress. Install a form plugin (WPForms, Gravity Forms, Fluent Forms, Contact
Form 7, Jetpack Forms...), build the form there, then delete the form's Custom HTML block and
insert the plugin's block or shortcode in its place. The plugin sends the messages and stores
them. To make its fields look like the theme's, add the theme's classes where the plugin allows
custom classes: field on fields, label on labels, btn btn-primary on the submit button. For
the viewing form, the plugin needs a way to know which home was asked about (the address's id).
Option 2: a form service#
Keep the theme's forms and send them to a service such as Formspree:
-
Create a form on the service and copy its endpoint URL.
-
Open the form's Custom HTML block and add the endpoint and method to the form tag, for example on the Contact page:
html<form class="card p-6 sm:p-8" action="https://formspree.io/f/your-id" method="post" novalidate="" data-validate=""> -
In
assets/js/main.js, in the forms' submit handler (it follows the comment "To receive submissions..."), replace the lines fromvar success = $('[data-success]', form);toform.reset();so a form with anactionis sent, and only shows its thank-you message when the service accepts it:jsvar success = $('[data-success]', form); var body = $('[data-form-body]', form); var done = function () { if (body) body.hidden = true; if (success) { success.hidden = false; success.focus(); } form.reset(); }; if (!form.getAttribute('action')) { done(); return; } // forms not connected yet fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } }) .then(function (res) { if (!res.ok) throw new Error('Request failed'); done(); }) .catch(function () { alert('Sorry, something went wrong. Please call or email us.'); });Make this change in a child theme (see Child themes), or it will be overwritten when you update the theme. Each form can have its own endpoint.
Saved data (localStorage)#
| Key | Value | Used for |
|---|---|---|
estate-saved |
array of listing ids, e.g. [3, 7] |
saved homes |
estate-view |
"grid" or "list" |
the listings layout |
getSaved() checks estate-saved on every read: anything that isn't an array of known, unique
listing ids is dropped and the stored value is repaired. estate-view falls back to grid for any
other value. Saved homes stay in one browser on one device. Clear them from the browser's
developer tools (Application > Local Storage), or with
localStorage.removeItem('estate-saved').
SEO and sharing#
Page titles come from WordPress: the page title, an em dash and the site name (for example
"Agents — Harbor & Hill"); the home page shows the site name and tagline (Settings >
General). On the property page the script replaces the title with the listing's and the site name
(for example "Rose Cottage — Linden Park | Harbor & Hill"), and updates
the meta description if the page has one. 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 listings are built in the browser, so search engines and link previews see one generic Property page, not one page per home. If listings matter for search, publish each home as its own page or post (or use a real-estate listings plugin) and link to those instead.
Accessibility#
Built in: a skip link to the main content (added by WordPress, styled by the theme), landmarks
(header, navigation with labels, main, footer, labelled asides), one h1 per page, visible brass
focus rings (also around link cards), breadcrumbs with aria-current, the mobile menu and filter
buttons with aria-expanded, the Buy/Rent/All, grid/list and heart buttons with aria-pressed,
labelled icon buttons, a lightbox dialog that traps focus, closes with Esc and restores focus,
generated artwork with descriptive labels (role="img") or hidden from screen readers when
decorative, toasts and the calculator result announced with aria-live, form errors linked with
aria-describedby and aria-invalid, keyboard-friendly Details blocks, and reduced-motion support
for reveals, smooth scrolling and the back-to-top button.
When you edit: keep one Heading 1 per page, give images alt text (or mark them decorative), keep
link text meaningful, label any field you add to a form (<label for="...">), write meaningful
listing title and summary text (they are used in labels), and check the contrast of any colour
you change (WCAG AA).
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, a template or the HTML strings in
assets/js/main.js, 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.
pnpm start starts WordPress without rebuilding the CSS; pnpm dev does both.
To deploy, zip the theme folder (named freeframe-estate, 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-estate. All text in patterns
(including the Custom HTML blocks and their data-pattern-message attributes), templates and PHP
goes through WordPress's translation functions. The script's interface strings (counts, titles,
filter price options, heart and menu labels, toasts, form errors, "Back to top") are translated in
PHP (freeframe_estate_script_strings() in inc/setup.php, printed as
window.freeframeEstate.l10n). Create a translation with Loco Translate, or with WP-CLI and
Poedit:
wp i18n make-pot wp-content/themes/freeframe-estate wp-content/themes/freeframe-estate/languages/freeframe-estate.potSave freeframe-estate-de_DE.mo (for example) in wp-content/languages/themes/ or in the theme's
languages/ folder. Page content created by Estate setup is copied into your pages in the site's
language at that moment; translate it in the editor like any other content.
These stay in English in assets/js/main.js, as content you edit there: the listings and agents
data (titles, types, badges, features), the artwork and floor plan labels, the gallery captions,
the "Built in ..." sentence on the property page and the property page's browser title. The
contact map's labels are inside its SVG and aren't translatable either. Prices are formatted as US
dollars.
Child themes#
Use a child theme for changes you want to keep when Estate is updated. Create
wp-content/themes/estate-child/style.css:
/*
Theme Name: Estate Child
Template: freeframe-estate
*/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.
Your listings, agents and any change to the script (photos, sending the forms) belong in a copy of
assets/js/main.js in the child theme, loaded in place of the parent's from the child's
functions.php. Keeping the script's handle keeps its translations and page URLs:
add_action( 'wp_enqueue_scripts', function () {
wp_scripts()->registered['freeframe-estate']->src = get_stylesheet_directory_uri() . '/assets/js/main.js';
}, 30 );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 > Estate.
- The featured homes, the listings or the property details are empty boxes in the editor. They
are filled in by the script on the site. Look at the page itself, and keep the empty Groups and
their
ffd-classes. - Cards link to a missing page, or the Property page always shows the first home. The pages
must be published with the slugs
listingsandproperty, and permalinks must not be Plain (see One page for every listing). The address needs?id=with an existing listing id. - A neighbourhood shows no homes. The listing's
areamust match the optionvalueand the card'slocation=exactly. - The header menu shows other pages, or an old menu. A Navigation block without a chosen menu shows the most recent one. Select it and choose "Main menu" (or yours) in the block settings, in both the desktop and the mobile Navigation block.
- Estate 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 > Estate pages.
- A form, the map or an icon is gone after someone saved the page. Forms and SVG in Custom HTML
need the
unfiltered_htmlcapability, which administrators have on a single site; when other roles (or site admins on multisite) save the page, WordPress strips them. Edit those pages as an administrator, or use a form plugin. - The forms "send" but nothing arrives. They are demo forms; see Forms.
- Saved homes disappear. They are stored per browser; private windows and cleared site data reset them.
- A page shows the title twice or no title. Pages use a template without a title, because the page patterns start with their own heading. For a plain text page (privacy policy, terms), choose the Page with title template in the page's settings.
License#
MIT, which is compatible with the GPL that WordPress requires (see LICENSE.txt in the theme
folder). Free for personal and commercial use. The generated property artwork, the agent portraits,
the logo and the icon are included under the same license. DM Sans (assets/fonts/dm-sans/OFL.txt)
and DM Serif Display (assets/fonts/dm-serif-display/OFL.txt) are licensed under the SIL Open
Font License 1.1.