Overview#
Keystone is a multi-page website template for a construction and architecture firm that designs and builds under
one contract. The demo brand is Corbel Design-Build, a fictional Milwaukee general contractor with an in-house
architecture practice (Corbel Studio), founded in 1987. The design is architectural and technical: drawing-paper and
blueprint grids, SVG line drawings with dimension lines, condensed uppercase headings and a safety-orange accent on
charcoal and concrete tones. This is the Next.js version: Next.js 16 (App Router, static export), React 19, TypeScript
and Tailwind CSS v4, with no other dependencies. This guide covers version 1.0.0 (see template.json). Live demo:
freeframe.dev/templates/keystone.
Quick start#
Requirements: Node.js 24 and pnpm.
pnpm install
pnpm devOpen http://localhost:3000. Pages reload as you edit. Other scripts:
| Script | What it does |
|---|---|
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally |
pnpm lint |
ESLint |
pnpm typecheck |
Generate route types and run TypeScript |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
src/app/page.tsx |
Home: hero with a self-drawing SVG building and crane, count-up stats, four services, featured projects, process timeline, safety and accreditation strip, client references, call-to-action band |
/projects |
src/app/projects/page.tsx |
Portfolio: nine project cards with type filter chips, live search, result count, empty state and shareable URLs |
/project |
src/app/project/page.tsx |
Case study (Malt House Lofts): facts table, challenge and solution, before/after slider, drawing gallery with lightbox, outcome numbers, related projects |
/services |
src/app/services/page.tsx |
Four service sections with scope and typical costs, sectors, three contract models, FAQ accordion |
/estimate |
src/app/estimate/page.tsx |
Five-step estimator (type, size, finish, timeline, your details) with a live cost range, build time and breakdown, and the quote request form |
/about |
src/app/about/page.tsx |
Story and milestones, values, leadership team, safety record with an SVG bar chart, open roles |
/contact |
src/app/contact/page.tsx |
Contact form, two offices with hours and live open/closed status, SVG regional map, direct email lines |
| 404 | src/app/not-found.tsx |
Keystone-and-arch line drawing and links back into the site |
Project structure#
keystone/
src/
app/
layout.tsx <html>, skip link, header, footer, page effects, back-to-top, site metadata
fonts.ts Barlow, Barlow Condensed and IBM Plex Mono (next/font/local)
globals.css Tailwind entry: @theme tokens and component classes
icon.svg favicon
page.tsx home page
not-found.tsx 404 page
projects/ project/ services/ estimate/ about/ contact/ one page.tsx per route
components/
SiteHeader.tsx licence bar, sticky header, main and mobile menus (client)
SiteFooter.tsx footer columns, address and the CORBEL wordmark
PageHero.tsx drawing-paper page header and the Breadcrumb
CtaBand.tsx orange "Next step" band
ProjectCard.tsx portfolio card
ProjectsBrowser.tsx filters, search, count and empty state (client)
BeforeAfter.tsx before/after slider (client)
Gallery.tsx case study gallery and lightbox (client)
Accordion.tsx FAQ accordion (client)
OfficeStatus.tsx open/closed badge (client)
PageEffects.tsx scroll reveals, line drawings and count-up numbers (client)
BackToTop.tsx back-to-top button (client)
Icon.tsx line icon set
Logo.tsx, Year.tsx
art/HeroDrawing.tsx the home page line drawing
estimator/Estimator.tsx, estimator/QuoteForm.tsx the estimator and quote request (client)
forms/ContactForm.tsx, forms/useValidation.tsx contact form and shared validation (client)
data/
site.ts company name, phone, email, licence number, menus
projects.ts the nine projects
estimator.ts estimator price model
offices.ts offices, opening hours and the open/closed logic
case-study.ts gallery sheets, before/after images, related projects
faq.ts services FAQ
lib/
estimate.ts estimate calculation, formatting and the saved estimate
metadata.ts pageMeta(): title, description and Open Graph per page
asset.ts asset(): prefixes public/ files with the base path
fonts/ woff2 files and their licences
public/img/ SVG artwork and the social image
next.config.ts static export and base path
template.json, package.json, README.md, DOCS.md, CHANGELOG.mdPages are Server Components. Only the interactive parts are Client Components ("use client"), so most of the site
is plain HTML in the export.
Customising#
Brand and colours#
All design tokens live in the @theme block of src/app/globals.css. Three colour families matter:
| Token family | Used for |
|---|---|
--color-char-950 … --color-char-500 |
Charcoal: dark sections, header border, text |
--color-concrete-50 … --color-concrete-400 |
Concrete: page background, borders, drawing grids |
--color-signal-300 … --color-signal-700 |
Signal orange: buttons, highlights, accents |
Use signal-500 as a fill (with char-950 text on it) and as text on charcoal. Use signal-700 for orange text on
light backgrounds; signal-500 text on white does not meet WCAG AA contrast.
To change the accent, edit the signal values:
@theme {
--color-signal-300: #7cc4ff;
--color-signal-400: #3fa5ff;
--color-signal-500: #1488f0;
--color-signal-600: #0b6fcc;
--color-signal-700: #0a5299;
}A few colours are written directly into SVG artwork as hex values such as #f15a0f: the hero drawing
(src/components/art/HeroDrawing.tsx), the map (src/app/contact/page.tsx), the safety chart
(src/app/about/page.tsx), the 404 drawing, the logo mark (src/components/Logo.tsx), src/app/icon.svg and the
files in public/img/. Search and replace them if you change the accent. The browser theme colour (#1a1b1d) is
viewport.themeColor in src/app/layout.tsx.
Useful component classes in globals.css:
| Class | What it does |
|---|---|
.btn + .btn-signal, .btn-dark, .btn-ghost, .btn-ghost-light, .btn-lg |
Square-cornered uppercase buttons |
.eyebrow, .eyebrow-light |
Mono section label with an orange square |
.h-display |
Condensed uppercase display heading |
.spec |
Small mono uppercase text for specs and labels |
.grid-paper, .grid-blueprint |
Light drawing-paper and dark blueprint grid backgrounds |
.crop |
Orange crop marks on two corners of a card |
.chip, .option-card, .input, .label, .field-error |
Filters, estimator choices and form fields |
.container-x |
The page container (max-w-7xl with side padding) |
Typography#
Three families are self-hosted from src/fonts/ with next/font/local in src/app/fonts.ts (the Latin files Google
Fonts serves, under the SIL Open Font License):
- Barlow Condensed (500–700) for headings, buttons and navigation:
--font-display. - Barlow (400–600, plus 400 italic) for body text:
--font-sans. - IBM Plex Mono (400–500) for technical labels:
--font-mono.
Each keeps its real family name (the declarations option), so globals.css and the SVG drawings
(fontFamily="IBM Plex Mono, ...") can refer to it directly. To swap a font, add its woff2 files to src/fonts/,
point the matching localFont() call at them with the new family name, and change the token in @theme:
--font-display: "Oswald", "Arial Narrow", ui-sans-serif, system-ui, sans-serif;Content#
Most page copy is written straight into each route's page.tsx. Lists that are reused or drive a feature live in
src/data/:
| File | Export | Used by |
|---|---|---|
site.ts |
site, NAV, MOBILE_NAV |
Header, footer, call-to-action band, metadata |
projects.ts |
PROJECTS, PROJECT_TYPES |
Projects page, related projects |
estimator.ts |
TYPES, FINISH, TIMELINE, DESIGN, DEFAULT_ESTIMATE |
Estimator and quote request |
offices.ts |
OFFICES |
Contact page office cards and status |
case-study.ts |
GALLERY, BEFORE_AFTER, RELATED |
Case study page |
faq.ts |
FAQ |
Services page accordion |
Projects. Each project is one object; the card, its filter chip count and the search text are all worked out from it:
{
code: "P-117",
name: "Cedarburg Mill",
place: "Cedarburg, Wisconsin",
type: "renovation",
completed: "2023",
summary: "An 1855 stone grist mill reborn as a brewery and event hall, joined to a new glass link.",
m2: 3400,
image: "/img/project-cedarburg-mill.svg",
alt: "Elevation drawing of Cedarburg Mill",
},type is one of commercial, residential, renovation or architecture. Square feet are calculated from m2
(ft2()), and the search matches the name, place, type, completion year and summary (searchText()). To add a type,
add it to the ProjectType union and to PROJECT_TYPES; a chip appears for it. The featured cards on the home page
are written into src/app/page.tsx.
Case studies. /project is one complete case study. For more, copy src/app/project/ to a new route (for example
src/app/project-third-ward/), replace the facts, text and images, give it its own gallery data, and point that
project's card link (ProjectCard.tsx links every card to /project) at the new route.
Count-up numbers. Stats use <span data-count="620" data-decimals="0">620</span>. Keep the final value as the
text inside the span: that is what visitors see without JavaScript or with reduced motion.
Team, roles and the safety chart are in src/app/about/page.tsx. Offices are in src/data/offices.ts
(see Office open/closed status).
Images and artwork#
There are no photos: every image is an SVG designed as a technical drawing, in public/img/.
| Artwork | Size / ratio | Replace with |
|---|---|---|
project-*.svg |
800 x 600 (4:3) | Project photos at 4:3, at least 1200 x 900 |
malt-house-before.svg, malt-house-after.svg |
800 x 520 | A matching pair of photos from the same viewpoint and size |
malt-house-section.svg, malt-house-interior.svg, malt-house-site.svg |
800 x 600 | Gallery photos or drawings at 4:3 |
team-*.svg |
400 x 480 (5:6) | Portraits at 5:6 |
| Hero, map, chart and 404 drawings | Inline SVG in the components and pages | Edit the SVG or swap for an <img> |
Images are plain <img> elements with src={asset("/img/...")}: asset() adds the base path when the site is served
from a sub-folder. Cards crop with object-cover, so other ratios still fit, but 4:3 looks best. When you switch to
JPEG or WebP, keep width and height in step with the real size and write a meaningful alt.
The hero drawing and the 404 drawing use the class draw. Every path inside has pathLength="1", which lets the CSS
animate the stroke from 0 to 1; elements with the class fade fade in after the lines. Give your own lines
pathLength="1" too.
The social image is public/img/og-image.png (1200 x 630), exported from public/img/og-image.svg. The favicon is
src/app/icon.svg.
Navigation, header and footer#
The header lives in src/components/SiteHeader.tsx and reads its menus from src/data/site.ts:
export const NAV = [
{ href: "/projects", label: "Projects" },
{ href: "/services", label: "Services" },
{ href: "/about", label: "About" },
{ href: "/contact", label: "Contact" },
];
export const MOBILE_NAV = [{ href: "/", label: "Home" }, ...NAV, { href: "/estimate", label: "Get an estimate" }];Add a route folder under src/app/, then add it to NAV. The current page is marked with aria-current="page"
from the URL (usePathname()); the case study marks Projects with aria-current="true". The "Get an estimate"
button is shown from the sm breakpoint up. The licence bar above the header uses site.license and site.phone.
The footer columns are the COLUMNS array in src/components/SiteFooter.tsx; the large "CORBEL" wordmark is an
aria-hidden inline SVG at the bottom of that file.
Features#
Project estimator#
/estimate walks through five steps and shows a live estimate in the summary panel (aria-live="polite"), with a
slim fixed price bar on small screens.
-
Where the code is:
src/components/estimator/Estimator.tsx(steps and summary),src/data/estimator.ts(the price model) andsrc/lib/estimate.ts(the calculation and saved state). -
The data:
tsexport const TYPES = { commercial: { label: "Commercial build", desc: "...", rate: [215, 340], min: 2000, max: 80000, step: 500, size: 12000, weeks: [26, 1], help: "..." }, ... }; export const FINISH = { standard: { ..., meta: "Base rate", mult: [1, 1] }, premium: { ..., meta: "+18–28%", mult: [1.18, 1.28] }, ... }; export const TIMELINE = { flexible: { ..., mult: [0.96, 0.97], pace: 1.15 }, standard: { ... }, fast: { ..., mult: [1.08, 1.14], pace: 0.82 } }; export const DESIGN = { label: "Architecture & permits", mult: [1.07, 1.1], weeks: [12, 20] }; export const DEFAULT_ESTIMATE = { type: "commercial", size: 12000, finish: "standard", timeline: "standard", design: true }; -
The calculation (
estimate(s)insrc/lib/estimate.ts): floor area x the type'srateper ft², times the finish and timeline multipliers, timesDESIGN.multwhen architecture is included. The low end is rounded down and the high end up to the nearest $5,000. Build time in weeks is(weeks[0] + weeks[1] x size / 1000) x pace, shown as a range of −8% to +10%.compact()formats amounts as$640kor$3.08M. -
Changing prices: edit
rateandmult. The option cards show the type'srateand each option'smetatext, so updatemetawhen you change a multiplier. -
Adding a project type, finish or timeline: add a key to
TYPES,FINISHorTIMELINE; a card appears in its step. Size limits, step, starting size and help text come fromTYPESand apply whenever the type changes. -
Size: the slider and the number box stay in step. The box updates the estimate while you type a size within range and clamps to the type's limits when it changes (on blur or Enter).
-
Links in:
/estimate?type=renovationpreselects a type (used by the services and case study pages), and/estimate#requestopens the details step. -
Accessibility: each step is a
<fieldset>with a<legend>; choices are real radio buttons and a checkbox, so arrow keys and Space work. The step buttons usearia-current="step". Moving between steps with the buttons puts focus on the new step's heading.
Projects filter and search#
src/components/ProjectsBrowser.tsx filters PROJECTS by type (chips with aria-pressed) and matches every typed
word against the search text. The count (role="status") announces "Showing 2 of 9 projects", an empty state with a
"Clear filters" button appears when nothing matches, and the URL updates to ?type=...&q=... with
history.replaceState, so filtered views can be shared (the services page links to /projects?type=commercial and
so on). Unknown type values are ignored. The URL is read with useSearchParams() inside a <Suspense> boundary,
as the static export requires; the exported HTML shows all projects.
Before/after slider#
src/components/BeforeAfter.tsx: the after image sits underneath and the before image is clipped on top with
clip-path: inset(...) driven by the --pos custom property (.before-after-clip in globals.css). An invisible
<input type="range"> covers the images, so dragging, clicking, touch and arrow keys all work, and screen readers
hear aria-valuetext such as "52% before, 48% after". The images come from BEFORE_AFTER in
src/data/case-study.ts; use two of the same size.
Gallery lightbox#
src/components/Gallery.tsx lists GALLERY and opens each sheet in a native <dialog> with showModal(): previous
and next buttons, arrow keys, Esc or a click on the backdrop to close, and focus back on the sheet that opened it.
FAQ accordion#
src/components/Accordion.tsx renders FAQ as buttons with aria-expanded and aria-controls, each answer a
role="region" panel toggled with hidden. Several answers can be open at once; the first starts open (pass
openFirst={false} to start closed).
Office open/closed status#
Each office card on /contact shows "Open now · until 5:30 pm" or "Closed · opens tomorrow 7:00 am", worked out in
the office's own time zone with Intl.DateTimeFormat (officeStatus() in src/data/offices.ts, shown by
OfficeStatus.tsx):
tz: "America/Chicago",
days: [null, [7, 17.5], [7, 17.5], [7, 17.5], [7, 17.5], [7, 16], null],Day 0 is Sunday; null means closed; hours are decimals (17.5 is 5:30 pm). Keep days in step with the hours
rows of the same office. The status depends on the visitor's clock, so the exported HTML says "Office hours" until
the page runs in the browser.
Count-up stats, scroll reveals and line drawings#
src/components/PageEffects.tsx runs on every page: elements with .reveal fade and rise into view, .draw SVGs
draw their lines, and [data-count] numbers count up once when they appear. Anything above a revealed element is
revealed too, so jumping with the End key or an anchor never leaves hidden content. All of it is skipped when the
visitor prefers reduced motion, and content is only hidden once a small script in layout.tsx has added the js
class to <html>, so nothing disappears without JavaScript.
Header, mobile menu and back to top#
The header is sticky and gains a shadow after scrolling. Below the lg breakpoint the menu button toggles
#mobile-menu, updating aria-expanded and its label; Esc closes it and returns focus to the button, and it closes
after navigating. src/components/BackToTop.tsx shows a button after about one screen of scrolling; it steps aside
while the footer's "Back to top" link is visible and lifts above the estimator's price bar, which has
data-back-to-top-avoid. To remove it, delete <BackToTop /> from layout.tsx and the .back-to-top rules in
globals.css.
Forms#
There are two forms. Both validate in the browser and neither sends anything until you connect it. The success messages say so.
| Form | File | Validates |
|---|---|---|
| Quote request | src/components/estimator/QuoteForm.tsx (estimator step 5) |
Name, email, phone (10+ digits), site, ideal start, project notes (20+ characters), consent box |
| Contact | src/components/forms/ContactForm.tsx |
Name, email, optional phone (10+ digits if filled), topic, nearest office, message (10+ characters) |
Validation is the useValidation() hook in src/components/forms/useValidation.tsx. It reads standard attributes
(required, type="email", minLength, required checkboxes) and data-validate:
data-validate |
Rule |
|---|---|
phone |
At least 10 digits |
phone-optional |
At least 10 digits if anything is typed |
A <fieldset data-group="name"> of radio buttons requires one to be chosen. Errors appear under each field as
.field-error, linked with aria-describedby, and the field gets aria-invalid="true". On a failed submit, focus
moves to the first invalid field, and fields are rechecked as they are corrected.
Connecting to Formspree#
Each form calls onValid(form) only when everything is valid. Create a form at Formspree, then post from there. In
ContactForm.tsx:
async function onValid(f: HTMLFormElement) {
const data = new FormData(f);
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: data,
headers: { Accept: "application/json" },
});
if (!res.ok) return alert("Sorry, your message could not be sent. Please call us.");
// ...then show the success panel as the demo does (setMessage, f.reset()).
}For the quote request, do the same in QuoteForm.tsx. The estimator choices are not part of that form, so append
them from the estimate prop:
const data = new FormData(f);
const r = calculate(estimate);
data.append("type", estimate.type);
data.append("size_ft2", String(estimate.size));
data.append("finish", estimate.finish);
data.append("timeline", estimate.timeline);
data.append("design", estimate.design ? "yes" : "no");
data.append("estimate", `${compact(r.low)} – ${compact(r.high)}`);Then change the demo notes ("This is a demo, so nothing was sent...") in both components. The quote reference
(CDB-26-1234, from newReference()) is random; replace it with the ID your system returns, or remove it.
If you remove the static export (see Building and deploying), you can post to a Route
Handler instead (src/app/api/contact/route.ts with export async function POST(request: Request)) or call a
Server Action from onValid.
Saved data (localStorage)#
| Key | Shape | Written |
|---|---|---|
keystone-estimate-v1 |
{"type": "commercial", "size": 12000, "finish": "standard", "timeline": "standard", "design": true} |
On every estimator change |
readEstimate() in src/lib/estimate.ts validates the stored value: type, finish and timeline must be keys of
TYPES, FINISH and TIMELINE; size must be a number within the type's min and max (otherwise the type's
starting size is used); design must be a boolean. Anything else falls back to DEFAULT_ESTIMATE, and storage errors
(private browsing, blocked storage) are ignored. A ?type= in the URL overrides the saved type. The saved answers are
read after the page loads, so the exported HTML shows the default estimate.
To reset it, delete the key in your browser's developer tools (Application, Local Storage). If you change the shape
or rename types, change ESTIMATE_KEY (for example to keystone-estimate-v2) so old values are ignored. To stop
saving, remove the saveEstimate(next) call in update() in Estimator.tsx.
SEO and sharing#
Each route exports metadata built with pageMeta(title, description) from src/lib/metadata.ts, which sets the
exact <title>, the description and the Open Graph title, description and image (public/img/og-image.png). The
layout adds the site name, a Twitter card and the theme colour. Social networks need absolute image URLs, so set your
domain when building; it becomes metadataBase in layout.tsx (the fallback is site.url in src/data/site.ts):
NEXT_PUBLIC_SITE_URL=https://www.your-domain.com pnpm buildConsider adding src/app/sitemap.ts and src/app/robots.ts listing the seven routes. For a real firm,
LocalBusiness or GeneralContractor structured data (JSON-LD) with your address, phone and opening hours helps
search engines show your details; add it as a <script type="application/ld+json"> in layout.tsx.
Accessibility#
Built in:
- Skip link,
header/nav/main/footerlandmarks, one<h1>per page and a logical heading order. - Visible focus outlines in signal orange, including on custom radio cards and the before/after slider.
- Mobile menu with
aria-expanded,aria-controlsand Esc to close. - Filter chips with
aria-pressed, a live result count, accordion buttons witharia-expanded, estimator steps witharia-current="step"and a live estimate. - Native
<dialog>lightbox with keyboard support and focus return. - Labelled form fields, inline errors linked with
aria-describedby, and focus moved to the first error. - Meaningful
alttext on drawings; decorative SVG isaria-hidden. The safety chart has a screen-reader table. - Reduced motion turns off reveals, line drawing, count-ups and smooth scrolling.
- Text colours meet WCAG AA contrast.
When editing, keep one <h1> per page, give every new input a <label>, keep orange text on light backgrounds at
signal-700, and keep the final value inside [data-count] spans.
Building and deploying#
pnpm build # static site in out/
pnpm start # preview out/next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH. To host the site
in a sub-folder, build with that folder (DEMO_BASE_PATH=/keystone pnpm build); links, scripts and asset() image
URLs all pick it up. Deploy out/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the
repository into Vercel. Most hosts serve 404.html for missing pages automatically. demo/ is the freeframe.dev
demo build and can be deleted.
To use server features (Route Handlers, Server Actions), remove output: "export" from next.config.ts and deploy to
Vercel or any Node.js host with next build and next start.
TypeScript 7 setup#
@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to
@typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6 API.
Replace both with typescript@7 once typescript-eslint supports it.
Troubleshooting#
| Problem | Fix |
|---|---|
| The estimator shows old answers | They are saved in localStorage. Delete keystone-estimate-v1 or change ESTIMATE_KEY. |
| Estimator card prices don't match the summary | Card prices come from rate and each option's meta text in src/data/estimator.ts; update meta with the multiplier. |
| The office status says "Closed" during opening hours | Check days in src/data/offices.ts (day 0 is Sunday, decimal hours) and the tz value for your city. |
| A filtered projects link shows everything | ?type= must be one of the PROJECT_TYPES values; unknown types are ignored on purpose. |
| Images or styles are missing under a sub-folder | Build with DEMO_BASE_PATH set to that folder and use asset() for files in public/. |
| Social previews show no image | Build with NEXT_PUBLIC_SITE_URL set to your live domain. |
| Content stays invisible after editing | .reveal elements are hidden until scrolled into view by PageEffects; check the browser console for errors. |
| Fonts fall back to the system font | Check that src/fonts/ still has the woff2 files named in src/app/fonts.ts. |
License#
MIT, see the repository license (included as LICENSE.txt in the download). All artwork (SVG drawings, portraits,
icons, favicon and social image) is part of the template and covered by the same license. Barlow, Barlow Condensed
and IBM Plex Mono are under the SIL Open Font License 1.1 (src/fonts/Barlow-OFL.txt, src/fonts/IBMPlexMono-OFL.txt).
Corbel Design-Build and every person, project and address in the template are fictional.