Freeframe

Documentation

Counsel

v1.0.4Updated Oct 17, 2026A refined, trustworthy website for a law firm or professional services practice.

Overview#

Counsel is a complete website for a law firm or professional services practice, shown as Hartwell & Sloane LLP, a fictional partner-led firm with offices in Boston, Hartford and Providence. It has practice areas with an audience filter, an attorney directory with search, case results, insights articles with a reading progress bar, and a consultation request form with a conflict-check notice. This is the Next.js version (1.0.1): Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site.

Live demo: freeframe.dev/templates/counsel

Quick start#

Requirements: Node.js 24 and pnpm (corepack enable pnpm) or npm.

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm build Static site in out/
pnpm start Serve out/ locally (npx serve out)
pnpm lint ESLint (Next.js core-web-vitals and TypeScript rules)
pnpm typecheck Generate route types (next typegen), then type-check with tsc (TypeScript 7)
pnpm build:demo Build for /demos/nextjs/counsel into demo/ (the freeframe.dev demo; you can delete it)

Pages#

Route File Notable sections and features
/ src/app/page.tsx Hero with courthouse illustration, count-up statistics, practice areas, how we work, attorneys, client quotes, insights, call to action
/practice-areas src/app/practice-areas/page.tsx Practice areas filtered by audience (PracticeGrid)
/practice-area src/app/practice-area/page.tsx One practice in depth (Employment Law): services, process, results, FAQ, team
/attorneys src/app/attorneys/page.tsx Directory with name search and practice filter (AttorneyDirectory)
/attorney src/app/attorney/page.tsx Profile of Priya Raman: bio, contact, matters, publications, admissions, education
/results src/app/results/page.tsx Results filtered by outcome and practice (ResultsBoard), with disclaimers
/insights src/app/insights/page.tsx Articles filtered by topic (InsightsList), newsletter sign-up
/article src/app/article/page.tsx Long-form article with reading progress and a table of contents
/contact src/app/contact/page.tsx Consultation request form, office details, illustrated map
404 src/app/not-found.tsx Page not found

The detail pages (/practice-area, /attorney, /article) are single examples that every card links to. See Content for turning them into one page per item.

Project structure#

src/
├── app/
│   ├── layout.tsx          fonts, metadata, header, footer, reveal controller, back-to-top
│   ├── page.tsx            home
│   ├── <route>/page.tsx    one folder per page
│   ├── not-found.tsx       404 page
│   ├── globals.css         Tailwind import, design tokens (@theme) and component classes
│   └── icon.svg            favicon
├── components/
│   ├── site-header.tsx     header, navigation (NAV) and mobile menu
│   ├── site-footer.tsx     footer with the attorney-advertising notice
│   ├── cards.tsx           PracticeCard, AttorneyCard, ArticleCard, ResultCard, Chips
│   ├── practice-grid.tsx, attorney-directory.tsx, results-board.tsx, insights-list.tsx   filterable lists
│   ├── article.tsx         ReadingProgress and ArticleToc
│   ├── count-up.tsx        animated statistics
│   ├── forms.tsx           ConsultationForm and NewsletterForm with validation
│   ├── icons.tsx           Icon (by name) and Arrow
│   ├── reveal-controller.tsx, back-to-top.tsx
├── data/site.ts            firm details, offices, practices, attorneys, results, topics, articles
├── fonts/                  self-hosted Libre Caslon Display and IBM Plex Sans (with OFL licences)
└── lib/
    ├── asset.ts            asset(): prefixes public files with the base path
    └── metadata.ts         pageMetadata(): title, description and Open Graph per page
public/img/                 SVG portraits, facade, article covers, og-image.svg and og-image.png
next.config.ts              static export and optional base path

Customising#

Brand and colours#

Design tokens are in the @theme block of src/app/globals.css:

Token Used for
--color-ink, --color-ink-2 Text and deep navy sections; raised panels on navy
--color-paper, --color-paper-2 Page background (warm ivory) and 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 ivory and on navy (both meet AA contrast)
--color-danger Error messages
css
@theme {
  --color-gold: #4f7a62;
  --color-gold-ink: #2f5a43;
  --color-gold-soft: #9cc7ae;
}

Tailwind compiles during pnpm dev/pnpm build; there's no separate CSS step. The browser theme colour is viewport.themeColor in src/app/layout.tsx.

Typography#

The fonts are self-hosted with next/font/local in src/app/layout.tsx, using the same Latin .woff2 files Google Fonts serves (their SIL Open Font License files are in src/fonts/):

tsx
const caslon = localFont({ src: "../fonts/LibreCaslonDisplay-latin.woff2", variable: "--font-caslon", weight: "400", display: "swap" });
const plex = localFont({ src: "../fonts/IBMPlexSans-latin.woff2", variable: "--font-plex", weight: "400 700", display: "swap" });

--font-display and --font-sans in globals.css use these variables. To change a font, add its .woff2 to src/fonts/ and point localFont at it, or switch to next/font/google with the same variable name.

Content#

Lists that repeat or are filtered come from src/data/site.ts:

Export What it holds
FIRM Name, legal name, year founded, phone (phone and phone_href), email
OFFICES Offices: slug, city, label, address lines, phone, hours, and map pin coordinates x/y
PRACTICES Practice: slug, name, audience ("business" | "individual"), icon (an IconName), summary, lead (an attorney slug)
ATTORNEYS Attorney: slug, name, role, practices (practice slugs), office (office slug), email, bio, bars, education, optional publications
ROLE_ORDER Sort order of roles in the directory (Founding Partner first)
RESULTS, RESULT_TYPES Result: practice, type (one of RESULT_TYPES), figure, year, title
TOPICS, ARTICLES Insight topics and Article: slug, title, topic, date, date_label, author (attorney slug), minutes, excerpt, art (cover number)

Helpers attorneyBySlug(), practiceBySlug(), officeBySlug() and topicKey() look items up for the cards.

To add an attorney:

ts
{
  slug: "jane-doe",
  name: "Jane Doe",
  role: "Associate",
  practices: ["employment"],
  office: "hartford",
  email: "jdoe@hartwellsloane.example",
  bio: "…",
  bars: ["Connecticut"],
  education: ["J.D., …"],
},

and add public/img/attorney-jane-doe.svg (or .jpg, 400 × 480). The directory, filters and sort pick it up. Icons for practices are the keys of PATHS in icons.tsx (building, scales, people, spark, key, document, home, shield, …).

Written directly in page files (edit them there): the home page sections and statistics, the three detail pages, the office details on /contact and the footer, and the legal notices.

One page per item. Every card links to the single example page (/attorney, /practice-area, /article). To publish a page for each attorney, move src/app/attorney/page.tsx to src/app/attorneys/[slug]/page.tsx, read the attorney with attorneyBySlug((await params).slug), export generateStaticParams() returning every slug (required for static export), and change the card link in AttorneyCard to `/attorneys/${attorney.slug}`. Practices and articles work the same way (give each article a unique slug; they're all "article" in the sample data).

Images and artwork#

Files in public/img/ Size Where
attorney-<slug>.svg 400 × 480 (5:6) Attorney cards and profile
insight-0.svg … insight-5.svg 640 × 400 (16:10) Article covers (art number)
facade.svg 640 × 560 Home hero
og-image.png (source og-image.svg) 1200 × 630 Social image used by pageMetadata() and the layout

src/app/icon.svg is the favicon. Images use next/image with images.unoptimized (required for static export), and paths go through asset() so they work under a base path:

tsx
<Image src={asset(`/img/attorney-${attorney.slug}.svg`)} alt={`Portrait of ${attorney.name}`} width={400} height={480} />

Replace SVGs with .jpg/.webp photos of the same proportions and update the extensions.

site-header.tsx holds the NAV array and a SECTION map that highlights the parent section on detail pages (for example /attorney highlights "Attorneys"); the current link gets aria-current="page". To add a page, create src/app/<route>/page.tsx, export metadata = pageMetadata(title, description) and add it to NAV. The footer is in site-footer.tsx, including the attorney-advertising and not-legal-advice notice; review it and the page-level disclaimers on /results, /article and /contact against your bar's advertising rules.

Features#

Filterable lists#

Each list is a client component that filters data from site.ts in React state:

Component Page Controls
PracticeGrid /practice-areas Audience chips (all, businesses, individuals)
AttorneyDirectory /attorneys Name search, practice select; sorted by ROLE_ORDER; empty state and reset
ResultsBoard /results Outcome chips (RESULT_TYPES), practice select; empty state and reset
InsightsList /insights Topic chips (TOPICS)

Chips are toggle buttons (aria-pressed, the shared Chips component in cards.tsx) and each list announces its count in a polite live region. Add items to the data and they appear in the right filters automatically.

Count-up statistics#

<CountUp to={1200} suffix="+" /> animates from 0 when it scrolls into view and shows the final value at once with reduced motion.

Reading progress and table of contents#

article.tsx exports ReadingProgress (a bar that fills as you read the article body) and ArticleToc (links that get aria-current for the section in view). Give each section heading an id and list it in the table of contents.

Header, mobile menu and scroll reveals#

The header shows a shadow after scrolling. The menu button toggles the mobile menu (aria-expanded, Esc closes it and returns focus, and it closes on navigation). RevealController fades in .reveal elements as they scroll into view and reveals everything above them too; with reduced motion, or before JavaScript runs (the js class is added by a small script in the layout), everything is visible.

Back to top#

back-to-top.tsx, mounted once in the layout, shows a button after about one screen of scrolling (useSyncExternalStore on scroll and resize). It scrolls smoothly (instantly with reduced motion) and moves focus to <main> when the scroll ends. Restyle it with the .back-to-top rules in globals.css, or remove <BackToTop /> from the layout to drop it. If you add fixed UI to the bottom-right corner (a cookie bar or chat launcher), give it room or move the button with CSS, since this version doesn't reposition itself around other elements.

Forms#

Form Component Validates
Consultation request ConsultationForm in forms.tsx (on /contact) Name, email, who it's for (required radio group), practice, a message of at least 20 characters, the acknowledgement checkbox
Newsletter NewsletterForm in forms.tsx (on /insights) Email

Both use the useValidation() hook in forms.tsx: native constraints are checked on blur and submit, errors render under each field (err-<name>, linked with aria-describedby), required radio groups are passed as useValidation({ for: "Please choose who the consultation is for." }), and focus moves to the first problem. On success the form hides and a focused success panel appears, with a button to send another.

Nothing is sent. To receive submissions with a form service such as Formspree, make the submit handler async and post the form where the demo comment is:

tsx
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  const form = e.currentTarget;
  if (!v.validateAll(form)) return;
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return; // show an error message here
  setSent(true);
  requestAnimationFrame(() => successRef.current?.focus());
};

Do the same in NewsletterForm, and update the success text, which says the form is a demo. If you remove the static export, you can post to a Route Handler (e.g. src/app/api/consultation/route.ts) or use a Server Action instead. Keep the conflict-check notice, and don't ask for confidential details before a conflict check.

SEO and sharing#

  • Defaults are in metadata in src/app/layout.tsx: a title template (%s — Hartwell & Sloane), description and Open Graph image.
  • Each page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts.
  • metadataBase is process.env.NEXT_PUBLIC_SITE_URL (falling back to https://hartwellsloane.example). Set it when you build so the social image URL is absolute:
sh
NEXT_PUBLIC_SITE_URL=https://www.your-firm.com pnpm build

Consider adding src/app/sitemap.ts and src/app/robots.ts (both work with static export) and LegalService structured data (JSON-LD) on the home page.

Accessibility#

Built in: a skip link and landmarks, one h1 per page, visible brass focus rings, toggle chips with aria-pressed, counts in live regions, aria-current for navigation and the table of contents, form errors linked with aria-describedby and focus moved to the first error, an accessible mobile menu, a titled office map, reduced motion respected everywhere, and colours that meet WCAG AA (with dedicated brass shades for text on ivory and navy).

When editing, keep one h1 per page, label every input, use gold-ink/gold-soft rather than gold for brass text, and keep alt text meaningful.

Building and deploying#

pnpm build writes a static site to out/ (output: "export" in next.config.ts):

  • Vercel: import the repository; no settings needed.
  • Netlify / Cloudflare Pages: build command pnpm build, publish directory out.
  • Any static host: upload the contents of out/.

To serve from a sub-folder, build with DEMO_BASE_PATH=/firm pnpm build; next.config.ts passes it to basePath and NEXT_PUBLIC_BASE_PATH, which asset() uses. pnpm build:demo does this for the freeframe.dev demo (demo/, which you can delete).

To use server features (Route Handlers, Server Actions), remove output: "export" and images.unoptimized from next.config.ts and deploy to a Node-capable host such as Vercel.

TypeScript setup#

package.json installs @typescript/native (TypeScript 7, which provides tsc) and typescript as an alias of @typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain still need the TypeScript 6 API. When typescript-eslint supports TypeScript 7, replace both with typescript@7. eslint.config.mjs pins settings.react.version, because eslint-plugin-react can't detect it under ESLint 10.

Troubleshooting#

  • generateStaticParams errors after adding [slug] routes. Static export needs every slug listed; return ATTORNEYS.map((a) => ({ slug: a.slug })) (and likewise for practices and articles).
  • A new practice icon fails to type-check. icon must be a key of PATHS in icons.tsx; add a path there first.
  • An attorney appears in the wrong filter. practices holds practice slugs ("real-estate"), not names.
  • Images break under a sub-folder. Build with DEMO_BASE_PATH, and always wrap public paths in asset().
  • Social previews use hartwellsloane.example. Set NEXT_PUBLIC_SITE_URL when building.
  • pnpm lint crashes with contextOrFilename.getFilename is not a function. Keep settings.react.version in eslint.config.mjs.
  • The form says it was sent but nothing arrived. It's a demo until connected (see Forms).

License#

MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this template, including the SVG artwork. The fonts are licensed under the SIL Open Font License (see src/fonts/). Hartwell & Sloane and everyone in this template are fictional.

Search documentation

Search the guides and every template's documentation