Overview#
Compass is a website template for coaches and consultants. The demo content is for Bearing, the fictional
leadership coaching practice of Nadia Whitcombe in Pittsburgh, who works with new managers, founders and senior
leaders through 1:1 coaching, a group cohort and team workshops. The design is warm and editorial: Source Serif 4
headings with italic accents, Instrument Sans body text, an ivory, dusk-indigo and marigold palette and cut-paper SVG
illustrations. 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/compass.
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 portrait and intro-call CTA, press wordmarks, "who it's for" cards, count-up outcomes, programs preview, the four-step method, testimonial carousel, free guide sign-up, final CTA |
/programs |
src/app/programs/page.tsx |
Foundations and Executive 1:1 packages, strategy session, First 90 Days cohort, team workshops, comparison table, FAQ accordion, "Recommended by your self-check" badges |
/method |
src/app/method/page.tsx |
Interactive compass diagram with tabs, 60-minute session structure, six-month engagement timeline |
/results |
src/app/results/page.tsx |
Summary stats, eight client stories with before/after metrics and filters by client type (?type=), pull quote |
/about |
src/app/about/page.tsx |
Story, studio illustration, career path, credentials, values, personal notes |
/resources |
src/app/resources/page.tsx |
Leadership self-check quiz, resource library with topic filters (?topic=), podcast, guide sign-up |
/article |
src/app/article/page.tsx |
Sample long-form article with drop cap, illustration, pull quote, "try this week" box, author card, related links |
/book |
src/app/book/page.tsx |
Four-step booking flow with a live summary sidebar (?type=strategy or ?type=team preselects a call type) |
/contact |
src/app/contact/page.tsx |
SVG map, studio address, contact cards, validated contact form, other ways to work together |
| 404 | src/app/not-found.tsx |
Wandering compass needle and links back into the site |
Project structure#
compass/
src/
app/
layout.tsx <html>, skip link, header, footer, page effects, back-to-top, site metadata
fonts.ts Instrument Sans and Source Serif 4 (next/font/local)
globals.css Tailwind entry: @theme tokens and component classes
icon.svg favicon
opengraph-image.png social sharing image (1200 x 630)
page.tsx home page
not-found.tsx 404 page
programs/ method/ results/ about/ resources/ article/ book/ contact/ one page.tsx per route
components/
SiteHeader.tsx sticky header, main and mobile menus (client)
SiteFooter.tsx footer columns, studio address and the large Bearing wordmark
Icon.tsx the line icon set
Logo.tsx the compass logo mark
PageArt.tsx the compass rose behind inner page headers
PageEffects.tsx scroll reveals and count-up numbers (client)
BackToTop.tsx back-to-top button (client)
Year.tsx current year in the footer (client)
Testimonials.tsx home page carousel (client)
Faq.tsx programs FAQ accordion (client)
RecommendedBadge.tsx "Recommended by your self-check" badge (client)
MethodCompass.tsx method diagram and tabs (client)
StoriesBrowser.tsx client stories with filters (client)
ResourceLibrary.tsx resource cards with topic filters (client)
useChipFilter.ts shared filter state and ?type= / ?topic= URLs
Quiz.tsx leadership self-check (client)
booking/Booking.tsx four-step booking flow and calendar (client)
forms/
useValidation.tsx inline validation for every form
useSuccess.ts swaps a form for its success panel
GuideForm.tsx free guide sign-up (home page and resources)
ContactForm.tsx contact form
data/
site.ts contact details, NAV menu items
programs.ts PROGRAMS: names, prices, links and summaries (quiz result)
quiz.ts QUIZ_KEY, QUIZ_QUESTIONS, QUIZ_RESULTS
booking.ts CALL_TYPES and AVAILABILITY
stories.ts STORY_FILTERS and STORIES (results page)
resources.ts RESOURCE_FILTERS and RESOURCES (library)
testimonials.ts TESTIMONIALS (home page carousel)
faq.ts FAQ (programs page)
method.ts METHOD_POINTS (method page tabs)
lib/
asset.ts prefixes public/ files with the base path
metadata.ts pageMeta(): per-page title, description and Open Graph
quiz.ts scoring, reading and saving the self-check
booking.ts date, time-zone and slot maths, .ics file
fonts/ woff2 files and their license (OFL.txt)
public/img/ nadia-portrait.svg, studio.svg, guide-cover.svg, og-image.svg (social image source)
next.config.ts static export and DEMO_BASE_PATH
template.json gallery metadata for freeframe.devCustomising#
Brand and colours#
All design tokens live in the @theme block at the top of src/app/globals.css. Each token becomes Tailwind
utilities (bg-dusk-800, text-marigold-700, border-linen-200 and so on).
| Token group | Used for |
|---|---|
--color-ivory, --color-linen-50 to --color-linen-300 |
Page background, soft section backgrounds, borders |
--color-stone-500 to --color-stone-800 |
Body copy and muted text |
--color-dusk-50 to --color-dusk-950 |
The brand indigo: buttons, links, dark sections, the footer |
--color-marigold-100 to --color-marigold-700 |
The warm accent: highlights, stars, accent buttons on dark backgrounds |
--color-clay-100 to --color-clay-700 |
Secondary accent for tags and illustrations |
To change the brand colour, replace the dusk scale:
@theme {
--color-dusk-700: #2f5d50;
--color-dusk-800: #244a40;
--color-dusk-900: #1a3730;
--color-dusk-950: #12261f;
}Keep the contrast rules noted in the comments: use marigold-700 (not 400/500) for marigold text on light
backgrounds, and stone-600 or darker for small text. Reusable pieces such as .btn, .btn-primary, .card,
.chip, .input, .eyebrow and .display are defined in the @layer components block of the same file.
The illustrations (public/img/*.svg), the logo (src/components/Logo.tsx) and the inline SVGs in the pages use
hex colours directly. If you change the palette, search for the old hex values (for example #2a3259 and
#f0c464) and replace them. themeColor in src/app/layout.tsx sets the browser bar colour.
Typography#
Two variable fonts are self-hosted with next/font/local in src/app/fonts.ts, from the same woff2 files Google
Fonts serves to the HTML version (so text measures exactly the same):
| Family | Token | Used for |
|---|---|---|
| Source Serif 4 (weights 300 to 600, roman and italic, optical sizes) | --font-serif |
Headings, numbers and quotes, through .display and font-serif |
| Instrument Sans (weights 400 to 700) | --font-sans |
Body text, buttons and labels |
Each face is registered under its plain family name, so globals.css and SVG fontFamily attributes can name it
directly. To swap a font, put the new woff2 files in src/fonts/, point the src of each localFont() call at them,
change the font-family declaration and the --font-* token in globals.css. Inline SVGs that contain text (press
wordmarks, the method compass, the podcast cover, the map and the footer wordmark) name the fonts too; quote family
names that contain digits, as in fontFamily="'Source Serif 4', Georgia, serif".
Content#
Page copy is JSX in each page.tsx. Repeated and interactive content lives in src/data/:
| File | What it holds |
|---|---|
programs.ts |
PROGRAMS: name, price, link and summary of each program, used by the quiz result; COHORT_DATES and COHORT_MIN_WEEKS: cohort start dates (rolling in the demo, see below) |
quiz.ts |
QUIZ_QUESTIONS, QUIZ_RESULTS and the storage key QUIZ_KEY |
booking.ts |
CALL_TYPES (label, minutes, price, where) and AVAILABILITY (time zone, weekly hours, slot spacing, notice, booking window, closed dates) |
stories.ts |
STORY_FILTERS (the chips) and STORIES (client stories with metrics) |
resources.ts |
RESOURCE_FILTERS and RESOURCES (library cards) |
testimonials.ts |
TESTIMONIALS for the home page carousel |
faq.ts |
FAQ questions and answers on the programs page |
method.ts |
METHOD_POINTS: the four method tabs and their panels |
site.ts |
Email, phone, address and the NAV menu |
To add a client story, add an object to STORIES:
{
id: "maya",
tags: ["founders"],
labels: [{ label: "Founder", className: "tag tag-marigold" }],
program: "Executive 1:1 · 6 months",
title: "Your headline",
text: "Two or three sentences about what changed.",
metrics: [
{ label: "Meetings per week", before: "30", after: "18", bars: [100, 60] },
{ label: "Direct reports", before: "9", after: "4", bars: [100, 44] },
],
person: { initials: "MK", avatar: "bg-dusk-100 text-dusk-800", name: "Maya Kerr", role: "Founder, Example Co" },
},tags must be values from STORY_FILTERS (managers, founders, executives, teams, career); a story can
have several. The chip counts are worked out from the data. bars are the widths in percent of the before and after
bars (the after bar is indigo when it is longer, marigold when shorter); leave them out to show only the numbers. Two
metrics give a two-column row, three a three-column row. Resources work the same way with a single topic. To add a
filter, add a { value, label } to the filters array and use that value in some items.
Programs and prices appear in src/app/programs/page.tsx, in the home page preview, in the comparison table and in
PROGRAMS; keep them in step when you change a price.
Cohort dates. First 90 Days cohorts start on the second Wednesday of January, April, July and October.
COHORT_DATES in src/data/programs.ts is null in the demo, which shows rolling dates: the first start at least
COHORT_MIN_WEEKS (6) weeks away and the one after it, so the demo never advertises a cohort that has started. Set
{ next: "2027-01-13", after: "2027-04-14" } for your real dates. <CohortDate> (src/components/CohortDate.tsx,
logic in src/lib/cohort.ts) shows them on the home page and the programs page; it renders its fallback text in
the static build and the computed date in the browser, so there is no hydration mismatch.
Images and artwork#
All artwork is SVG, so there are no photos to license. To use real photos instead:
- Portrait (
public/img/nadia-portrait.svg, on home, about, article and book pages): replace with a 4:5 photo. To keep the arch shape, addarch object-coverto the<img>className;.archrounds the top into a full arch. - Studio (
public/img/studio.svgon about): any 10:7 landscape image. - Guide cover (
public/img/guide-cover.svg): a 360 x 460 (roughly 4:5) cover of your own lead magnet. - The method compass (
MethodCompass.tsx), press wordmarks (home page), map (contact), podcast cover (resources), article illustration and the 404 compass are inline SVG. Replace the press wordmarks with your own logos, and only list publications that have really featured you.
Images in public/ are referenced with asset("/img/...") so they keep working under a base path. The social image
is src/app/opengraph-image.png (1200 x 630), rendered from public/img/og-image.svg; edit the SVG and export a new
PNG of the same size. The favicon is src/app/icon.svg.
Navigation, header and footer#
The header lives in src/components/SiteHeader.tsx and reads its links from NAV in src/data/site.ts; the mobile
menu adds Home first and "Book a free intro call" last. The current page is marked with aria-current="page"
automatically; NAV_SECTION maps routes that belong to another menu item (the article belongs to Resources). The
desktop menu appears from the lg breakpoint (1024 px); below that the menu button opens the mobile menu. Footer
columns are the COACHING and PRACTICE arrays at the top of src/components/SiteFooter.tsx. To add a page, create
src/app/<route>/page.tsx with its metadata and add it to NAV and the footer.
Features#
Booking flow#
/book renders src/components/booking/Booking.tsx, a four-step flow:
-
Call type: radio cards, one per key in
CALL_TYPES. Link to/book?type=strategy(orteam) to preselect a type; unknown values are ignored. -
Date and time: a month calendar (Monday first) and a list of time slots. Slots come from
slotsFor()insrc/lib/booking.ts, which appliesAVAILABILITY:tsexport const AVAILABILITY = { timeZone: "America/New_York", zoneName: "Eastern Time", hours: { 1: [["09:00", "12:00"], ["13:30", "17:00"]], /* ... */ 5: [["09:00", "12:30"]] }, slotStep: 30, minNoticeHours: 24, daysAhead: 45, closed: ["2026-11-26", "2026-12-25" /* ... */], demoBusyShare: 0.35, };hoursis keyed by weekday (0 is Sunday) in studio time; a slot fits when its whole length (minutesof the call type) ends before the window closes.demoBusySharehides a stable share of slots so the demo looks realistic; set it to0once you connect real availability. Times are shown in the visitor's time zone (detected withIntl), with the studio time underneath and a "Show times in" selector to switch to studio time. Slots that cross midnight are labelled(+1 day). -
Details: a validated form (
[data-booking-form]) with name, email, role, optional company, a message of at least 20 characters and a consent checkbox. -
Confirm: a summary of the request, a clear "this is a demo" notice, a Save a calendar hold (.ics) button that downloads an event file built in the browser (
icsFor()), and Book another call.
The sidebar summary updates as choices are made. The flow depends on today's date and the visitor's time zone, so it renders in the browser; the exported HTML shows step 1.
Accessibility: each step's heading receives focus when the step changes, and the step list marks the current step
with aria-current="step". Calendar days are buttons with full-date aria-labels that include the number of open
times; only one day is in the tab order, the arrow keys move by day and week, Home/End go to the start/end of the
week and Page Up/Page Down move four weeks. Unavailable days use aria-disabled="true". Time slots are native radio
buttons inside a fieldset, and a missing date or time shows an inline role="alert" message and moves focus back to
the calendar or the times.
Leadership self-check#
Quiz.tsx on /resources#quiz has three views: intro, questions and result. Questions come from QUIZ_QUESTIONS,
one at a time as a fieldset of radio buttons with a progress bar and Back/Next buttons; choosing nothing shows an
inline message. Each answer is tagged with a result type; scoreQuiz() in src/lib/quiz.ts picks the type with the
most answers, and ties go to the type listed first in QUIZ_RESULTS. The result shows the type, three steps to try,
a bar for each type and the recommended program from PROGRAMS.
To change the quiz, edit the arrays: every question needs one answer per result type you want it to count towards,
and each result's program must be a key of PROGRAMS. Link to /resources#quiz from anywhere to send people to
it. The legend of each new question receives focus so screen reader users hear it, and the result heading receives
focus when it appears.
Recommended program badge#
On /programs, each program has a <RecommendedBadge program="..." />. It stays hidden unless the self-check result
saved on this device recommends that program (foundations, executive, cohort or teams). It updates if the
quiz is taken in another tab.
Method compass#
MethodCompass.tsx on /method shows four tabs (standard role="tablist" with arrow-key, Home and End support) and
one panel each, from METHOD_POINTS. The compass needle turns to the selected point by the shortest route and the
matching point is highlighted; clicking a point in the drawing selects its tab. The drawing is decorative for
assistive technology, which uses the tabs.
Filters#
StoriesBrowser.tsx (/results) and ResourceLibrary.tsx (/resources) use useChipFilter(): chip buttons with
aria-pressed (all means everything), a live result count and an empty message. The selected filter is written to
the URL (?type= on results, ?topic= on resources) when a chip is picked, so filtered views can be shared and
linked, for example /results?type=founders. Unknown values show everything. Because they read the URL, these
components render in the browser; the exported HTML shows every item.
Testimonial carousel#
Testimonials.tsx is a horizontal scroll-snap list, so swiping, trackpads and the arrow keys (once the list is
focused) all work. The Previous/Next buttons scroll by one card and disable at the ends; the "1 / 6" counter is
visual only. There is no autoplay. Slides and their "1 of 6" labels come from TESTIMONIALS.
FAQ accordion#
Faq.tsx renders FAQ: each question is a .acc-btn button with aria-expanded and aria-controls pointing to
its answer. Set open: true on an item to open it by default; link adds a link at the end of the answer.
Count-up stats, scroll reveals and motion#
PageEffects.tsx runs on every page. Numbers marked <span data-count="340">340</span> (with data-decimals="1" for
6.5) count up once when they scroll into view; the final value is already in the markup. Elements with .reveal
fade in on scroll (add .reveal-delay-1 to -3 to stagger) and the hero's .spin-in compass rose turns in;
everything above a revealed element is revealed too. The 404 needle wanders with a CSS animation. All motion is
switched off when the visitor prefers reduced motion.
Header, mobile menu and back to top#
The header gains a border and shadow after scrolling. Below 1024 px the menu button (#menu-button) toggles
#mobile-menu, updates aria-expanded and its label, and closes on Escape (returning focus to the button), when a
link is chosen or when the window grows past 1024 px. BackToTop.tsx appears after about one screen of scrolling,
steps aside while the footer's own "Back to top" link is visible and moves focus to the main content. To remove it,
delete <BackToTop /> from src/app/layout.tsx and the .back-to-top styles in globals.css.
Forms#
There are four forms. None of them sends anything until you connect it, and each success state says so.
| Form | File | Validates |
|---|---|---|
| Guide sign-up | src/components/forms/GuideForm.tsx (home page and resources) |
First name required, email format |
| Contact | src/components/forms/ContactForm.tsx |
Name, email, optional phone (7+ digits if filled), topic, message of 20+ characters |
| Booking details | src/components/booking/Booking.tsx |
Name, email, role, message of 20+ characters, consent checkbox |
| Quiz | src/components/Quiz.tsx |
An answer is chosen before moving on |
Validation is the useValidation() hook in src/components/forms/useValidation.tsx, driven by data-validate
attributes:
data-validate |
Rule |
|---|---|
required |
Not empty (selects: an option chosen) |
email |
Required and shaped like name@company.com |
phone-optional |
Empty, or at least 7 digits |
message |
At least 20 characters |
consent |
A required checkbox that must be ticked |
The wording is in fieldMessage(). Errors appear under the field, are linked with aria-describedby, set
aria-invalid="true" and clear as the visitor fixes them; the first invalid field receives focus.
Connecting to Formspree#
Each form calls its onValid(formEl) only when everything is valid; that is the place to send it. Create a form at
Formspree, then in ContactForm.tsx:
const onValid = async (formEl: HTMLFormElement) => {
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(formEl),
headers: { Accept: "application/json" },
});
if (!res.ok) return alert("Sorry, something went wrong. Please email hello@bearing.example.");
const name = firstName(formEl);
show(`Thanks${name ? ", " + name : ""}. Your message is ready to go.`);
};Then update the success text (remove the "This is a demo" notice). For the guide sign-up you can post to your email
tool (Kit, Mailchimp, Buttondown) the same way in GuideForm.tsx.
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.
Booking flow#
You have three options:
- Replace it with an embed. In
src/app/book/page.tsx, replace<Booking />with the inline embed code from Cal.com or Calendly (as a small client component). Keep the page header. - Keep the flow and send a request. In
onDetailsinBooking.tsx, post the details plus the chosen slot (type,slotAt.toISOString()) to a form service beforegoTo(4), then confirm each request by hand. Change the step 4 notice to say a confirmation will follow. - Use a scheduling API. Load real free slots from the Cal.com or Calendly API on your own server and replace
slotsFor()insrc/lib/booking.tswith them; never put API keys in client code.
Saved data (localStorage)#
| Key | Shape | Notes |
|---|---|---|
compass-quiz-v1 |
{ "answers": [0, 2, 1, 0, 3, 2], "date": "2026-10-12" } |
One answer index per question |
parseQuiz() in src/lib/quiz.ts ignores the value unless answers is an array with exactly one whole-number index
per question, each in range. The result type is always recalculated from the answers, so editing QUIZ_RESULTS never
leaves a stale result. Retake the self-check removes the key. To clear it manually, run
localStorage.removeItem("compass-quiz-v1") in the browser console. If storage is blocked, the quiz still works; it
just isn't remembered. If you change the questions, bump QUIZ_KEY in src/data/quiz.ts (for example to
compass-quiz-v2). The saved result is read after the page loads, so the exported HTML shows the quiz intro.
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 and description; the image comes from
src/app/opengraph-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:
SITE_URL=https://www.your-domain.com pnpm buildConsider adding src/app/sitemap.ts and src/app/robots.ts listing your routes, and ProfessionalService or
Person structured data (JSON-LD) on the home and about pages with your name, address and services.
Accessibility#
Built in: a skip link, semantic landmarks, one <h1> per page and a logical heading order, visible focus rings
(:focus-visible in globals.css), WCAG AA contrast for text, aria-current in the navigation, labelled form fields
with inline errors, aria-pressed filter chips with a live result count, accessible tabs and accordion, a
keyboard-operable calendar, a carousel that never moves on its own, alt text or aria-hidden on every image and
SVG, and reduced-motion support everywhere.
When editing: keep one h1 per page, give every new input a <label>, keep the aria-controls ids of tabs and
accordion buttons in step with their panels, keep alt text meaningful when you swap illustrations for photos, and
check new colour pairs for contrast (small text on ivory should be stone-600 or darker).
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=/compass 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 |
|---|---|
| No time slots on any day | Check AVAILABILITY.hours uses weekday numbers 0 to 6 and "HH:MM" strings, the call length fits inside each window, and daysAhead is larger than minNoticeHours / 24 |
| Times look an hour off around daylight saving changes | Make sure AVAILABILITY.timeZone is an IANA name such as America/New_York, not a fixed offset |
| The quiz always shows an old result | It is saved in localStorage; click Retake the self-check or remove compass-quiz-v1, and bump QUIZ_KEY when you change the questions |
| A filter chip shows 0 or the wrong items | The chip's value must appear exactly (lowercase, no spaces) in the stories' tags or the resources' topic |
| A filtered link shows everything | ?type= and ?topic= must be one of the filter values; unknown values are ignored on purpose |
| SVG text renders in the wrong font | Quote font names that contain digits in fontFamily attributes, for example 'Source Serif 4' |
| 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/ |
| Content stays invisible after editing | .reveal elements are hidden until PageEffects reveals them; check the browser console for errors |
License#
MIT, see the repository license (included as LICENSE.txt in the download). The illustrations, icons and artwork in
public/img/, src/app/ and inline in the components are part of the template and covered by the same license.
Instrument Sans and Source Serif 4 are under the SIL Open Font License 1.1 (src/fonts/OFL.txt). Bearing and all
people, clients, companies and publications named in the demo are fictional.