Freeframe

Documentation

Brief

v1.0.0Updated Oct 15, 2026A calm, plain-English website for a solo lawyer or solicitor, with fixed fees, an options checker and booking.

Overview#

Brief is a website template for a solo lawyer, solicitor or attorney. The demo content is for Pennington Employment Law, the fictional Southampton practice of independent employment solicitor Tessa Pennington, who advises employees and senior staff on settlement agreements, redundancy, dismissal, discrimination and problems at work, mostly for fixed fees. It includes a fee estimator, a branching "what are my options?" check with a tribunal deadline calculator, services filterable by area, a searchable guide library with a long-form article and a four-step consultation booking flow. 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/brief.

The sample copy is general information about employment law in England and Wales, written for a fictional practice. Before you publish, have a qualified lawyer in your jurisdiction review every legal statement, fee, time limit and regulatory detail, and replace the placeholders (registration number, insurance, complaints procedure) with your own.

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 Hero with the plain-English promise, portrait and "Book a free 15-minute call", credentials strip, four areas of help, four-step "how working together works", fixed-fee teaser, client reviews with a disclaimer, FAQ accordion, call to action
/services src/app/services/page.tsx Eight services with what's included, typical timeline and fee; filter chips by area with live counts (?area=)
/fees src/app/fees/page.tsx Fee estimator (#estimator, ?matter=, ?level=), full fee table, staged tribunal fees, employer contributions, payment plans and insurance
/check src/app/check/page.tsx Branching options check with plain-English results, and a tribunal deadline calculator (#deadline)
/resources src/app/resources/page.tsx Featured guide, topic filters and search (?tag=, ?q=), empty state
/article src/app/article/page.tsx Long-form sample guide with breadcrumb, reading progress bar, table of contents that follows your scroll, sticky fee card, author box and related guides
/about src/app/about/page.tsx Story, three promises, credentials timeline, office illustration and hours, regulation, insurance, complaints and confidentiality (#regulation)
/book src/app/book/page.tsx Four-step booking flow with a live summary sidebar (?type=, ?topic=), plus a short message form
404 src/app/not-found.tsx Struck-out page illustration and links back into the site (noindex)

Project structure#

brief/
  src/
    app/
      layout.tsx              <html>, skip link, header, footer, page effects, back to top, site metadata
      fonts.ts                Literata and Albert Sans (next/font/local)
      globals.css             Tailwind entry: @theme tokens and component classes
      icon.svg                favicon (the logo mark)
      opengraph-image.png     social sharing image (1200 x 630)
      page.tsx                home page, including the FAQ items
      services/ fees/ check/ resources/ article/ about/ book/   one page.tsx per route
      not-found.tsx           404 page
    components/
      SiteHeader.tsx          sticky header, main menu, mobile menu
      SiteFooter.tsx          footer with links, contact details and the not-legal-advice notice
      Logo.tsx, Icon.tsx      logo mark and line icons
      Year.tsx                current year in the footer
      PageEffects.tsx         scroll reveals
      BackToTop.tsx           back-to-top button
      CallToAction.tsx        "Not sure where you stand?" panel
      Accordion.tsx           FAQ accordion
      ServiceList.tsx         services and the area filter
      Estimator.tsx           fee estimator
      OptionsCheck.tsx        the "what are my options?" check
      DeadlineCalculator.tsx  tribunal deadline calculator
      GuideLibrary.tsx        guide cards, topic filter and search
      Article.tsx             reading progress bar and table of contents
      Booking.tsx             booking flow
      ContactForm.tsx         message form
      useValidation.tsx       form validation hook and messages
    data/
      site.ts                 practice details, main menu and areas of work
      matters.ts              MATTERS, LEVELS, VAT_RATE, PLAN, the fee table's extra row, tribunal stages
      services.ts             service cards
      guides.ts               guide topics, the featured guide and the guide cards
      check.ts                CHECK_STEPS, CHECK_RESULTS, CHECK_KEY, DEADLINE_RULES
      booking.ts              CALL_TYPES and AVAILABILITY
    lib/
      dates.ts                date keys, long dates and the "three months less one day" rule
      zone.ts                 UK time maths for the booking calendar
      format.ts               money, plurals and address-bar updates
      metadata.ts             per-page metadata helper
      asset.ts                base-path-aware URLs for files in public/
    fonts/                    the woff2 files and OFL.txt
  public/img/                 tessa-portrait.svg, office.svg, og-image.svg
  next.config.ts, eslint.config.mjs, postcss.config.mjs, tsconfig.json
  package.json, pnpm-workspace.yaml, template.json
  README.md, DOCS.md, CHANGELOG.md

Customising#

Brand and colours#

All colours are tokens in the @theme block at the top of src/app/globals.css. Tailwind turns each one into utilities (bg-ink, text-pine, border-line and so on), so changing a token changes it everywhere.

Token Default Used for
--color-paper #f5f3ec Page background
--color-paper-2 #ece9df Sunken panels, tags
--color-card #fffdf8 Cards and inputs
--color-line / --color-line-strong #dcd8ca / #bfb9a6 Hairlines and input borders
--color-ink #13221c Text, primary buttons, dark sections
--color-ink-2 / --color-ink-3 #1c3229 / #2c473b Raised surfaces and hovers on ink
--color-slate #4c5852 Secondary text (AA on paper and card)
--color-slate-soft #b6c4bb Secondary text on ink
--color-pine / --color-pine-soft #1f5a43 / #dfe9e2 Links, icons and icon tiles
--color-lime / --color-lime-soft / --color-lime-deep #d4eb5c / #edf6bd / #b8d13a The highlighter accent: marks, chips, buttons on ink

The lime accent is a fill colour. Don't use it for text on light backgrounds; use text-pine or text-ink. To change the accent, for example to a warm coral:

css
@theme {
  --color-lime: #ffb199;
  --color-lime-soft: #ffe1d6;
  --color-lime-deep: #f2896b;
}

The SVG files in public/img/, the logo in src/components/Logo.tsx, src/app/icon.svg and the inline illustrations use the hex values directly; search for #d4eb5c and #13221c to update them too. The theme colour in the browser bar is viewport.themeColor in src/app/layout.tsx.

Typography#

Both fonts are self-hosted with next/font/local in src/app/fonts.ts, from the same woff2 files the HTML version loads from Google Fonts (latin and latin-ext subsets, under the SIL Open Font License in src/fonts/OFL.txt):

  • Literata (--font-serif, used through font-serif and the .display class) for headings, prices and quotes. It's a variable font with an optical-size axis, which .display turns on.
  • Albert Sans (--font-sans) for everything else.

To swap a font, put its woff2 files in src/fonts/, point the localFont() calls at them (keep the font-family declaration in step with the name you use) and change the two --font-* tokens in globals.css. Both tokens include system fallbacks.

Content#

Page copy lives in the route files (src/app/**/page.tsx). Everything that a feature reads, or that's repeated, lives in src/data/:

File and export What it holds Used by
matters.ts MATTERS Each matter's name, area, timeline, whether an employer contribution applies, three levels ([min, max] in pounds, excluding VAT) and three hints Fee estimator, fee table, options check results, booking topics
matters.ts LEVELS, VAT_RATE, PLAN, DEFAULT_CONTRIBUTION ?level= values, 0.2, { minFee: 1000, months: 3 }, 500 Fee estimator
matters.ts ADVICE_SESSION, TRIBUNAL_STAGES The last fee table row and the staged tribunal fees /fees
services.ts SERVICES The service cards: area, icon, summary, what's included, timeline and fee /services
guides.ts TOPICS, FEATURED, GUIDES Guide topics, the featured guide and the cards /resources
check.ts CHECK_STEPS, CHECK_RESULTS The questions and the 15 result screens Options check
check.ts DEADLINE_RULES Claim types and their time limits in months Deadline calculator
booking.ts CALL_TYPES, AVAILABILITY Call types, time zone, weekly hours, notice, closed days Booking flow
site.ts SITE, NAV, AREAS Phone, email, address, hours, the menu and the four areas of work Header, footer, booking, services

To add a matter, add an entry to MATTERS:

ts
holiday: {
  name: "Holiday pay claim",
  area: "Claims and disputes",
  timeline: "1–2 weeks",
  contribution: false,
  levels: [[300, 300], [450, 450], [600, 900]],
  hints: ["Checking your figures", "Plus a letter to your employer", "Several years of underpayment"],
},

It then appears in the estimator, the fee table and the booking topics. To show it on /services, add a card with the same id to SERVICES in services.ts (links such as /services#holiday, /book?topic=holiday and /fees?matter=holiday#estimator use that id). TypeScript checks that service ids are MATTERS keys and that areas are AREAS ids.

To add a guide, add an item to GUIDES in guides.ts; tags are TOPICS ids, and keywords holds extra search words that aren't visible on the card. Each card links to href: the template has one sample article, so point it at your own pages as you add them.

Images and artwork#

All artwork is SVG, so it stays sharp and light:

  • public/img/tessa-portrait.svg (480 x 560) is the portrait on the home and about pages, and is cropped to a circle or square on the article and booking pages. To use a photo, keep a 6:7 portrait crop and replace the file (or the src); the object-cover object-top classes keep the face in view in the small crops.
  • public/img/office.svg (640 x 460) on the about page.
  • Inline SVGs: the logo mark (Logo.tsx), the document drawings in CallToAction.tsx, OptionsCheck.tsx, GuideLibrary.tsx and the 404 page, and the icons in Icon.tsx.
  • public/img/og-image.svg is the source of the social image. Export it to src/app/opengraph-image.png at 1200 x 630 after editing; Next.js adds the og:image tags.
  • src/app/icon.svg is the favicon.

Files in public/ are referenced through asset("/img/...") (src/lib/asset.ts) so they keep working when the site is built for a sub-folder.

The header (SiteHeader.tsx) reads the menu from NAV in src/data/site.ts; the mobile menu adds Home first and "Book a free call" last. Links to the current route get aria-current="page", which also draws the highlighter underline. To add a page, create src/app/<route>/page.tsx with its own metadata, add it to NAV and, if it belongs there, to the footer's PRACTICE_LINKS in SiteFooter.tsx.

The footer holds the not-legal-advice notice and the regulatory statement. Keep both, and replace the placeholder registration number with your own (it also appears on /about#regulation).

Features#

Fee estimator#

On /fees (#estimator). Code: src/components/Estimator.tsx.

  • The matter list comes from MATTERS. The three level cards (input[name="est-level"], values 0 to 2) show the matter's hints.
  • When min equals max the result shows a fixed fee with a "Fixed fee" tag; otherwise a range with a "Range: fixed in writing before work starts" tag.
  • Show prices including VAT multiplies by 1 + VAT_RATE. The breakdown always lists the fee, VAT and the total including VAT.
  • My employer is contributing only appears for matters with contribution: true (settlement agreements and exit negotiations). The amount (default DEFAULT_CONTRIBUTION, excluding VAT) is deducted from the fee, never below zero.
  • I'd like to spread the cost shows PLAN.months equal payments when the amount you pay (after any contribution, excluding VAT) is at least PLAN.minFee, and otherwise explains why it isn't available.
  • The matter and level are written to the URL (/fees?matter=tribunal&level=complex), and links such as "Estimate this fee" on /services or the options check open the right estimate. Changing the URL on the page (a link to another matter) updates the estimator too.

The estimate is announced politely to screen readers (aria-live). Every control is a native select, radio, checkbox or number input, so it works with a keyboard.

Options check#

On /check. Code: src/components/OptionsCheck.tsx; data in src/data/check.ts.

Each step in CHECK_STEPS has a question q, a hint (or "") and options. An option has either next (another step id) or result (a key of CHECK_RESULTS). The first step is always start. A result has a tag, title, summary, steps, an urgent message (or null), a service (a MATTERS key, or null) and a deadline (a DEADLINE_RULES key, or null).

The result screen shows the summary and next steps, an amber "Time-sensitive" box when urgent is set, the recommended service with its lowest fee and links to the services page, the estimator and booking (/book?topic=<service>), a "There may be a time limit" prompt whose link scrolls to the calculator and preselects the right claim type, and a collapsible recap of the answers. Back and Start again let visitors change their answers.

To add a branch, add a step and point an option at it:

ts
"tupe-stage": {
  q: "Has the transfer happened yet?",
  hint: "",
  options: [
    { label: "Not yet", result: "general" },
    { label: "Yes", result: "general" },
  ],
},

Each question is a <fieldset> whose <legend> receives focus when it appears; answers are radio buttons styled as cards. A missing answer shows an inline error with role="alert". The result heading receives focus when the result appears.

Deadline calculator#

On /check#deadline. Code: src/components/DeadlineCalculator.tsx, with deadlineFrom() in src/lib/dates.ts.

It applies the "three months less one day" rule: the last day is the day before the same date three months later (15 March gives 14 June). If the later month has no such date (31 August to November), the last day is the final day of that month (30 November). DEADLINE_RULES sets the months per claim type; statutory redundancy pay uses six. The result shows the date, the number of days left from today ("1 day", "12 days") and the card's data-state: ok, urgent (21 days or less, or today) or late (the date has passed). Dates must be within the last two years or the next six months.

This is an estimate only and says so on screen. It doesn't model early conciliation extensions, continuing acts or late-claim discretion, and it doesn't store anything. "Today" is the visitor's own date, read in the browser, so the static pages never contain a stale date.

Booking flow#

On /book. Code: src/components/Booking.tsx, with the UK time maths in src/lib/zone.ts and the data in src/data/booking.ts.

  1. Call type from CALL_TYPES. /book?type=advice preselects one.
  2. Date and time: a month calendar (Monday first) and time slots generated by slotsFor() from AVAILABILITY: weekly hours windows in UK time, slotStep minutes apart, long enough for the call, at least minNoticeHours from now, up to daysAhead days ahead, skipping closed dates. demoBusyShare hides a stable share of slots so the demo looks realistic; set it to 0 when you connect a real calendar.
  3. Details: name, email, optional phone, topic (/book?topic=settlement preselects it), the conflict-check fields (employer, required; anyone else involved, optional), an optional deadline, a short description and a required acknowledgement that booking doesn't create a client relationship.
  4. Confirmation with a summary and a Save a calendar hold (.ics) button (pennington-<type>-call.ics).

Times are always generated in Europe/London, so they follow UK daylight saving. If the visitor's device is in another time zone, each slot also shows their local time ("2:30pm yours"), and so do the summary and confirmation. The calendar is built in the browser when step 2 opens, so the static HTML never contains a stale date.

The calendar is a table of buttons with a roving tabindex: arrow keys move by day and week, Home and End to the start and end of the week, Page Up and Page Down by four weeks, clamped to today and the last bookable day. Unavailable days are aria-disabled, the chosen day is aria-pressed, today has aria-current="date" and each day has a full label such as "Tuesday 20 October 2026, 9 times available". Each step's heading receives focus when the step opens.

  • Services (ServiceList.tsx): chips with data-filter show the cards in that area, update the counts and the "8 services shown" status, and keep the choice in ?area=.
  • Guides (GuideLibrary.tsx): topic chips (data-tag) and the search box combine. Search matches every word against the card's text and its keywords. Both are kept in the URL as ?tag= and ?q=. When nothing matches, an empty state offers to clear everything.

Chips are buttons with aria-pressed, and the result counts are in a polite live region. Unknown values in the URL show everything.

Article reading aids#

On /article, ReadingProgress (in Article.tsx) is the bar at the top of the window; it fills as you scroll through [data-article]. Toc marks the section you're reading with aria-current="true". Each <h2> in the article needs an id that matches an item in the page's TOC list. From 1280px a fee card sits beside the article and stays in view as you read.

FAQ accordion, scroll reveals and motion#

Accordion.tsx renders each question as a button that toggles the panel named in aria-controls and updates aria-expanded; the home page's questions are the FAQ list in src/app/page.tsx. Elements with .reveal fade in as they scroll into view (PageEffects.tsx), and the hero highlighter (.draw-mark) draws in once. With "reduce motion" turned on, everything appears immediately.

Header, mobile menu and back to top#

The header gains a border once you scroll. Below 1024px the menu button opens #mobile-menu; Escape closes it and returns focus to the button, and choosing a link closes it. The back-to-top button (BackToTop.tsx) appears after about one screen of scrolling, hides while the footer's "Back to top" link is visible and moves focus to the main content.

Forms#

There are three forms. None of them sends anything until you connect it.

Form File Validates
Booking details ([data-booking-form]) src/components/Booking.tsx Name, email, optional phone (10+ digits), topic, employer, description (20+ characters), acknowledgement
Message ([data-contact-form]) src/components/ContactForm.tsx Name, email, message (20+ characters)
Deadline calculator ([data-deadline]) src/components/DeadlineCalculator.tsx A date within the last two years or the next six months

Validation is the useValidation() hook in src/components/useValidation.tsx. Each field declares its rule with data-validate (required, email, phone-optional, message, consent, date-event), and fieldMessage() holds the wording. Errors appear under the field, are linked with aria-describedby, set aria-invalid="true", focus moves to the first invalid field, and they're checked again as the visitor fixes them.

Connecting to Formspree#

Create a form at Formspree, then replace the marked comment in submit() in ContactForm.tsx:

tsx
async function submit(e: FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const f = e.currentTarget;
  if (!v.validate(f)) return;
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(f),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return alert("Sorry, your message could not be sent. Please email or call instead.");
  const name = String(new FormData(f).get("name") ?? "").trim().split(/\s+/)[0] || "";
  flushSync(() => setSentTo(name));
  success.current?.focus();
}

Then change the success text from "ready to send" to "sent" and remove the "This is a demo" sentence. The booking form has the same marked comment in its submit().

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 submit().

Taking real bookings#

The simplest route is a scheduling tool such as Cal.com or Calendly: replace <Booking /> in src/app/book/page.tsx with the tool's embed, or send people straight to your booking link from step 1. If you keep this flow, post the details form to your form service or Route Handler from submit() in Booking.tsx (as above, using new FormData(f)) and add the chosen slot:

ts
const data = new FormData(f);
data.append("call_type", type);
data.append("start", slot.at.toISOString());

Set AVAILABILITY.demoBusyShare to 0, keep closed up to date with your holidays, and show the confirmation only once your server has accepted the request.

Because enquiries can contain sensitive personal information, use a form service or host that stores data in a suitable region, and say in your privacy notice how you handle it.

Saved data (localStorage)#

Key Shape Written by
brief-check-v1 (CHECK_KEY) { "answers": [0, 1], "date": "2026-10-15" } Options check, when a result is reached

answers holds the chosen option index for each question in order. When /check loads, readCheck() replays the answers through CHECK_STEPS; if the value isn't valid JSON, any index is out of range or not a whole number, or the answers don't end in a result, the saved value is ignored and the intro is shown. If you change the questions, bump CHECK_KEY (for example to brief-check-v2) so old answers are dropped. Start again removes the key; visitors can also clear site data in their browser. Nothing else is stored, and nothing is sent anywhere.

SEO and sharing#

Each route exports metadata built with pageMeta(title, description) from src/lib/metadata.ts, which sets the exact <title>, the description, Open Graph tags (article for the guide) and a Twitter card; the image comes from src/app/opengraph-image.png. The 404 page adds robots: { index: false }. 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, a canonical URL per page (alternates.canonical in metadata), and LegalService structured data (schema.org JSON-LD) on the home page with your real name, address, phone and opening hours. The template doesn't include structured data because it would describe a fictional practice. Check your regulator's rules on advertising, reviews and how fees must be published.

Accessibility#

Built in:

  • A skip link, landmarks (header, nav, main, footer), one <h1> per page and a logical heading order.
  • Visible focus outlines; colours meet WCAG AA (body text slate on paper is about 7:1).
  • Native form controls with labels, hints linked by aria-describedby, inline errors and focus management.
  • Accessible patterns: mobile menu (aria-expanded, Escape), accordion, chips (aria-pressed), live regions for counts and the estimate, the keyboard calendar, focus moved to new questions, steps and results.
  • prefers-reduced-motion turns off reveals, the highlighter animation and smooth scrolling.

When editing, keep one <h1> per page, give every new input a <label> (and data-validate when it needs checking), and keep text on lime dark (text-ink).

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=/brief 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. Serve the site over HTTPS, especially once forms are connected.

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
An option in the check leads nowhere Every next must name a key of CHECK_STEPS, and every result a key of CHECK_RESULTS
The check keeps showing an old result It's saved in localStorage. Click Start again, or bump CHECK_KEY after changing the questions
The booking calendar has no free days Check AVAILABILITY.hours (day 0 is Sunday), that the call's minutes fit inside a window, minNoticeHours, daysAhead and closed, and that demoBusyShare is below 1
Booking times look an hour out Slots are in Europe/London and follow UK daylight saving. Change AVAILABILITY.timeZone and zoneName if you practise elsewhere
A filter or estimator link opens the default view The parameter must match exactly, for example /services?area=at-work, /resources?tag=redundancy or /fees?matter=tribunal&level=complex
A new service doesn't appear in the estimator The estimator reads MATTERS, not SERVICES: add the matter there too, with the same id
The social preview has no image Build with SITE_URL set to your domain so og:image is an absolute URL
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/

License#

MIT, see the repository license (included as LICENSE.txt in the download). The SVG illustrations, portrait, icons and social image are original artwork included under the same license. Literata and Albert Sans are under the SIL Open Font License 1.1 (src/fonts/OFL.txt). The practice, people, reviews and prices are fictional.

Search documentation

Search the guides and every template's documentation