Freeframe

Documentation

Compass

v1.0.2Updated Oct 17, 2026A warm, editorial site for a leadership coach or consultant, with a booking flow, a self-assessment quiz and client results.

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.

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

Customising#

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:

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

ts
{
  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, add arch object-cover to the <img> className; .arch rounds the top into a full arch.
  • Studio (public/img/studio.svg on 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.

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:

  1. Call type: radio cards, one per key in CALL_TYPES. Link to /book?type=strategy (or team) to preselect a type; unknown values are ignored.

  2. Date and time: a month calendar (Monday first) and a list of time slots. Slots come from slotsFor() in src/lib/booking.ts, which applies AVAILABILITY:

    ts
    export 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,
    };

    hours is keyed by weekday (0 is Sunday) in studio time; a slot fits when its whole length (minutes of the call type) ends before the window closes. demoBusyShare hides a stable share of slots so the demo looks realistic; set it to 0 once you connect real availability. Times are shown in the visitor's time zone (detected with Intl), with the studio time underneath and a "Show times in" selector to switch to studio time. Slots that cross midnight are labelled (+1 day).

  3. Details: a validated form ([data-booking-form]) with name, email, role, optional company, a message of at least 20 characters and a consent checkbox.

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

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.

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:

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 onDetails in Booking.tsx, post the details plus the chosen slot (type, slotAt.toISOString()) to a form service before goTo(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() in src/lib/booking.ts with 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:

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

Consider 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#

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=/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.

Search documentation

Search the guides and every template's documentation