Freeframe

Documentation

Counsel

v1.0.1Updated Nov 11, 2026A refined, trustworthy website for a law firm or professional services practice.

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

sh
pnpm install      # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev          # http://localhost:3000

Other 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, Tailwind

Customizing#

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:

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

ts
{
  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.

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").

  • 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 /> from layouts/default.vue to 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.ts and the page key in app.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:

ts
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:

sh
SITE_URL=https://www.yourfirm.com pnpm generate

The 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 h1 per page.
  • Visible focus rings in brass, filter chips with aria-pressed, live counts, a labeled map image, aria-current in 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-ink on ivory, gold-soft and mist on navy).

When editing, keep one h1 per page, give new inputs a label, and check contrast if you change colors.

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt 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:

sh
DEMO_BASE_PATH=/counsel pnpm generate

pnpm 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 lint can't find .nuxt/eslint.config.mjs: run pnpm install (or pnpm typecheck) once; nuxt prepare writes it.
  • Links 404 after deploying: the host isn't mapping /attorneys to attorneys.html. Turn on clean URLs or add a rewrite.
  • Pages load without styles or scripts from a sub-folder: build with DEMO_BASE_PATH set to that folder.
  • A new page doesn't exist after pnpm generate: add its route to routes in nuxt.config.ts.
  • og:image points at hartwellsloane.example: set SITE_URL when you build.
  • A new card stays invisible: elements with the reveal class fade in from the reveal plugin; don't also give them a :class binding, which would remove the is-in class 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.

Search documentation

Search the guides and every template's documentation