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).
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.devCustomizing#
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):
@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.
@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):
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:
{ 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
/patientsand the directions and accessibility notes on/contact; - the monthly price and the Dr. Alvarez card in the
/servicesidebar; - 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.
Navigation, header and footer#
- Header links are
NAVinapp/data/nav.ts; the mobile menu usesMOBILE_NAV(Home plusNAV).SiteHeader.vuerenders both and marks the current section witharia-current="page"throughuseCurrentPage()(the/serviceguide 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,HoursListand the copyright year, which is filled in by the browser so it never goes stale. - To add a page, create
app/pages/<route>.vuewith<main id="main">as its root, callusePageSeo({ title, description }), start it withPageHero, add the route toroutesinnuxt.config.tsand add aNAV(and maybeFOOTER_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.vueshows "Open now · closes at 7:00 pm" or "Closed now · opens tomorrow at 8:00 am" fromopenStatus(), in a.open-pillwithdata-open="true"/"false"for color androle="status". Before the page has loaded it reads "Checking today's hours...".HoursList.vueprints Monday to Sunday fromHOURSwithhoursText(), and marks today's row withdata-today="true"andaria-current="date". Passlist-classfor 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 fromnextFreeSlot(), 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):
- Treatment: from
BOOKABLE, with each treatment's length. - Clinician: the treatment's
wholist with portraits and workdays, plus "First available". - Date and time: a week of day buttons (up to
WEEKS_AHEADweeks, starting at the first week with free days) and the free start times for the chosen day. - Your details:
first,last,email,phone,dob,insurance(INSURANCE_OPTIONS),returning,notes(optional, 500 characters) and the requiredconsentcheckbox. - 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_STEPminutes from opening, as long as the appointment ends by closing time; - the
LUNCHwindow is skipped on weekdays (not Saturdays); - nothing sooner than
MIN_NOTICE_MINUTESfrom now; - only clinicians whose
daysinclude 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:
// 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.
Insurance search#
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:
{ 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) |
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:
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 (withog:imageatimg/og-image.png) and the Twitter card. The 404 page passesnoindex: true. - Canonical,
og:urlandog:imageare absolute:SITE_URL(runtimeConfig.public.siteUrlinnuxt.config.ts, falling back tohttps://brightside.example) plus the base path. Set it when you build:
SITE_URL=https://www.your-practice.com pnpm generatelang, the theme color and the favicon are inapp.headinnuxt.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 oneh1per page; - larger base text, high contrast and thick 3px focus rings (
:focus-visibleinmain.css); - the wizard's fieldsets, legends, focus management and
aria-current="step", with descriptive day labels; aria-pressedon filter chips, days and times;aria-expandedon the menu button;aria-currentfor 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#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt 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:
DEMO_BASE_PATH=/clinic pnpm generatepnpm 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
TIMEZONEinapp/data/practice.tsto 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
HOURSbyhoursText(), so they update automatically, as do the badge and slots. Hand-written copy doesn't: check the "Hours that fit" reason inREASONS(app/data/home.ts), the step 3 hint inBookingWizard.vue, and theNextFreeSlotplaceholder ("Tomorrow, 9:30 am") shown before the page has loaded. - A treatment has no free days. Check that its
whoclinicians work on open days (daysinCLINICIANS) and that itsminutesfit within the hours outsideLUNCH. ?service=doesn't preselect a treatment. The value must match anidinBOOKABLEexactly.- A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.- 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, asRATINGSdoes with"w-[92%]". - Social previews use
brightside.example. SetSITE_URLwhen 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.