Overview#
Counsel is a multi-page website for a law firm or another professional services practice. The fictional firm is Hartwell & Sloane LLP, an independent, partner-led firm founded in 1987 with offices in Boston (headquarters), Hartford and Providence, advising businesses and families across New England. This is the Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4), version 1.0.1, ported page for page from the HTML template, and it generates to static files. The template page, with a link to the live demo, is at freeframe.dev/templates/counsel.
The look is refined and quiet: warm ivory paper, deep navy bands and brass accents, Libre Caslon Display for headings and IBM Plex Sans for text. It is a light-only design.
The site is a front end only: the consultation and newsletter forms check what is entered but don't send anything until you connect them (see Forms).
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev # http://localhost:3000Other scripts:
| Script | What it does |
|---|---|
pnpm generate |
Static site in .output/public/ (nuxt generate): every page prerendered to HTML |
pnpm build |
A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint (@nuxt/eslint) |
pnpm typecheck |
nuxt prepare and vue-tsc |
pnpm build:demo |
Generate for freeframe.dev under /demos/nuxt/counsel into demo/ |
Pages#
| Route | File | Purpose and notable sections |
|---|---|---|
/ |
app/pages/index.vue |
Hero with the courthouse illustration, count-up statistics, the eight practice areas, "How we work", four attorneys, client statements, the latest three insights and a call-to-action band |
/practice-areas |
app/pages/practice-areas.vue |
All practice areas with audience filter chips and live counts (PracticeGrid), and a "Not sure which area fits?" panel |
/practice-area |
app/pages/practice-area.vue |
Employment Law in depth: breadcrumb, "At a glance" box, services, how a matter runs, representative results, FAQ accordion and the team |
/attorneys |
app/pages/attorneys.vue |
Attorney directory with name search, a practice filter and an empty state (AttorneyDirectory) |
/attorney |
app/pages/attorney.vue |
Priya Raman's profile: contact details, about, representative matters, publications, credentials and related insights |
/results |
app/pages/results.vue |
Case results with outcome chips, a practice select, an empty state and the results disclaimer (ResultsBoard) |
/insights |
app/pages/insights.vue |
Articles filtered by topic (InsightsList) and the monthly briefing sign-up (NewsletterForm) |
/article |
app/pages/article.vue |
A long-form article with a reading progress bar, a table of contents that follows your scroll, an author box and "Keep reading" |
/contact |
app/pages/contact.vue |
The consultation request form (ContactForm), the office map, office cards and direct contact details |
| 404 | app/error.vue |
Generated as 404.html, with links back into the site |
Project structure#
app/
app.vue page key, so a link to the page you are on starts it afresh
error.vue the 404 page
router.options.ts keeps query strings exactly as written
assets/css/main.css Tailwind entry: fonts, @theme tokens and component classes
assets/fonts/ Libre Caslon Display and IBM Plex Sans (woff2 + OFL texts)
layouts/default.vue skip link, header, page, footer and the back-to-top button
pages/ one file per page (see Pages)
components/
SiteHeader.vue top bar, sticky header, menus and the phone menu
SiteFooter.vue logo, practice and firm links, offices, legal notice, year
BrandLogo.vue the logo link (ivory or navy version)
AppIcon.vue, ArrowIcon.vue line icons (paths in data/icons.ts) and the button arrow
PracticeCard.vue, AttorneyCard.vue, ResultCard.vue, ArticleCard.vue the cards
PracticeGrid.vue, AttorneyDirectory.vue, ResultsBoard.vue, InsightsList.vue filterable lists
FilterChips.vue a row of aria-pressed filter chips
CountUp.vue a number that counts up when scrolled into view
PageHero.vue, PageBreadcrumb.vue, CtaBand.vue repeated page sections
ContactForm.vue, NewsletterForm.vue, FieldError.vue forms and their messages
OfficeMap.vue the contact page map
BackToTop.vue the back-to-top button
composables/
usePageSeo.ts title, description, canonical and Open Graph tags
useCurrentPage.ts aria-current for menu links
useFormValidation.ts inline form validation
useFilterReveal.ts shows the cards a filter keeps
useAsset.ts files in public/ with the base path
data/ the content (see Content)
plugins/
reveal.client.ts scroll reveals
same-page.client.ts links to the current page behave like a page load
stable-build.server.ts reproducible builds
utils/makeRef.ts the HS-7K2Q reference after a consultation request
public/img/ favicon, social image, portraits, article covers, facade
nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, TailwindCustomizing#
Brand and colors#
The design tokens are in the @theme block of app/assets/css/main.css. Tailwind turns each --color-* into
utilities (bg-ink, text-gold-ink, border-line...).
| Token | Use |
|---|---|
--color-ink / --color-ink-2 |
Text and the deep navy bands; raised panels on navy |
--color-paper / --color-paper-2 |
Page background (warm ivory); sunken panels |
--color-surface |
Cards |
--color-line / --color-line-dark |
Hairlines on ivory and on navy |
--color-muted / --color-mist |
Secondary text on ivory and on navy |
--color-gold |
Decorative brass: rules, icons, borders |
--color-gold-ink / --color-gold-soft |
Brass for text on light backgrounds and on navy (both pass WCAG AA) |
--color-danger |
Form errors |
To change the accent, change the three gold tokens together and check text contrast:
@theme {
--color-gold: #4f7a6a;
--color-gold-ink: #2f5a4a;
--color-gold-soft: #a9cbbd;
}The browser bar color is theme-color in nuxt.config.ts.
Typography#
Headings use Libre Caslon Display (--font-display, the display-xl, display-lg and font-display classes)
and everything else IBM Plex Sans (--font-sans). Both are self-hosted from app/assets/fonts/ and declared
with @font-face at the top of main.css. To swap a family, add its woff2 file there, update the @font-face
rule and the matching --font-* token.
Content#
Text that appears once lives in the page files. Everything that repeats is typed data in app/data/:
| File | Contents |
|---|---|
site.ts |
FIRM (name, phone, email), OFFICES (addresses, phones, hours, map pins), NAV and NAV_PARENT |
practices.ts |
PRACTICES: slug, name, audience, icon, summary and lead attorney |
attorneys.ts |
ATTORNEYS in directory order, FEATURED_ATTORNEYS for the home page |
results.ts |
RESULTS and the RESULT_TYPES order of the outcome chips |
articles.ts |
ARTICLES (newest first) and the TOPICS chips |
home.ts |
STATS, APPROACH and TESTIMONIALS |
practice-area.ts |
PRACTICE_PAGE: the practice page's glance box, services, process, results, FAQ and team |
profile.ts |
PROFILE: the profile page's about text, matters, languages and related insights |
article.ts |
ARTICLE_SECTIONS (the table of contents) and the articles under "Keep reading" |
icons.ts |
ICONS, the line icons |
For example, to add an attorney, add an entry to ATTORNEYS and a portrait at
public/img/attorney-<slug>.svg (or a photo, see below):
{
slug: "nora-quinlan",
name: "Nora Quinlan",
role: "Associate",
practices: ["litigation"],
office: "hartford",
email: "nquinlan@hartwellsloane.example",
bio: "Nora handles commercial disputes from the first demand letter through trial.",
bars: ["Connecticut"],
education: ["J.D., Yale Law School"],
},The directory, its count, the hero text on the attorneys page and the search pick it up. A new practice area
works the same way in PRACTICES (choose an icon from data/icons.ts); it then appears on the home page, the
practice areas page with its chip counts, the footer and every practice select. Results and articles appear in
the order you write them.
The detail pages (practice-area.vue, attorney.vue, article.vue) each show one example. To give every
practice, attorney or article its own page, turn them into dynamic routes (pages/attorneys/[slug].vue), read
the entry with useRoute().params.slug, list the routes in nuxt.config.ts, and point the card links at them.
Images and artwork#
All artwork is SVG in public/img/: the courthouse facade on the home page (640 × 560), attorney portraits
(attorney-<slug>.svg, 5:6, drawn at 400 × 480), article covers (insight-<n>.svg, 16:10, 640 × 400) and the
favicon. Photos work too: keep the same aspect ratios (the cards crop with object-cover) and update the file
names in the components. The office map is drawn in components/OfficeMap.vue; its pins come from the x and y
of each office in data/site.ts.
The social image is public/img/og-image.png (1200 × 630), made from og-image.svg. Replace both with your own.
Navigation, header and footer#
The header and phone menu are in components/SiteHeader.vue, the footer in components/SiteFooter.vue; both
read NAV from data/site.ts. To add a page, create app/pages/<name>.vue, add it to routes in
nuxt.config.ts and, if it belongs in the menu, to NAV. A detail page marks its section in the menus through
NAV_PARENT (the article marks Insights).
Features#
Filterable lists#
Four lists filter instantly, without reloading:
- Practice areas (
PracticeGrid.vue): chips for All, For businesses and For individuals, each with a count. - Attorneys (
AttorneyDirectory.vue): a search box that matches the name and role ("partner" finds every partner), a practice area select, and an empty state with "Clear filters", which also puts focus back in the search box. - Results (
ResultsBoard.vue): outcome chips (only outcomes that have results) and a practice select (only practices that have results), with an empty state. - Insights (
InsightsList.vue): topic chips with counts.
The chips are buttons with aria-pressed inside a labeled group (FilterChips.vue), and the count line is an
aria-live="polite" region, so screen readers hear "3 practice areas" after a change. Cards a filter keeps are
shown at once, even below the fold (useFilterReveal).
Count-up statistics#
The home page numbers (STATS in data/home.ts) count up from zero over 1.4 seconds the first time they scroll
into view (CountUp.vue). The generated HTML shows the final values, and with reduced motion they never animate.
Article reading progress and contents#
On the article page a thin brass bar along the top of the window shows how far you have read, and the table of
contents (sticky on wide screens) marks the section you are reading with aria-current="true". Both update once
per animation frame while you scroll (in pages/article.vue). Contents links are plain #hash links, so they
work without JavaScript; the headings have a scroll margin so they land below the sticky header. To change the
sections, edit the <h2 id> headings in the article and ARTICLE_SECTIONS together.
FAQ accordion#
The practice page FAQ uses native <details> and <summary> elements: keyboard accessible with no script. The
first question starts open (:open="i === 0").
Menu, header and motion#
- The header gains a soft shadow once the page scrolls. The current section is marked with
aria-current="page"in both menus (useCurrentPage). - Below 1024px the menu button opens the phone menu and moves focus to its first link; Escape closes it and returns focus to the button, and so do choosing a link and widening the window.
- Sections fade up as they scroll into view (
plugins/reveal.client.ts); when one appears, everything above it does too. With reduced motion, or without JavaScript, everything is simply shown. - A back-to-top button appears after about one screen of scrolling (
BackToTop.vue) and moves focus to the main content. Remove<BackToTop />fromlayouts/default.vueto drop it. - A link to the page you are already on (a footer practice link on the practice page, "Keep reading" on the
article) opens the page again at the top, like a full page load (
plugins/same-page.client.tsand the page key inapp.vue).
The year#
The footer's copyright year is written as 2026 in the generated HTML and set to the current year in the browser, so it never goes stale.
Forms#
Both forms are demos: they validate in the browser, then show a success message, but nothing is sent.
| Form | Component | Checks |
|---|---|---|
| Consultation request (contact page) | ContactForm.vue |
Full name, email (format), who the consultation is for (radio group), practice area, a message of at least 20 characters and the acknowledgment checkbox; phone, office and other parties are optional |
| Newsletter (insights page) | NewsletterForm.vue |
Email (format) |
Validation is in composables/useFormValidation.ts: a field is checked when you leave it with a value, and again
as you type once it shows an error. Submitting checks everything and focuses the first problem. Messages are
inserted after the field with aria-invalid and aria-describedby. After a consultation request the form is
replaced by a thank-you message with a reference such as HS-7K2Q (utils/makeRef.ts), which takes focus;
"Send another inquiry" brings back an empty form.
To receive submissions, send the form data from the callback passed to useFormValidation. With Formspree:
const v = useFormValidation(async () => {
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(formEl.value!),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show your own error message here
reference.value = makeRef("HS");
sent.value = true;
await nextTick();
success.value?.focus();
});Or, with pnpm build (a server build), add a server route such as server/api/consultation.post.ts and post to
/api/consultation. Keep the conflict-check notice: the form asks people not to send confidential details.
Saved data (localStorage)#
None. The template keeps nothing in the browser.
SEO and sharing#
Each page calls usePageSeo({ title, description }) with its own title and description. It sets the
description, Open Graph and Twitter card tags, a canonical link and an absolute og:image. Absolute URLs are
built from SITE_URL (runtimeConfig.public.siteUrl in nuxt.config.ts) and the base path, so set it when you
build:
SITE_URL=https://www.yourfirm.com pnpm generateThe 404 page is noindex. Consider adding a sitemap.xml and robots.txt to public/, and LegalService
structured data with your real address if you use the template for a firm.
Accessibility#
- A skip link, landmarks (header, labeled navigation, main, footer) and one
h1per page. - Visible focus rings in brass, filter chips with
aria-pressed, live counts, a labeled map image,aria-currentin the menus, breadcrumbs and the table of contents. - Forms with labels, inline messages tied to their fields and focus moved to the first problem or the success message.
- Reduced motion turns off reveals, count-ups and smooth scrolling.
- Text colors pass WCAG AA on their backgrounds (
gold-inkon ivory,gold-softandmiston navy).
When editing, keep one h1 per page, give new inputs a label, and check contrast if you change colors.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html
(attorneys.html, linked as /attorneys), plus 404.html from app/error.vue. Next to each page is a folder
with its _payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static
host (Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /attorneys to
attorneys.html on their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown
URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/counsel pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are
still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint
load; replace it with typescript@7 once they support it.
Troubleshooting#
pnpm lintcan't find.nuxt/eslint.config.mjs: runpnpm install(orpnpm typecheck) once;nuxt preparewrites it.- Links 404 after deploying: the host isn't mapping
/attorneystoattorneys.html. Turn on clean URLs or add a rewrite. - Pages load without styles or scripts from a sub-folder: build with
DEMO_BASE_PATHset to that folder. - A new page doesn't exist after
pnpm generate: add its route toroutesinnuxt.config.ts. og:imagepoints athartwellsloane.example: setSITE_URLwhen you build.- A new card stays invisible: elements with the
revealclass fade in from the reveal plugin; don't also give them a:classbinding, which would remove theis-inclass it adds. - The form says it was sent, but nothing arrived: the forms are demos until you connect them (see Forms).
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and
at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Libre Caslon
Display and IBM Plex Sans are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Hartwell &
Sloane, its attorneys, clients and results are fictional.