Freeframe

Documentation

Clinic

v1.0.4Updated Oct 17, 2026A calm, accessible website for a dental or medical practice, with online booking.

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

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/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.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'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):

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

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

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

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

html
<img src={url("/img/dr-okoye.jpg")} alt="Dr. Amara Okoye" width="480" height="560" />
  • src/components/SiteHeader.astro holds the emergency bar, the desktop navigation and the mobile menu (#mobile-menu). Each link gets aria-current="page" when the page's current prop matches it (current="team" marks /team; the implant guide uses current="services").
  • src/components/SiteFooter.astro is the footer, including the newsletter form and the toast region ([data-toast]).

To add a page, create src/pages/pricing.astro:

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

  1. Treatment: generated from SERVICES. Link to /appointments?service=implant to preselect one.
  2. Clinician: the people in that treatment's who list, plus "First available".
  3. Date and time: a week view (up to WEEKS_AHEAD weeks) and time slots.
  4. Your details: first, last, email, phone, dob, insurance, returning (new or returning), notes and a required consent checkbox.
  5. Review: every choice with a "Change" link back to its step.

Time slots are calculated by slotsFor(date, minutes, clinicianIds):

  • start every SLOT_STEP minutes from opening, as long as the appointment ends by closing time;
  • skip the LUNCH window on weekdays;
  • skip anything sooner than MIN_NOTICE_MINUTES from now (in TIMEZONE);
  • 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.

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:

ts
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 title and description to <Layout>. The layout turns them into <title>, the meta description, og:title, og:description and a summary_large_image Twitter card.

  • 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://clinic.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm build

    or 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 same site. Add a public/robots.txt that 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 uses aria-expanded and 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#

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

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /team serves team.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 /team to team.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=/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 TIMEZONE in src/data/practice.ts to your practice's IANA zone name (for example "America/Chicago").
  • A treatment has no free days. Check that its who clinicians work on open days (days in CLINICIANS) and that the appointment length fits within the hours outside LUNCH.
  • A clinician's portrait is missing in the wizard. Add public/img/<clinician-id>.svg (or change the path in renderClinicians()).
  • Today's row in the hours list is off by one day. data-day must use the same numbers as HOURS (0 = Sunday … 6 = Saturday).
  • ?service= doesn't preselect a treatment. The value must match an id in SERVICES.
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, and write internal links and asset paths with url() rather than plain "/..." strings.
  • /team shows a 404 on my host. Set build.format: "directory" in astro.config.mjs and 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.

Search documentation

Search the guides and every template's documentation