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. This is the Astro version: Astro 7, TypeScript and Tailwind CSS v4,
with no UI framework and no runtime dependencies. It builds to plain static files. This is version 1.0.0 (see
CHANGELOG.md). 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 or newer and pnpm (npm install -g pnpm, or use npm with the same script names).
pnpm install
pnpm dev # http://localhost:4321, reloads as you editOther scripts:
| Command | What it does |
|---|---|
pnpm build |
Builds the static site into dist/ |
pnpm preview |
Serves dist/ locally |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
astro sync && tsc --noEmit with TypeScript 7 |
pnpm lint |
ESLint (typescript-eslint and eslint-plugin-astro) |
Pages#
| File | Route | Purpose | Notable sections and features |
|---|---|---|---|
src/pages/index.astro |
/ |
Home | 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 |
src/pages/services.astro |
/services |
Services | Eight services with what's included, typical timeline and fee; filter chips by area with live counts and a shareable ?area= URL |
src/pages/fees.astro |
/fees |
Fees | Fee estimator (matter, complexity, VAT, employer contribution, payment plan), full fee table, staged tribunal fees, employer contributions, payment plans and insurance |
src/pages/check.astro |
/check |
Check your options | Branching questionnaire with plain-English results, and a tribunal deadline calculator |
src/pages/resources.astro |
/resources |
Guides | Featured guide, topic filters, search, empty state, shareable ?tag= and ?q= URLs |
src/pages/article.astro |
/article |
Sample guide | Long-form article with breadcrumb, reading progress bar, sticky table of contents, not-legal-advice notices, author box and related guides |
src/pages/about.astro |
/about |
About | Story, three promises, credentials timeline, office illustration and hours, regulation, insurance, complaints and confidentiality (#regulation) |
src/pages/book.astro |
/book |
Book a call | Four-step booking flow with a live summary sidebar, plus a short message form |
src/pages/404.astro |
404.html |
Not found | Struck-out page illustration and links back into the site |
Project structure#
brief/
astro.config.mjs site URL, base path, static file output
package.json scripts and dependencies
tsconfig.json extends Astro's strict preset
eslint.config.mjs ESLint flat config
public/img/
favicon.svg logo mark, used as the favicon
og-image.svg social sharing image source (1200 x 630)
og-image.png the same image as PNG, referenced by og:image
tessa-portrait.svg illustrated portrait (480 x 560)
office.svg office and harbour illustration on the about page (640 x 460)
src/
pages/*.astro one file per page (index, services, fees, check, resources,
article, about, book, 404)
layouts/Layout.astro <html>, <head> metadata, reading progress bar, header, footer
and the script imports
components/
SiteHeader.astro logo, main menu, phone number, Book button and mobile menu
SiteFooter.astro link lists, contact details, the not-legal-advice notice,
the regulatory statement and copyright
data/
practice.ts matters and fees, the options check, deadline rules, call types
and booking availability
nav.ts header, mobile menu and footer links
lib/site.ts site name, theme colour and the url() helper
scripts/
main.ts all interactivity, one block per feature
back-to-top.ts the back-to-top button
styles/global.css Tailwind source: brand tokens (@theme) and component classes
README.md, CHANGELOG.md, DOCS.mdCustomising#
Brand and colours#
All colours are tokens in the @theme block at the top of src/styles/global.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/ and the inline SVGs (logo, page illustrations) use the hex values directly;
search for #d4eb5c and #13221c to update them too. The browser theme-color is site.themeColor in
src/lib/site.ts.
Typography#
Two Google Fonts are loaded by the <link> in src/layouts/Layout.astro:
- Literata (
--font-serif, used throughfont-serifand the.displayclass) for headings, prices and quotes. - Albert Sans (
--font-sans) for everything else.
To swap them, change the Google Fonts URL in the layout and the two --font-* tokens in
src/styles/global.css. Both tokens include system fallbacks, so the site still reads well if the fonts fail
to load.
Content#
Most text lives directly in the page files in src/pages/. The data that drives the interactive features
lives in src/data/practice.ts:
| Export | What it holds | Used by |
|---|---|---|
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, options check results, booking topics |
LEVELS |
["straightforward", "standard", "complex"], the ?level= values |
Fee estimator |
VAT_RATE |
0.2 |
Fee estimator |
PLAN |
{ minFee: 1000, months: 3 } |
Payment plan in the estimator |
CHECK_STEPS |
The questions of the options check | Options check |
CHECK_RESULTS |
The 15 result screens | Options check |
CHECK_KEY |
"brief-check-v1", the localStorage key |
Options check |
DEADLINE_RULES |
Claim types and their time limits in months | Deadline calculator |
CALL_TYPES |
Booking call types: label, minutes, price, where | Booking flow |
AVAILABILITY |
Time zone, weekly hours, notice, closed days | Booking flow |
The file also exports the TypeScript types (Matter, CheckStep, CheckResult, DeadlineRule, CallType,
Availability), so a typo in a new entry shows up in pnpm check and your editor.
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's matter list and the booking topic list. Also add a card to
src/pages/services.astro (copy an existing <li id="..."> and give it the same key as its id) and a row to
the fee table in src/pages/fees.astro, so the static pages match the estimator.
Services on the services page are plain markup. Each card is an <li> with an id (used by links such as
url("/services#settlement")) and a data-tags value naming its area (leaving, at-work, disputes or
new-job). Guides on the resources page are <li data-guide data-tags="..." data-text="..."> items;
data-text holds extra search words that aren't visible on the card.
Text in .astro files is HTML: entities such as ’ and £ work as they are. A literal { or }
in text must be written as {"{"} or {"}"}, because braces start an expression.
Images and artwork#
All artwork is SVG, so it stays sharp and light. Files in public/ are copied to the site as they are, and
pages reference them with url(), for example src={url("/img/office.svg")}.
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 in the header and footer, the document drawings in the call to action, options check, featured guide and 404 page, and every icon.
public/img/og-image.svgis the source of the social image. Social networks need a PNG, so export it topublic/img/og-image.pngat 1200 x 630 after editing (any vector editor or a headless browser).public/img/favicon.svgis the favicon.
Navigation, header and footer#
The header and mobile menu live in src/components/SiteHeader.astro, the footer in
src/components/SiteFooter.astro, and their links in src/data/nav.ts:
| Export | Where it shows |
|---|---|
MAIN_NAV |
The desktop menu |
MOBILE_NAV |
The mobile menu: Home, MAIN_NAV and the booking page |
FOOTER_HELP |
Footer, "Help with" (the services page filtered with ?area=) |
FOOTER_PRACTICE |
Footer, "Practice" |
Each link has a label, an href written without .html ("/fees", "/about#regulation") and, for header
links, a page name. A page passes current to <Layout> (current="fees"), and the header links whose
page matches get aria-current="page", which also draws the highlighter underline. The article page passes
current="resources", so Guides stays highlighted.
To add a page, create src/pages/<name>.astro with a <Layout title="..." description="..." current="<name>">
wrapper, then add it to MAIN_NAV (it is added to the mobile menu automatically) and, if you like, to a footer
list. Always link with url("/<name>") so the link works under a base path.
The footer holds the not-legal-advice notice and the regulatory statement. Keep both, and replace the placeholder registration number with your own.
Features#
All interactivity is in src/scripts/main.ts, imported once by the layout; each feature only runs on pages
that have its markup.
Fee estimator#
On the fees page (#estimator), inside [data-estimator]. Code: initEstimator().
- The matter list is built from
MATTERS. The three level cards (input[name="est-level"], values0-2) 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 (default 500, 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), so links such as "Estimate this fee" on the services page open the right estimate. Unknown values fall back to a settlement agreement at the straightforward level.
The result 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 the check page, inside [data-check]. Code: initCheck(), replayCheck() and readCheck().
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, estimator and booking
(/book?topic=<service>), a "There may be a time limit" prompt that preselects the right claim type in the
calculator, and a collapsible recap of the answers. Back and Start again buttons 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> with a <legend> that receives focus when it appears; answers are radio
buttons styled as cards. A missing answer shows an inline error with role="alert".
Deadline calculator#
On the check page (#deadline), inside [data-deadline]. Code: deadlineFrom() and the block after it.
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 gives 30 November), the last
day is the final day of that month. DEADLINE_RULES sets the months per claim type; statutory redundancy pay
uses six. The result shows the date, the number of days left ("1 day", "12 days"), and warnings when the date
is 21 days away or less, today, or 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.
Booking flow#
On the book page, inside [data-booking]. Code: initBooking().
- Call type from
CALL_TYPES./book?type=advicepreselects one. - Date and time: a month calendar (Monday first) and time slots generated from
AVAILABILITY: weeklyhourswindows in UK time,slotStepminutes apart, 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.
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, and so do the summary and confirmation.
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. Unavailable days are aria-disabled, the
chosen day is aria-pressed, 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 (
[data-filter-root]on the services page): chips withdata-filtershow cards whosedata-tagscontain the value, update the counts and the "8 services shown" status, and keep the choice in?area=. - Guides (
[data-guides]on the resources page): topic chips (data-tag) and the search box combine. Search matches every word against the card text and itsdata-text. 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.
Article reading aids#
On the article page, the layout's progress prop adds [data-progress], the bar at the top of the window;
it fills as you scroll through [data-article]. The table of contents ([data-toc]) marks the section you're
reading with aria-current="true". Each <h2> in the article needs an id that matches a TOC link. To use
the bar on another long page, pass progress to <Layout> and wrap the content in data-article.
FAQ accordion, scroll reveals and motion#
Accordion buttons (.acc-btn) toggle the panel named in aria-controls and update aria-expanded.
Elements with .reveal fade in as they scroll into view, and the hero highlighter (.draw-mark) draws in
once. With "reduce motion" turned on, everything appears immediately. The layout adds the js class to
<html> in an inline script, so content is only hidden for the reveal when JavaScript runs.
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. The back-to-top button (src/scripts/back-to-top.ts) appears after about one
screen of scrolling and hides while the footer's "Back to top" link is visible. To remove it, delete the file,
its import in src/layouts/Layout.astro and the .back-to-top styles in src/styles/global.css.
Forms#
There are three forms. None of them sends anything until you connect it.
| Form | Where | Validates |
|---|---|---|
Booking details ([data-booking-form]) |
Book page, step 3 | Name, email, optional phone (10+ digits), topic, employer, optional deadline, description (20+ characters), acknowledgement |
Message ([data-contact-form]) |
Book page | Name, email, message (20+ characters) |
Deadline calculator ([data-deadline]) |
Check page | A date within the last two years or the next six months |
Validation runs on submit and again as fields are corrected. Each field declares its rule with
data-validate (required, email, phone-optional, message, consent, date-event). Errors appear
under the field, are linked with aria-describedby, set aria-invalid="true", and focus moves to the first
invalid field.
Connecting to Formspree#
Create a form at Formspree, then replace the marked comment in the [data-contact-form] submit handler in
src/scripts/main.ts:
fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(cf),
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error("Send failed");
// show the success panel here
})
.catch(() => {
alert("Sorry, your message could not be sent. Please email or call instead.");
});Move the lines that hide the form and show [data-success] into the then callback, and change the success
text from "ready to send" to "sent". The booking form has the same marked comment in its submit handler.
Taking real bookings#
The simplest route is a scheduling tool such as Cal.com or Calendly: replace the step 2 calendar in
src/pages/book.astro with the tool's embed, or send people straight to your booking link from step 1. If you
keep this calendar, post the details form to your form service (as above, using new FormData(form)) and add
the chosen slot:
const data = new FormData(form);
data.append("call_type", state.type);
data.append("start", state.slot.at.toISOString());Set AVAILABILITY.demoBusyShare to 0 in src/data/practice.ts, and keep closed up to date with your
holidays.
Because enquiries can contain sensitive personal information, use a form service that stores data in a
suitable region, and say in your privacy notice how you handle it. The site is static, so there is no server
code to secure, but never put API keys or secrets in src/: everything there ends up in the browser.
Saved data (localStorage)#
| Key | Shape | Written by |
|---|---|---|
brief-check-v1 |
{ "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. On load, readCheck() replays the
answers through CHECK_STEPS; if any index is out of range, or the answers don't end in a result, the saved
value is ignored and the intro is shown. If you change the questions, change CHECK_KEY in
src/data/practice.ts (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 page passes title and description to <Layout>. The layout outputs the meta description, a canonical
link, Open Graph tags (og:title, og:description, og:type, og:site_name, og:url, og:image),
twitter:card and theme-color. 404.astro passes noindex, which outputs
<meta name="robots" content="noindex"> and no canonical.
Canonical, og:url and og:image are absolute URLs built from site in astro.config.mjs. Set your domain
when building:
SITE_URL=https://www.example.com pnpm buildor change the fallback (https://brief.example.com) in astro.config.mjs.
Suggestions: add @astrojs/sitemap (or a hand-written public/sitemap.xml) and a public/robots.txt, and add
LegalService structured data (schema.org JSON-LD) to index.astro 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:
- 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, keyboard calendar, focus moved to new 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 keep text on lime dark
(text-ink).
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as fees.html rather than fees/index.html
(build.format: "file" in astro.config.mjs), and links point to /fees without a trailing slash. Netlify,
Cloudflare Pages and GitHub Pages serve /fees from fees.html automatically; Vercel does with cleanUrls
(below). 404.html is served for unknown URLs by most hosts.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/feesservesfees.html. - GitHub Pages: use the official Astro GitHub Action.
For a project site (
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/feestofees.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, for example
DEMO_BASE_PATH=/brief pnpm build. It becomes Astro's base, and every link and image written with url()
includes it, including the links the scripts write (the estimator's "Book a free call about this" and "What's
included" links and the options check's recommendations).
Tailwind scans src/ for class names, including the strings in src/scripts/main.ts. Classes that only appear
inside TypeScript strings must be written out in full (for example "tag tag-amber"), not assembled from
pieces.
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides
the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6
API that astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a
single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- New Tailwind classes have no effect. Make sure the class appears in full in a file under
src/. Restartpnpm devif you added a new folder. - The fee estimator, check or booking shows nothing. Check the browser console and run
pnpm check. A mistake inMATTERS,CHECK_STEPSorCALL_TYPESstopsmain.ts. - An option in the check leads nowhere. Every
nextmust name a key ofCHECK_STEPS, and everyresulta key ofCHECK_RESULTS. - The check keeps showing an old result. It's saved in
localStorage. Click Start again, or changeCHECK_KEYafter changing the questions. - The booking calendar has no free days. Check
AVAILABILITY.hours(day 0 is Sunday),minNoticeHours,daysAheadandclosed, and thatdemoBusyShareis below 1. - Booking times look an hour out. Slots are in
Europe/Londonand follow UK daylight saving. ChangeAVAILABILITY.timeZoneandzoneNameif you practise elsewhere. - Links or images break in a sub-folder. Write internal links and
public/paths withurl()(href={url("/fees")}), never as a bare"/fees", and build withDEMO_BASE_PATH. - The social preview has no image. Build with
SITE_URLset to your real domain soog:imageis an absolutehttps://URL toog-image.png.
License#
MIT, like every Freeframe template: free for personal and commercial use. See LICENSE.txt in the download.
The SVG illustrations, portrait and icons are original artwork included under the same license. The practice,
people, reviews and fees are fictional.