Overview#
Clinic is a calm, accessible website for a dental or medical practice, with treatments and typical costs, the care team, patient information and a five-step online booking wizard. The demo practice is Brightside Dental, a fictional family dental clinic. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.3. It builds to static HTML files with no UI framework and a small amount of JavaScript, and can be hosted anywhere. See it live at freeframe.dev/templates/clinic.
All treatment descriptions, prices and aftercare notes are placeholder copy, not medical advice. Replace them with your practice's own information.
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/clinic 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 "next free slot" card, services, why us, team preview, reviews with rating breakdown, insurers, hours with open-now badge, map |
/services |
src/pages/services.astro |
Four groups of expandable treatments with typical costs, payment plans prompt |
/service |
src/pages/service.astro |
One treatment in depth (dental implants): four-step timeline, aftercare, FAQs, sticky cost breakdown, related treatments |
/team |
src/pages/team.astro |
Eight people with credentials and languages, specialty filter |
/appointments |
src/pages/appointments.astro |
Five-step booking wizard with a live summary |
/patients |
src/pages/patients.astro |
First visit, insurance search, downloadable forms, payment plans, FAQ, privacy |
/contact |
src/pages/contact.astro |
Contact form, emergency notice, hours, contact cards, map, directions, accessibility |
| 404 | src/pages/404.astro |
Not-found page with links back (most hosts show 404.html automatically) |
Every page has an emergency bar above the header and a newsletter form in the footer.
Project structure#
clinic/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), skip link, header, footer, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro emergency bar, logo, navigation, "Book online" button, mobile menu
│ │ └─ SiteFooter.astro newsletter form, address, links, opening hours, toast region
│ ├─ data/
│ │ └─ practice.ts opening hours, time zone, booking rules, clinicians and treatments
│ ├─ lib/
│ │ └─ site.ts site name, theme colour and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts open-now badge, menu, reveals, forms, team filter, insurance search, booking wizard
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (colours, fonts) and shared component classes
├─ public/img/ team portraits (dr-*.svg and others), hero.svg, map.svg, favicon, social image
├─ 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's <head> that swaps the no-js class on <html>
for js (so reveal animations only hide content when scripts run). Layout.astro imports both scripts
and Astro bundles them into one small module. src/scripts/main.ts is organised in numbered sections
listed in its header comment.
Customising#
Brand and colours#
The palette and fonts are tokens in the @theme block at the top of src/styles/global.css:
| Token | Used for |
|---|---|
--color-brand-50 … --color-brand-900 |
Teal brand scale: buttons, links, icons, focus rings, dark sections |
--color-sun, --color-coral |
Warm accents in illustrations and highlights |
--color-sky-soft, --color-cream |
Soft section backgrounds |
--color-ink, --color-body, --color-muted |
Headings, body text and secondary text |
--color-line |
Borders |
--radius-blob |
Rounded "blob" corners |
To switch to a blue brand, replace the brand scale (keep the lighter-to-darker order):
@theme {
--color-brand-500: #2f6fd1;
--color-brand-600: #2459ad;
--color-brand-700: #1d4789;
}Keep --color-brand-600 and darker shades dark enough for white text (WCAG AA). Also update
themeColor in src/lib/site.ts, which sets <meta name="theme-color"> on every page.
Typography#
Text uses Figtree (--font-sans) and headings use Fraunces (--font-display), loaded from
Google Fonts by the <link rel="stylesheet"> in src/layouts/Layout.astro. The base text size is
slightly larger than usual for readability; keep it that way. To change fonts, replace that link and the
two tokens.
Content#
Text, treatments, costs and FAQs are plain markup in src/pages/. The practice name, phone number and
address also appear in SiteHeader.astro and SiteFooter.astro; search and replace to rebrand, and
change name in src/lib/site.ts (used for og:site_name).
Booking settings are in src/data/practice.ts:
export const HOURS: Record<number, DayHours | null> = {
0: null,
1: [8, 19],
// ...
5: [8, 16],
6: [9, 14],
};
export const LUNCH: DayHours = [12.5, 13.5]; // no appointments during lunch (weekdays)
export const SLOT_STEP = 30; // minutes between appointment start times
export const MIN_NOTICE_MINUTES = 90; // earliest bookable time from now
export const WEEKS_AHEAD = 4; // how far ahead people can book
export const TIMEZONE = "America/Los_Angeles";Times are 24h decimals (8.5 = 8:30 am). Set TIMEZONE to your practice's IANA zone. nowInZone() in
src/scripts/main.ts reads "now" there, so the open-now badge, today's row, the next free slot and the
bookable times are the same for every visitor, wherever their device is.
Clinicians (used by the booking wizard) are in CLINICIANS, keyed by an id. days lists the
weekdays they work (0 = Sunday):
"dr-okoye": { name: "Dr. Amara Okoye", role: "General & family dentist", days: [1, 2, 3, 4, 5] },The wizard shows each clinician's portrait from public/img/<id>.svg, so add a portrait with the same
file name when you add someone.
Bookable treatments are in SERVICES. minutes sets the appointment length and who lists the
clinician ids who offer it:
{ id: "checkup", name: "Check-up & cleaning", minutes: 60, note: "Exam, clean and polish", who: ["dr-okoye", "jordan-ellis", "sofia-marchetti"] },The team page and treatment pages are static markup, so update them separately when you change
CLINICIANS or SERVICES. Likewise, the printed opening hours (data-hours lists on the home and
contact pages and in the footer) are plain markup: keep them in step with HOURS.
Images and artwork#
All artwork in public/img/ is original SVG: team portraits (dr-*.svg, jordan-ellis.svg,
sofia-marchetti.svg, grace-liu.svg), hero.svg, map.svg, favicon.svg and the social image
(og-image.svg source, og-image.png export at 1200×630). Replace portraits with photos of the same
portrait proportions (they are cropped from the top), and keep the file names that CLINICIANS ids
expect, or change the path in renderClinicians() in main.ts. Link images with the url() helper so
they still work under a base path:
<img src={url("/img/dr-okoye.jpg")} alt="Dr. Amara Okoye" width="480" height="560" />Navigation, header and footer#
src/components/SiteHeader.astroholds the emergency bar, the desktop navigation and the mobile menu (#mobile-menu). Each link getsaria-current="page"when the page'scurrentprop matches it (current="team"marks/team; the implant guide usescurrent="services").src/components/SiteFooter.astrois the footer, including the newsletter form and the toast region ([data-toast]).
To add a page, create src/pages/pricing.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Pricing · Brightside Dental" description="Fees and payment plans." page="pricing.html" current="pricing">
<section class="container-x py-16">
<h1 class="font-display text-4xl">Pricing</h1>
</section>
</Layout>Then add the link to both menus in SiteHeader.astro, written as href={url("/pricing")} with
aria-current={current === "pricing" ? "page" : undefined}, and to the footer. Always write internal
links with url() from src/lib/site.ts so they keep working under a base path.
Features#
Open-now badge and today's hours#
Elements with data-open-status show "Open now · closes at 7:00 pm" or "Closed now · opens tomorrow at
8:00 am" and get data-open="true"/"false" for styling; the text goes into their [data-open-text]
child. Hours lists marked data-hours highlight today's .hours-row with data-today="true" and
aria-current="date". data-day uses the same numbers as HOURS (0 = Sunday … 6 = Saturday). The
status (updateOpenStatus()) refreshes every minute and uses the time in TIMEZONE.
Next free slot#
On the home page, [data-next-slot] shows the earliest free 60-minute slot in the next two weeks across
all clinicians, for example "Tomorrow, 9:30 am".
Booking wizard#
src/pages/appointments.astro contains the wizard (form[data-booking]) with five
fieldset[data-step] steps; the code is initBooking() in section 7 of main.ts:
- Treatment: generated from
SERVICES. Link to/appointments?service=implantto preselect one. - Clinician: the people in that treatment's
wholist, plus "First available". - Date and time: a week view (up to
WEEKS_AHEADweeks) and time slots. - Your details:
first,last,email,phone,dob,insurance,returning(new or returning),notesand a requiredconsentcheckbox. - Review: every choice with a "Change" link back to its step.
Time slots are calculated by slotsFor(date, minutes, clinicianIds):
- start every
SLOT_STEPminutes from opening, as long as the appointment ends by closing time; - skip the
LUNCHwindow on weekdays; - skip anything sooner than
MIN_NOTICE_MINUTESfrom now (inTIMEZONE); - only count clinicians who work that weekday;
- mark some slots as already booked using
isTaken(), a fixed pattern so the demo looks realistic.
Sundays and other closed days, past days and days with no free times are disabled. A live summary card
([data-sum="service"], "clinician", "when", "length") follows the choices. Each step is validated
before moving on, and focus moves to the step's heading. The confirmation ([data-done]) says the
practice would confirm by email; nothing is booked.
To take real bookings, replace isTaken() with availability from your scheduling system and send
the booking in the submit handler (see Forms). Many practices embed their practice-management
or booking provider's widget instead; you can drop it into appointments.astro in place of the form.
Team filter#
On /team, buttons with data-filter (all, general, ortho, kids, surgery, cosmetic,
hygiene, care) show matching cards in [data-filter-grid] by their data-specialty, with a live
count in [data-filter-count]. The buttons use aria-pressed. Add a button and use the same value on
cards to add a specialty.
Insurance search#
On /patients, the search field [data-plan-search] filters every [data-plan] element by its text,
updates [data-plan-count] and shows [data-plan-empty] when nothing matches. Add an insurer by adding
another element with data-plan.
Downloadable forms#
Links with data-demo-download show a toast instead of downloading. Put your PDFs in public/, set
each link's href to the file with url() (for example href={url("/new-patient-form.pdf")}), and
remove the data-demo-download attribute.
Mobile menu, header and reveals#
The header gets a shadow once the page scrolls. Below 1024px the [data-menu-toggle] button opens
#mobile-menu, swaps its icon, updates aria-expanded and closes with Esc (returning focus to the
button). Elements with reveal fade in when scrolled into view (everything above is revealed too) and
appear immediately with reduced motion.
Back to top button#
src/scripts/back-to-top.ts adds a .back-to-top button that appears after you scroll down about one
screen. It scrolls back up (instantly with reduced motion), moves keyboard focus to #main, and lifts
itself above the toast. The sticky cost card on /service uses data-back-to-top-hide so the button
never covers it. Add data-back-to-top-avoid to anything you pin to the bottom of the screen. The styles
are the .back-to-top rules in global.css. To remove it, delete the file, its import in
Layout.astro and those styles.
Forms#
| Form | File | Notes |
|---|---|---|
| Contact | src/pages/contact.astro |
Marked data-validate with its own data-success-text |
| Newsletter | src/components/SiteFooter.astro |
Email only |
Booking wizard (data-booking) |
src/pages/appointments.astro |
See Booking wizard |
Forms marked data-validate are checked by validateField(): required fields, email format, phone
numbers on fields with data-phone (at least 7 digits), minlength, and dates of birth after today's
date on the visitor's device. Errors appear under each field, are linked with aria-describedby, and the
first invalid field is focused. On success the [data-success] element shows the form's
data-success-text.
Nothing is sent until you connect the forms. For the contact and newsletter forms, add an action
(for example a Formspree endpoint) and, in section 5 of src/scripts/main.ts,
replace the success code in the submit handler (after the comment "To send for real") with:
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);
if (success) {
success.textContent = form.dataset.successText || "Thank you!";
success.classList.remove("hidden");
}
form.reset();
})
.catch(() => toast("Sorry, that didn’t send. Please call us instead."));For the booking wizard, look for the comment "To take real bookings" in the wizard's submit handler and
send new FormData(booking) together with the chosen state (service, clinician, date, time) to your
scheduling system before showing the confirmation. 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/book.ts with
export const prerender = false, then post to it.
Health information is sensitive: send forms only over HTTPS to services you trust, ask for the minimum needed, and check your local privacy rules (for example HIPAA in the US or GDPR in the EU) before collecting medical details online.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>. The layout turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://clinic.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm buildor change the default in
astro.config.mjs. Canonical URLs have no.html(https://example.com/team). -
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); it uses the samesite. Add apublic/robots.txtthat points to it. -
Local practices benefit from Dentist or MedicalClinic structured data (address, phone, opening hours) in a JSON-LD block.
Accessibility#
Clinic is built for patients of all ages and abilities:
- Larger base text, high contrast, thick 3px focus rings, a "Skip to content" link and labelled landmarks.
- The wizard uses fieldsets with legends, moves focus to each step's heading, marks the current step
with
aria-current="step", and labels each day button with its date and availability. - Filter buttons use
aria-pressed; the mobile menu usesaria-expandedand closes with Esc. - Form errors are announced and linked to their fields; today's hours row gets
aria-current="date". - Animations respect
prefers-reduced-motion.
When editing, keep text contrast at WCAG AA, give new images meaningful alt text, and label every new
form field.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as team.html rather than
team/index.html (build.format: "file" in astro.config.mjs), and links point to /team without a
trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /team from team.html automatically;
Vercel does with cleanUrls (below).
- 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/teamservesteam.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/teamtoteam.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=/clinic pnpm build. It becomes Astro's base, and every link written with url()
(including the portraits the booking wizard adds) 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#
- Open-now badge or slots are an hour or more out. Set
TIMEZONEinsrc/data/practice.tsto your practice's IANA zone name (for example"America/Chicago"). - A treatment has no free days. Check that its
whoclinicians work on open days (daysinCLINICIANS) and that the appointment length fits within the hours outsideLUNCH. - A clinician's portrait is missing in the wizard. Add
public/img/<clinician-id>.svg(or change the path inrenderClinicians()). - Today's row in the hours list is off by one day.
data-daymust use the same numbers asHOURS(0= Sunday …6= Saturday). ?service=doesn't preselect a treatment. The value must match anidinSERVICES.- Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and asset paths withurl()rather than plain"/..."strings. /teamshows a 404 on my host. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Forms "send" but nothing arrives. Connect them as described in Forms.
License#
MIT. The code and all included SVG portraits and illustrations are free for personal and commercial
use; the download includes the license text in LICENSE.txt. Figtree and Fraunces are licensed under
the SIL Open Font License.