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).
pnpm install
pnpm dev # http://localhost:4321With 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.jsonPages 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:
@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:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Libre+Caslon+Display&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:
<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:
- Copy an
<article data-item …>card insrc/pages/attorneys.astro, update the portrait, name, role and practices, and setdata-practicesanddata-search(lower case). - Copy
src/pages/attorney.astroto a new file (for exampleattorney-jane-doe.astro), replace the profile content and point the card's links at it withhref={url("/attorney-jane-doe")}. - Add the portrait as
public/img/attorney-jane-doe.svg(or.jpg) at 400 × 480 and reference it withsrc={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:
<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.
Navigation, header and footer#
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:
<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:
---
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.
Legal notices#
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 anis-scrolledclass after scrolling, which adds its shadow. - The menu button (
data-menu-button) toggles#mobile-menu, updatesaria-expandedand its label, moves focus to the first link; Esc closes it and returns focus, and it closes when the window widens pastlg. - Elements with the
revealclass 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. Optionalstyle="--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 |
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-invalidandaria-describedbyare 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-successnext to it is shown and focused. Adata-reset-formbutton 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:
- Add the endpoint to the form in
src/pages/contact.astro:<form action="https://formspree.io/f/your-id" method="POST" … data-validate>. - In
src/scripts/main.ts, replace the demo block at the end of the submit handler:
// 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."));- 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
titleanddescriptionto<Layout>, which writes<title>, the meta description,og:title,og:description, asummary_large_imageTwitter card and atheme-color. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://counsel.example.com). Set it to your domain when you build:shSITE_URL=https://www.your-firm.com pnpm buildCanonical 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 apublic/robots.txtthat points to it. ConsiderLegalService/Attorneystructured 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
h1per 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 witharia-current, andaria-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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ 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 directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/attorneysservesattorneys.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/attorneystoattorneys.html, changebuild.formatto"directory"inastro.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 thedata-filter-listcontainer withdata-item. - Search doesn't find an attorney. The search reads
data-search, not the visible name. Add the words (lower case) todata-search. - The table of contents doesn't highlight a new section. The
data-toc-linkhrefmust match the section heading'sid. - Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and image paths withurl()fromsrc/lib/site.tsrather than plain"/..."strings. /attorneysshows a 404 on my host. The host doesn't map extensionless URLs to.html. Setbuild.format: "directory"inastro.config.mjsand 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_URLto 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.