Freeframe

Documentation

Counsel

v1.0.1Updated Oct 9, 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 Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.1. It builds to static HTML files with no UI framework and one small script, and can be hosted anywhere. The pages, design and behaviour match the HTML and Next.js versions.

Live demo: freeframe.dev/templates/counsel

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev          # http://localhost:4321

With npm: npm install, then npm run dev.

Command What it does
pnpm dev Development server at http://localhost:4321 with hot reload
pnpm build Static site in dist/
pnpm preview Serve dist/ locally after a build
pnpm check astro check: type-checks .astro files and TypeScript
pnpm typecheck Generate Astro's types, then type-check with tsc (TypeScript 7)
pnpm lint ESLint (TypeScript and Astro rules)
pnpm build:demo Build for /demos/astro/counsel into demo/ (only used for the demo on freeframe.dev)

Pages#

Every file in src/pages/ becomes one page.

URL File What's on it
/ src/pages/index.astro Hero with courthouse illustration, count-up statistics, practice areas, how we work, attorneys, client quotes, insights, consultation call to action
/practice-areas src/pages/practice-areas.astro Eight practice areas filtered by audience (businesses or individuals)
/practice-area src/pages/practice-area.astro One practice in depth (Employment Law): services, process, results, FAQ, team
/attorneys src/pages/attorneys.astro Directory with name search, a practice-area filter, a live count and an empty state
/attorney src/pages/attorney.astro Priya Raman: bio, contact, matters, publications, bar admissions, education
/results src/pages/results.astro Case results filtered by outcome and practice area, with disclaimers
/insights src/pages/insights.astro Articles filtered by topic, newsletter sign-up
/article src/pages/article.astro Long-form article with a reading progress bar and a table of contents that follows your scroll
/contact src/pages/contact.astro Consultation request form with a conflict-check notice, office details and an illustrated map
404 src/pages/404.astro Page-not-found page (most hosts show 404.html automatically)

The cards for practice areas, attorneys and articles all link to one example detail page each (/practice-area, /attorney, /article). Duplicate a detail page for every item you publish (see Content).

Project structure#

counsel/
├─ src/
│  ├─ pages/                 one .astro file per page (see above)
│  ├─ layouts/
│  │  └─ Layout.astro        <head> (title, meta, Open Graph, fonts), header, <main>, footer, scripts
│  ├─ components/
│  │  ├─ SiteHeader.astro    skip link, contact bar, logo, desktop and mobile navigation
│  │  └─ SiteFooter.astro    consultation banner, offices, links, legal notice
│  ├─ lib/
│  │  └─ site.ts             site name, theme colour and the url() helper for links
│  ├─ scripts/
│  │  ├─ main.ts             menu, reveals, count-up, filters, article progress, forms
│  │  └─ back-to-top.ts      floating "Back to top" button
│  └─ styles/
│     └─ global.css          Tailwind theme (colours, fonts) and shared component classes
├─ public/img/               SVG portraits, facade, article covers, favicon, og-image.svg and og-image.png
├─ astro.config.mjs          site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.json

Pages are rendered to HTML at build time. The two files in src/scripts/ are the only JavaScript sent to the browser, plus one inline line in the layout that adds a js class to <html> (so reveal animations only hide content when scripts run). Layout.astro imports both scripts, and Astro bundles them into one small module.

Customising#

Brand and colours#

Design tokens are in the @theme block at the top of src/styles/global.css:

Token Used for
--color-ink Text and deep navy sections
--color-ink-2 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 Secondary text on ivory
--color-mist Secondary text on navy
--color-gold Decorative brass: rules, icons, borders
--color-gold-ink Brass for text on light backgrounds (meets AA contrast)
--color-gold-soft Brass for text on navy (meets AA contrast)
--color-danger Error messages

For example, to swap the brass accent for a deep green:

css
@theme {
  --color-gold: #4f7a62;
  --color-gold-ink: #2f5a43;
  --color-gold-soft: #9cc7ae;
}

Tailwind rebuilds automatically in pnpm dev and pnpm build. Keep the -ink and -soft variants at WCAG AA contrast against ivory and navy respectively. The browser theme colour is site.themeColor in src/lib/site.ts.

Typography#

Fonts load from Google Fonts by a <link> in src/layouts/Layout.astro:

html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&amp;family=Libre+Caslon+Display&amp;display=swap" />

--font-display (Libre Caslon Display, used for h1, h2 and .font-display) and --font-sans (IBM Plex Sans, body) are set in @theme. To change a font, edit that link and the family name in the token. To self-host, put the files in public/fonts/, add @font-face rules to global.css and remove the link and the two preconnect tags.

Content#

Page text lives in each src/pages/*.astro file. The page title, description and current menu item are props on <Layout> at the top:

html
<Layout title="Attorneys — Hartwell & Sloane" description="Meet the attorneys of Hartwell & Sloane..." current="attorneys">

The interactive lists use data attributes, so keep them when you add or edit cards:

Page Card attribute Values
practice-areas.astro data-audience business or individual
attorneys.astro data-practices Space-separated practice keys, e.g. "ip corporate"
attorneys.astro data-search Lower-case words the search matches, e.g. "priya raman partner"
results.astro data-type, data-practices Outcome (verdict, settlement, transaction, appeal, dismissal, resolution) and practice key
insights.astro data-topic business, employment, intellectual-property, estate-planning, real-estate

Practice keys used by the filters are corporate, litigation, employment, ip, real-estate, estate-planning, family and injury (the <option> values of the practice select on attorneys.astro and results.astro).

To add an attorney:

  1. Copy an <article data-item …> card in src/pages/attorneys.astro, update the portrait, name, role and practices, and set data-practices and data-search (lower case).
  2. Copy src/pages/attorney.astro to a new file (for example attorney-jane-doe.astro), replace the profile content and point the card's links at it with href={url("/attorney-jane-doe")}.
  3. Add the portrait as public/img/attorney-jane-doe.svg (or .jpg) at 400 × 480 and reference it with src={url("/img/attorney-jane-doe.svg")}.

Practice areas and articles work the same way: copy a card, duplicate the detail page, update the links. The office details appear in contact.astro and in SiteFooter.astro; the firm's phone and email appear in SiteHeader.astro and SiteFooter.astro.

The home page statistics count up from zero. Each one has its final value and an optional suffix:

html
<span data-count data-to="1200" data-suffix="+">1,200+</span>

Text containing literal curly braces must be escaped in .astro files ({"{"}), because braces start an expression.

Images and artwork#

All artwork is original SVG in public/img/:

Files Size Where
attorney-<name>.svg 400 × 480 (5:6) Attorney cards and profile
insight-0.svg … insight-5.svg 640 × 400 (16:10) Article covers
facade.svg 640 × 560 Home hero (courthouse)
favicon.svg 64 × 64 Browser tab icon
og-image.svg / og-image.png 1200 × 630 Social sharing image (the SVG is the editable source)

The office map on /contact is an inline SVG with a <title> describing it. Replace images with photos of the same proportions (.jpg or .webp), keeping descriptive alt text, and always link them through url() so they work under a base path. For automatic resizing, move images to src/ and use Astro's <Image /> from astro:assets.

The header and mobile menu are in src/components/SiteHeader.astro, the footer in src/components/SiteFooter.astro; every page gets them from Layout.astro. A menu link gets aria-current="page" when the page's current prop matches it:

html
<a href={url("/attorneys")} class="link-u py-1" aria-current={current === "attorneys" ? "page" : undefined}>Attorneys</a>

Detail pages highlight their section (attorney.astro passes current="attorneys"). To add a page, create src/pages/careers.astro:

html
---
import Layout from "../layouts/Layout.astro";
---

<Layout title="Careers — Hartwell & Sloane" description="Join the firm." current="careers">
  <section class="container-x py-16">
    <h1 class="display-xl mt-4">Careers</h1>
  </section>
</Layout>

Then add a link to the desktop and mobile menus in SiteHeader.astro (with the aria-current expression above) and to the footer. Always write internal links as href={url("/careers")} so they keep working under a base path.

SiteFooter.astro carries an attorney-advertising and not-legal-advice notice on every page. results.astro, article.astro and contact.astro add their own disclaimers. Review all of them against your bar's advertising rules before publishing: requirements differ between jurisdictions.

Features#

All interactive code is in src/scripts/main.ts, one commented block per feature. Each block looks for its elements by data-* attribute and does nothing if they're missing.

Filterable lists#

/practice-areas, /attorneys, /results and /insights share one generic filter. A container marked data-filter-list holds the cards (data-item) and any of these controls:

Control Markup Filters on
Chips <button data-chip data-value="…" aria-pressed> The attribute named by the list's data-key (e.g. data-key="audience")
Selects <select data-filter-select data-key="practices"> The attribute named by the select's own data-key
Search box <input type="search" data-filter-search> Each card's data-search text
Count [data-filter-count] Shows the number of visible cards (inside a live region)
Empty state [data-filter-empty] Shown when nothing matches
Reset [data-filter-reset] Clears every control and refocuses the search

Attribute values can hold several space-separated tokens (data-practices="ip corporate"), and a card matches if any token matches. The value all matches everything. To filter a new list, wrap it in a data-filter-list container with the same markup; no script changes are needed.

Count-up statistics#

Elements with data-count animate from 0 to data-to (with data-suffix) over 1.4 seconds when they scroll into view. With reduced motion they show the final value at once. The markup already contains the final value, so the number is correct without JavaScript.

Reading progress and table of contents#

On /article, the bar marked data-reading-progress fills as you read the data-article-body (via the --progress CSS variable), and the table-of-contents links (data-toc-link, pointing at section ids) get aria-current="true" for the section you're reading. To add a section, give its heading an id and add a matching data-toc-link.

Header, mobile menu and scroll reveals#

  • The header (data-header) gets an is-scrolled class after scrolling, which adds its shadow.
  • The menu button (data-menu-button) toggles #mobile-menu, updates aria-expanded and its label, moves focus to the first link; Esc closes it and returns focus, and it closes when the window widens past lg.
  • Elements with the reveal class fade in as they scroll into view; revealing one also reveals everything before it, so jumping to an anchor never leaves hidden content. With reduced motion (or without JavaScript) everything shows at once. Optional style="--d:70ms" staggers neighbouring cards.

Back to top#

src/scripts/back-to-top.ts adds a "Back to top" button after the visitor scrolls about one screen. It scrolls smoothly (instantly with reduced motion) and moves keyboard focus to the skip-link target. It steps aside while an in-page "Back to top" link is on screen, lifts above toasts and elements marked data-back-to-top-avoid, and hides while it would cover a sticky element marked data-back-to-top-hide. Restyle it with the .back-to-top rules in global.css; to remove it, delete the file, its import in Layout.astro and those rules.

Forms#

Form Page Validates
Consultation request /contact Name, email, who it's for (a required radio group), practice area, a message of at least 20 characters and the acknowledgement checkbox; phone, office and other parties are optional
Newsletter /insights Email

Every form[data-validate] is handled in src/scripts/main.ts:

  • required fields, emails, minimum lengths and checkboxes are checked on blur and on submit, with plain-language messages created under each field (aria-invalid and aria-describedby are set for you);
  • a group of radios or checkboxes wrapped in an element with data-required-group="Your message" must have one option chosen, and shows that message otherwise;
  • focus moves to the first problem;
  • on success the form is hidden and the element marked data-success next to it is shown and focused. A data-reset-form button inside the wrapper (data-form-wrap) brings the form back.

Nothing is sent until you connect a backend. To use a form service such as Formspree:

  1. Add the endpoint to the form in src/pages/contact.astro: <form action="https://formspree.io/f/your-id" method="POST" … data-validate>.
  2. In src/scripts/main.ts, replace the demo block at the end of the submit handler:
ts
// Demo only: nothing is sent. See the comment above to connect a form service.
fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } })
  .then((res) => {
    if (!res.ok) throw new Error("Request failed");
    const success = $("[data-success]", form.parentNode as ParentNode);
    form.hidden = true;
    if (success) {
      success.hidden = false;
      success.focus();
    }
  })
  .catch(() => alert("Sorry, your request could not be sent. Please call us instead."));
  1. Update the success messages, which currently say the form is a demo.

To run your own server code instead, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel) and an API endpoint such as src/pages/api/consultation.ts with export const prerender = false, then post to it.

Keep the conflict-check notice on the consultation form, and don't ask for confidential details before you've confirmed there's no conflict of interest.

SEO and sharing#

  • Each page passes title and description to <Layout>, which writes <title>, the meta description, og:title, og:description, a summary_large_image Twitter card and a theme-color.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://counsel.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://www.your-firm.com pnpm build

    Canonical URLs have no .html (https://www.your-firm.com/attorneys).

  • The 404 page passes noindex, which adds <meta name="robots" content="noindex"> and leaves out the canonical URL.

  • For a sitemap, add @astrojs/sitemap (pnpm astro add sitemap) and a public/robots.txt that points to it. Consider LegalService / Attorney structured data (JSON-LD) with your offices, phone numbers and opening hours.

Accessibility#

Built in:

  • a "Skip to content" link and landmarks on every page, with one h1 per page;
  • visible focus rings in brass (:focus-visible, with a lighter variant on navy);
  • filter chips with aria-pressed, counts in polite live regions, table-of-contents links with aria-current, and aria-current="page" on the current menu item;
  • form errors linked to their fields with aria-describedby, and focus moved to the first error;
  • an accessible mobile menu (aria-expanded, Esc to close, focus management);
  • the office map with a descriptive <title>;
  • reduced motion respected by reveals, count-ups, smooth scrolling and the back-to-top button;
  • colour pairs that meet WCAG AA, including dedicated brass shades for text on ivory and on navy.

When editing, keep one h1 per page, label new inputs, use --color-gold-ink/--color-gold-soft (not --color-gold) for brass text, and keep alt text meaningful.

Building and deploying#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ is plain HTML, CSS and JavaScript. Pages are written as attorneys.html rather than attorneys/index.html (build.format: "file" in astro.config.mjs), and links point to /attorneys without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /attorneys from attorneys.html automatically.

  • Netlify / Cloudflare Pages: build command pnpm build, publish directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /attorneys serves attorneys.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /attorneys to attorneys.html, change build.format to "directory" in astro.config.mjs.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/firm pnpm build. It becomes Astro's base, and every link written with url() includes it.

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#

  • A new card never appears when filtering. Check its data attributes: values are matched as exact lower-case tokens (data-practices="real-estate", not "Real estate"), and the card must be inside the data-filter-list container with data-item.
  • Search doesn't find an attorney. The search reads data-search, not the visible name. Add the words (lower case) to data-search.
  • The table of contents doesn't highlight a new section. The data-toc-link href must match the section heading's id.
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, and write internal links and image paths with url() from src/lib/site.ts rather than plain "/..." strings.
  • /attorneys shows a 404 on my host. The host doesn't map extensionless URLs to .html. Set build.format: "directory" in astro.config.mjs and rebuild.
  • Two words run together after editing. Astro compresses whitespace in markup; when text continues on the next line before an inline element such as <em>, keep them on one line or add {" "}.
  • The form says it was sent but nothing arrived. The template is a demo until you connect it (see Forms).
  • Social previews show the wrong domain or no image. Set SITE_URL to your domain at build time.

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. Hartwell & Sloane and everyone in this template are fictional. IBM Plex Sans and Libre Caslon Display are licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation