Freeframe

Documentation

Clinic

v1.0.1Updated Nov 11, 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, shown as Brightside Dental, a fictional family practice in Portwell. It has a home page with a live open-now badge and a "next free slot" card, services with typical costs, a detailed treatment guide (dental implants), a filterable team page, patient information with a searchable insurance list, a contact page and a five-step booking wizard that only offers real opening hours and clinician days. Text is larger than usual and contrast is high for easy reading. This is the Nuxt version (1.0.0): Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site.

Live demo: 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 (corepack enable pnpm; npm works too).

sh
pnpm install      # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev          # http://localhost:3000
Script What it does
pnpm dev Development server at http://localhost:3000
pnpm generate Static site in .output/public/ (nuxt generate): every page prerendered to HTML
pnpm build A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server
pnpm preview Serve the last build locally
pnpm lint ESLint (@nuxt/eslint)
pnpm typecheck nuxt prepare and vue-tsc
pnpm build:demo Generate for freeframe.dev under /demos/nuxt/clinic into demo/ (you can delete it)

Pages#

Route File Notable sections and features
/ app/pages/index.vue Hero with the "next free slot" card, services, why Brightside, team preview, reviews with rating breakdown, insurers, open-now badge and hours, map, call to action
/services app/pages/services.vue Four categories of expandable treatments with typical costs, jump links, payment plan prompt
/service app/pages/service.vue Treatment guide for dental implants: key facts, four-step timeline, aftercare, FAQ, sticky cost breakdown, related treatments
/team app/pages/team.vue Eight team members with credentials and languages, specialty filter
/appointments app/pages/appointments.vue Five-step booking wizard with a live summary (?service=<id> preselects a treatment)
/patients app/pages/patients.vue First visit, insurance search, downloadable forms, payment options, FAQ, privacy
/contact app/pages/contact.vue Emergency notice, contact form, open-now badge and hours, contact cards, map, directions, accessibility
404 app/error.vue Page not found, with links home, to booking and to contact

Every inner page starts with PageHero (breadcrumb, eyebrow, h1 and intro). The footer on every page has the newsletter form and the opening hours.

Project structure#

app/
├── app.vue                   <NuxtLayout><NuxtPage /></NuxtLayout>
├── layouts/default.vue       skip link, header, the page, footer, toast and back-to-top button
├── pages/                    one file per page (index.vue, services.vue, service.vue, team.vue, ...)
├── error.vue                 404 page (generated as 404.html)
├── components/               BookingWizard, OpenStatus, HoursList, NextFreeSlot, TeamGrid, PlanSearch,
│                             FormDownloads, ContactForm, NewsletterForm, FieldError, FaqList, PageHero,
│                             SiteHeader, SiteFooter, ToastMessage, BackToTop, AppIcon
├── composables/
│   ├── useZoneNow.ts         the practice's current time, after the page has loaded
│   ├── useValidation.ts      inline form validation
│   ├── usePageSeo.ts         title, description, canonical and Open Graph tags per page
│   ├── useCurrentPage.ts     aria-current for menu links
│   └── useAsset.ts           files in public/ with the base path
├── data/
│   ├── practice.ts           practice details, TIMEZONE, opening hours, booking rules, clinicians, bookable treatments
│   ├── services.ts           services page categories and treatments with costs
│   ├── implants.ts           treatment guide content (/service)
│   ├── team.ts               team members, specialty filters and training list
│   ├── patients.ts           first visit steps, insurance plans, patient forms, payment options, FAQ
│   ├── home.ts               home page cards, reasons, ratings, reviews, insurers
│   ├── nav.ts                header, mobile and footer links
│   ├── icons.ts              line icons drawn by AppIcon
│   └── faq.ts                the Faq type
├── utils/
│   ├── schedule.ts           opening hours text, open-now status, time zone, appointment slots
│   └── toast.ts              toast() and makeRef()
├── plugins/
│   ├── reveal.client.ts      scroll reveals
│   └── stable-build.server.ts  reproducible builds
├── router.options.ts         keeps query strings as written
└── assets/
    ├── css/main.css          Tailwind import, fonts, design tokens (@theme) and component classes
    └── fonts/                Figtree and Fraunces (Latin .woff2) with their OFL licenses
public/img/                   SVG portraits and illustrations, favicon.svg, og-image.svg and og-image.png
nuxt.config.ts                base path, head defaults, SITE_URL, prerendered routes, Tailwind
template.json                 template metadata used by freeframe.dev

Customizing#

Brand and colors#

Design tokens live in the @theme block in app/assets/css/main.css:

Token Used for
--color-brand-50 ... --color-brand-900 Teal brand scale: buttons, links, icons, focus rings, the footer
--color-sun, --color-coral Warm accents (newsletter button, underline in the hero)
--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 scale (keep the light-to-dark order):

css
@theme {
  --color-brand-500: #2f6fd1;
  --color-brand-600: #2459ad;
  --color-brand-700: #1d4789;
  --color-brand-800: #193b70;
  --color-brand-900: #16315c;
}

Keep --color-brand-600 and darker dark enough for white text (WCAG AA). Tailwind compiles on the fly in pnpm dev and pnpm generate (through @tailwindcss/vite), so there's no separate CSS step. A few places use hex values directly: the browser theme color (theme-color in nuxt.config.ts), the open-now pill and error colors in main.css, the button shadow (rgb(27 98 91 / .8)), and the emergency notices (bg-[#fdf0ea], text-[#7c2d12]). Search for the old values when you rebrand.

Typography#

Body text uses Figtree (--font-sans) and headings use Fraunces (--font-display). Both are self-hosted from app/assets/fonts/ and declared with @font-face at the top of main.css: the same Latin files Google Fonts serves, so no request goes to Google and text sets exactly like the HTML version. Their SIL Open Font License texts are app/assets/fonts/Figtree-OFL.txt and app/assets/fonts/Fraunces-OFL.txt.

css
@font-face {
  font-family: "Figtree";
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url("../fonts/Figtree-latin.woff2") format("woff2");
}

To swap a font, add its .woff2 (and license) to app/assets/fonts/, change the @font-face rule and the family name in --font-sans or --font-display. The base size is font-size: 106.25% on html in main.css; keep it large for readability.

Content#

Content that repeats or drives features lives in typed files in app/data/, imported as ~/data/<file>:

Export File What it holds
PRACTICE practice.ts name, phone, phoneHref, email, street, town, shortAddress
TIMEZONE practice.ts The practice's IANA time zone ("America/Los_Angeles")
HOURS, LUNCH practice.ts Opening hours per weekday and the weekday lunch break
SLOT_STEP, MIN_NOTICE_MINUTES, WEEKS_AHEAD practice.ts Booking rules
CLINICIANS practice.ts Bookable clinicians keyed by id: name, role, days
BOOKABLE practice.ts Bookable treatments: id, name, minutes, note, who
INSURANCE_OPTIONS, CONTACT_TOPICS practice.ts Insurance choices in the booking form, topics in the contact form
CATEGORIES services.ts Services page categories (id, title, icon, intro, treatments)
TEAM, SPECIALTIES, TRAINING team.ts Team members, the filter buttons and the training list
FACTS, STEPS, AFTERCARE, IMPLANT_FAQ, COSTS, TOTAL, RELATED implants.ts Treatment guide
FIRST_VISIT, PLANS, PATIENT_FORMS, PATIENT_FAQ, PATIENT_SECTIONS, PAYMENT_OPTIONS patients.ts Patients page
HOME_SERVICES, REASONS, RATINGS, REVIEWS, INSURERS home.ts Home page
NAV, MOBILE_NAV, FOOTER_LINKS nav.ts Menus

Opening hours are 24-hour decimals in the practice's time zone (8.5 = 8:30 am; null = closed):

ts
export const HOURS: Record<number, DayHours | null> = {
  0: null,
  1: [8, 19],
  2: [8, 19],
  3: [8, 19],
  4: [8, 19],
  5: [8, 16],
  6: [9, 14],
};

To add a bookable treatment, add an entry to BOOKABLE; who lists the CLINICIANS ids who offer it:

ts
{ id: "veneers", name: "Veneers consultation", minutes: 45, note: "Shade and shape planning", who: ["dr-kim"] },

It appears in step 1 of the wizard and can be preselected with /appointments?service=veneers.

To add a clinician, add them to CLINICIANS (with the weekdays they work, 0 = Sunday) and to TEAM in team.ts (the team page), and add a portrait public/img/<id>.svg. CLINICIANS drives booking; TEAM drives the team page and the first four members appear on the home page. A TEAM member's specialty must be one of the SPECIALTIES ids.

Some content is written directly in the page and component files:

  • the hero statistics and the "Why Brightside" intro on the home page (app/pages/index.vue);
  • the privacy text on /patients and the directions and accessibility notes on /contact;
  • the monthly price and the Dr. Alvarez card in the /service sidebar;
  • the "We're closed on Sundays" hint in step 3 of BookingWizard.vue.

Images and artwork#

All artwork in public/img/ is original SVG:

Files Size Where
dr-okoye.svg, dr-hartmann.svg, dr-raman.svg, dr-alvarez.svg, dr-kim.svg, jordan-ellis.svg, sofia-marchetti.svg, grace-liu.svg 360 × 420 portrait Team cards, home team preview, booking wizard step 2
hero.svg 640 × 560 Home hero
map.svg 800 × 520 Home and contact pages
favicon.svg 32 × 32 Browser icon and the logo in the header and footer
og-image.svg, og-image.png 1200 × 630 Social image (SVG source, PNG export used by usePageSeo())

Portraits are named after the person's id, and the team grid and wizard build the path from it (asset(`img/${m.id}.svg`)). To use photos, keep the same proportions (portraits are cropped by object-cover), and change the extension where the path is built in TeamGrid.vue, BookingWizard.vue and index.vue. Always reference files in public/ through useAsset() so they keep working under a base path:

<script setup lang="ts">
const asset = useAsset();
</script>

<template>
  <img :src="asset('img/hero.svg')" alt="Illustration of a bright, calm treatment room" width="640" height="560">
</template>

Icons are 24 × 24 line drawings kept as strings in app/data/icons.ts and drawn by <AppIcon name="phone" />. To add one, add the inside of its <svg> under a new name.

  • Header links are NAV in app/data/nav.ts; the mobile menu uses MOBILE_NAV (Home plus NAV). SiteHeader.vue renders both and marks the current section with aria-current="page" through useCurrentPage() (the /service guide counts as Services). "Book appointment" and the phone number are buttons in both menus.
  • The emergency top bar above the header and the phone number come from PRACTICE.
  • The footer (SiteFooter.vue) has the logo and newsletter form, the address, FOOTER_LINKS, HoursList and the copyright year, which is filled in by the browser so it never goes stale.
  • To add a page, create app/pages/<route>.vue with <main id="main"> as its root, call usePageSeo({ title, description }), start it with PageHero, add the route to routes in nuxt.config.ts and add a NAV (and maybe FOOTER_LINKS) entry.

Features#

Time zone and hydration#

Everything time-dependent uses the practice's time, not the visitor's: set TIMEZONE in app/data/practice.ts (default "America/Los_Angeles"). nowInZone() in app/utils/schedule.ts returns a Date whose local fields (getDay(), getHours() ...) are the date and time in that zone, so they can be compared with HOURS directly.

Components read it through useZoneNow() in app/composables/useZoneNow.ts. It is null in the generated HTML and until the page has loaded, then the current zone time, updated every minute. So the static HTML contains neutral placeholders, and the open-now badge, today's hours row and bookable slots are filled in by the browser, with no hydration mismatches whatever the build machine's clock or zone.

Open-now badge and opening hours#

  • OpenStatus.vue shows "Open now · closes at 7:00 pm" or "Closed now · opens tomorrow at 8:00 am" from openStatus(), in a .open-pill with data-open="true"/"false" for color and role="status". Before the page has loaded it reads "Checking today's hours...".
  • HoursList.vue prints Monday to Sunday from HOURS with hoursText(), and marks today's row with data-today="true" and aria-current="date". Pass list-class for extra classes (the footer uses a smaller size).
  • NextFreeSlot.vue (home hero card) shows the first free 60-minute slot with anyone in the next 14 days from nextFreeSlot(), for example "Today, 2:30 pm".

The pulsing dot stops with reduced motion.

Booking wizard#

BookingWizard.vue (on /appointments) is a single form with five fieldset[data-step] steps and a sticky summary (treatment, clinician, when, length):

  1. Treatment: from BOOKABLE, with each treatment's length.
  2. Clinician: the treatment's who list with portraits and workdays, plus "First available".
  3. Date and time: a week of day buttons (up to WEEKS_AHEAD weeks, starting at the first week with free days) and the free start times for the chosen day.
  4. Your details: first, last, email, phone, dob, insurance (INSURANCE_OPTIONS), returning, notes (optional, 500 characters) and the required consent checkbox.
  5. Review: every answer with a "Change" button back to its step.

Slots come from slotsFor(date, minutes, clinicianIds, now) in app/utils/schedule.ts:

  • a start every SLOT_STEP minutes from opening, as long as the appointment ends by closing time;
  • the LUNCH window is skipped on weekdays (not Saturdays);
  • nothing sooner than MIN_NOTICE_MINUTES from now;
  • only clinicians whose days include that weekday;
  • some slots are marked as booked by isTaken(), a fixed demo pattern.

Past days, closed days and days with no free times are disabled, and booked times are struck through. With "First available", the summary names the first clinician free at the chosen time. Changing the treatment or clinician clears the chosen date and time, and a clinician who doesn't offer a newly chosen treatment is cleared.

Preselecting a treatment. /appointments?service=implant starts with that treatment chosen (the implant guide links to it). The value must match an id in BOOKABLE exactly (?service=constructor matches nothing). The query string is read in onMounted().

Nothing is booked until you connect it. The confirmation reads as if it had been: it shows a reference (BD- and four characters, see Forms), says the practice will confirm by email within one business day and gives the phone number for changes. To take real bookings, replace isTaken() with your scheduling system's availability and send the booking where onSubmit() says:

ts
// To take real bookings, send new FormData(form.value) plus `b` (the booking) to your scheduling system here.

The booking state b holds step, service, clinician, week, date, time and who. Many practices embed their booking provider's widget instead; you can render it in app/pages/appointments.vue in place of <BookingWizard />.

Accessibility: each step is a fieldset with a legend; focus moves to the legend on each step (scrolling with reduced motion respected); the progress list marks the current step with aria-current="step"; day buttons have labels such as "Thursday 8 Oct, 6 times available" or "..., closed"; selected days and times use aria-pressed; step errors use role="alert" and focus the first choice. Pressing Enter in a field of step 4 works like "Continue".

Team filter#

TeamGrid.vue renders TEAM with one .chip button per entry in SPECIALTIES plus "Everyone". Buttons use aria-pressed, and a polite live region reads "Showing 2 of 8 team members: Hygiene". Cards have id anchors (/team#dr-okoye). To add a specialty, add it to SPECIALTIES and use its id as a member's specialty.

PlanSearch.vue on /patients#insurance filters PLANS as you type (anywhere in the name, ignoring case), updates a polite count ("3 of 18 plans match") and shows a "call us" message when nothing matches. Add a plan to PLANS in app/data/patients.ts. The home page strip uses the shorter INSURERS list in home.ts.

Patient form downloads#

FormDownloads.vue lists PATIENT_FORMS. Forms without an href show a toast instead of downloading (it says the form is being updated and asks the patient to complete it in the waiting room). Add your PDFs to public/ (for example public/forms/registration.pdf) and give each entry an href relative to public/; the base path is added for you:

ts
{ title: "New patient registration", meta: "PDF · 2 pages", href: "forms/registration.pdf" },

Services and FAQs#

Treatments on /services and the FAQs on /service and /patients are native <details> disclosures (keyboard and screen-reader friendly, no script). FaqList.vue takes an array of { q, a } (Faq). A treatment with a guide gets a link to its full guide.

Toasts#

toast(message) from app/utils/toast.ts (auto-imported) shows a message in the [data-toast] element at the bottom of the page (ToastMessage.vue, in the layout) for 3.2 seconds, in a polite live region.

Header, mobile menu and scroll reveals#

SiteHeader.vue adds a shadow (.is-scrolled) after scrolling. Below 1024px the menu button toggles #mobile-menu (aria-expanded, aria-controls); Esc closes it and returns focus to the button, and it closes on navigation or when the window gets wider. app/plugins/reveal.client.ts fades in .reveal elements as they scroll into view, revealing everything above too; a --d style (for example style="--d:80ms") staggers them. With reduced motion, or before JavaScript runs (a small script in nuxt.config.ts adds the js class to <html>), content is visible straight away. Don't put .reveal on an element that also has a :class binding: Vue could remove the is-in class the plugin adds.

Back to top#

BackToTop.vue, in the layout, shows a button after about one screen of scrolling. It scrolls smoothly (instantly with reduced motion), moves focus to <main>, lifts above the toast and elements marked data-back-to-top-avoid, and hides while it would cover a pinned element marked data-back-to-top-hide (the sticky cost card on /service). Remove <BackToTop /> from app/layouts/default.vue and the .back-to-top block in main.css to drop it.

Forms#

Form Where Validates
Booking wizard BookingWizard.vue (/appointments) A choice on steps 1 to 3; names, email, phone, date of birth and consent on step 4
Contact ContactForm.vue (/contact) Name, email, message of at least 10 characters
Newsletter NewsletterForm.vue (footer) Email

All three use useValidation() from app/composables/useValidation.ts. Bind v-bind="v.field(id)" on an input and put <FieldError :id="id" :message="v.errors[id]" /> after it; v.validateAll(scope) on submit shows every error and focuses the first invalid field. messageFor() checks required (including checkboxes), email format, phone numbers on fields with data-phone (at least 7 digits), minlength, and a date of birth (name="dob") not in the future. Errors appear when you leave a field that has a value, update as you type, and are linked with aria-describedby and aria-invalid.

Nothing is sent until you connect the forms, even though the success messages read as if it had been. The contact form and the booking wizard show a short reference (BD- and four characters) made in the browser by makeRef() in app/utils/toast.ts; if your form service or scheduling system returns its own reference, show that instead. Check that the promises in the messages (a reply within one business day, a confirming email within one business day, a newsletter confirmation email) match how your practice works.

To post the contact or newsletter form to a form service such as Formspree, make onSubmit() async and replace the "To send for real" comment and the success.value = ... line:

ts
const res = await fetch("https://formspree.io/f/your-id", {
  method: "POST",
  body: new FormData(form),
  headers: { Accept: "application/json" },
});
if (!res.ok) return toast("Sorry, that didn't send. Please call us instead.");
success.value = `Thank you! Your message has reached us, and we’ll reply within one business day. Your reference is ${makeRef("BD")}.`;
form.reset();

For the booking wizard, send new FormData(form.value!) together with the booking state in onSubmit() before done.value = .... With pnpm build (a Node.js server build) you can post to your own server route instead, for example server/api/contact.post.ts; a static site from pnpm generate has no server routes.

Health information is sensitive: send forms only over HTTPS to services you trust, ask for the minimum you need, and check your local privacy rules (for example HIPAA) before collecting medical details online.

SEO and sharing#

  • Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title, description, canonical URL, Open Graph tags (with og:image at img/og-image.png) and the Twitter card. The 404 page passes noindex: true.
  • Canonical, og:url and og:image are absolute: SITE_URL (runtimeConfig.public.siteUrl in nuxt.config.ts, falling back to https://brightside.example) plus the base path. Set it when you build:
sh
SITE_URL=https://www.your-practice.com pnpm generate
  • lang, the theme color and the favicon are in app.head in nuxt.config.ts.

For launch, consider adding a sitemap and public/robots.txt, and Dentist or MedicalClinic structured data (address, phone, opening hours) as JSON-LD on the home page with useHead().

Accessibility#

Built in:

  • a "Skip to content" link (in app/layouts/default.vue), labeled landmarks and breadcrumbs, and one h1 per page;
  • larger base text, high contrast and thick 3px focus rings (:focus-visible in main.css);
  • the wizard's fieldsets, legends, focus management and aria-current="step", with descriptive day labels;
  • aria-pressed on filter chips, days and times; aria-expanded on the menu button; aria-current for the current page and today's hours;
  • form errors linked with aria-describedby/aria-invalid, with focus moved to the first error;
  • live regions for the open-now badge, filter and plan counts, form success messages and toasts;
  • native <details> for FAQs and treatments;
  • reduced motion respected by reveals, smooth scrolling, the pulsing badge, card hover lifts and the back-to-top button;
  • print styles that hide the header and footer.

When editing, keep one h1 per page, label every new input, give images meaningful alt text (or alt="" when decorative), and keep text contrast at WCAG AA.

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (services.html, linked as /services), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /services to services.html on their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.

To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:

sh
DEMO_BASE_PATH=/clinic pnpm generate

pnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.

TypeScript setup#

pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them). typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load; replace it with typescript@7 once they support it.

Troubleshooting#

  • The open-now badge or slots are an hour or more out. Set TIMEZONE in app/data/practice.ts to your practice's IANA zone name (for example "America/Chicago").
  • Opening hours changed, but some text still shows the old ones. Every hours table is printed from HOURS by hoursText(), so they update automatically, as do the badge and slots. Hand-written copy doesn't: check the "Hours that fit" reason in REASONS (app/data/home.ts), the step 3 hint in BookingWizard.vue, and the NextFreeSlot placeholder ("Tomorrow, 9:30 am") shown before the page has loaded.
  • A treatment has no free days. Check that its who clinicians work on open days (days in CLINICIANS) and that its minutes fit within the hours outside LUNCH.
  • ?service= doesn't preselect a treatment. The value must match an id in BOOKABLE exactly.
  • A new page is missing from the generated site. Add its route to routes in nuxt.config.ts.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install or npx nuxt prepare first.
  • A Tailwind class has no effect. Tailwind only generates classes it finds as complete strings in the source. Don't build class names from pieces (`w-[${n}%]`); store the full class, as RATINGS does with "w-[92%]".
  • Social previews use brightside.example. Set SITE_URL when building.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Figtree and Fraunces are licensed under the SIL Open Font License (app/assets/fonts/*-OFL.txt). Brightside Dental, its team and its patients are fictional.

Search documentation

Search the guides and every template's documentation