Freeframe

Documentation

Keystone

v1.0.1Updated Oct 14, 2026An architectural, high-trust site for a design-build construction firm, with a project estimator and case studies.

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.

sh
pnpm install
pnpm dev

Open 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.md

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

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

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

ts
{
  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.

The header lives in src/components/SiteHeader.tsx and reads its menus from src/data/site.ts:

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) and src/lib/estimate.ts (the calculation and saved state).

  • The data:

    ts
    export 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) in src/lib/estimate.ts): floor area x the type's rate per ft², times the finish and timeline multipliers, times DESIGN.mult when 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 $640k or $3.08M.

  • Changing prices: edit rate and mult. The option cards show the type's rate and each option's meta text, so update meta when you change a multiplier.

  • Adding a project type, finish or timeline: add a key to TYPES, FINISH or TIMELINE; a card appears in its step. Size limits, step, starting size and help text come from TYPES and 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=renovation preselects a type (used by the services and case study pages), and /estimate#request opens 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 use aria-current="step". Moving between steps with the buttons puts focus on the new step's heading.

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.

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

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

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:

ts
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):

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

Consider 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/footer landmarks, 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-controls and Esc to close.
  • Filter chips with aria-pressed, a live result count, accordion buttons with aria-expanded, estimator steps with aria-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 alt text on drawings; decorative SVG is aria-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#

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

Search documentation

Search the guides and every template's documentation