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.
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 |
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.mdCustomising#
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:
@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 throughfont-serifand the.displayclass) for headings, prices and quotes. It's a variable font with an optical-size axis, which.displayturns 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:
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 thesrc); theobject-cover object-topclasses 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 inCallToAction.tsx,OptionsCheck.tsx,GuideLibrary.tsxand the 404 page, and the icons inIcon.tsx. public/img/og-image.svgis the source of the social image. Export it tosrc/app/opengraph-image.pngat 1200 x 630 after editing; Next.js adds theog:imagetags.src/app/icon.svgis 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.
Navigation, header and footer#
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"], values0to2) show the matter'shints. - When
minequalsmaxthe 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 (defaultDEFAULT_CONTRIBUTION, excluding VAT) is deducted from the fee, never below zero. - I'd like to spread the cost shows
PLAN.monthsequal payments when the amount you pay (after any contribution, excluding VAT) is at leastPLAN.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/servicesor 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:
"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.
- Call type from
CALL_TYPES./book?type=advicepreselects one. - Date and time: a month calendar (Monday first) and time slots generated by
slotsFor()fromAVAILABILITY: weeklyhourswindows in UK time,slotStepminutes apart, long enough for the call, at leastminNoticeHoursfrom now, up todaysAheaddays ahead, skippingcloseddates.demoBusySharehides a stable share of slots so the demo looks realistic; set it to0when you connect a real calendar. - Details: name, email, optional phone, topic (
/book?topic=settlementpreselects 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. - 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.
Filters and search#
- Services (
ServiceList.tsx): chips withdata-filtershow 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 itskeywords. 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:
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:
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:
SITE_URL=https://www.your-domain.com pnpm buildConsider 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
slateonpaperis 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-motionturns 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#
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.