Overview#
Smile is a bright, friendly website for a family and cosmetic dental practice. It's built for Grin Lane Dental, a fictional practice in Boise, Idaho, with four dentists, three hygienists and a patient care team. It includes a five-step booking flow whose time slots come from each clinician's schedule and the length of the visit, a pricing page with a cost estimator and a payment-plan calculator, an emergency symptom guide, a multi-step new patient form and filterable treatments and team pages. This is the Astro version: Astro 7, TypeScript and Tailwind CSS v4, with no UI framework (interactivity is plain TypeScript), built to static files. This guide covers version 1.0.0. Live demo: freeframe.dev/templates/smile.
All health content is general information written for a fictional practice. Have a qualified professional review every treatment description, triage rule and price before you publish your own site.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm install -g pnpm, or use npm with the same script names).
pnpm install
pnpm dev # http://localhost:4321, reloads as you editOther scripts:
| Command | What it does |
|---|---|
pnpm build |
Builds the static site into dist/ |
pnpm preview |
Serves dist/ locally |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
astro sync && tsc --noEmit with TypeScript 7 |
pnpm lint |
ESLint (typescript-eslint and eslint-plugin-astro) |
Pages#
| Route (source file) | Purpose | Notable sections and features |
|---|---|---|
/ (src/pages/index.astro) |
Home | Hero with "Book an appointment" and "Dental emergency?" buttons, live open-now badge, next available checkup card, emergency strip, $99 new patient offer, six treatment cards, why-us cards, four dentists, review carousel, insurance and Grin Club teaser, hours with map, call-to-action band |
/treatments (treatments.astro) |
Treatments | 16 treatments filterable by category (?category=), each with what to expect, duration, from-price and a booking link; general-information note; FAQ accordion |
/pricing (pricing.astro) |
Pricing and insurance | Fee table (stacks into cards on phones), three Grin Club plans, cost estimator, illustrative payment-plan calculator, insurance help |
/team (team.astro) |
Team | Nine team members filterable by role (?role=) and by language, with training, specialities, days in the practice and "Book with" links |
/new-patients (new-patients.astro) |
New patients | First-visit timeline, what to bring, nervous-patient guide in tabs, five-step patient form with review screen, privacy summary (#privacy) |
/book (book.astro) |
Book online | Five-step booking flow with a summary sidebar; accepts ?reason= and ?clinician= |
/emergency (emergency.astro) |
Dental emergencies | Call and book buttons, "call 911 first" box, three-question symptom guide, first-aid tips, emergency hours |
/contact (contact.astro) |
Contact | Contact cards, validated contact form, map, hours, getting here and accessibility (#accessibility) |
404.html (404.astro) |
Not found | Friendly error page with links (has noindex) |
Project structure#
smile/
astro.config.mjs site URL, base path, static file output
package.json scripts and dependencies
tsconfig.json extends Astro's strict preset
eslint.config.mjs ESLint flat config
public/img/ favicon, social image, mascot, map and team portraits (copied as-is)
src/
pages/*.astro one file per page (index, treatments, pricing, team, new-patients,
book, emergency, contact, 404)
layouts/Layout.astro <html>, <head> metadata, header, footer, toast and the script imports
components/
SiteHeader.astro logo, main menu, Emergency pill, Book button and mobile menu
SiteFooter.astro contact details, link lists, opening hours and copyright
OpeningHours.astro the hours list (<dl data-hours>) used in the footer and on pages
OpenPill.astro the live open-now badge
data/
practice.ts practice data: time zone, hours, closures, clinicians, visit types,
estimator prices, membership, payment-plan terms, triage rules
nav.ts header and footer links
lib/site.ts site name, theme colour and the url() helper
scripts/
main.ts all interactivity (hours, booking, estimator, triage, forms, filters...)
back-to-top.ts the back-to-top button
styles/global.css Tailwind source: brand tokens (@theme) and component classes
README.md, CHANGELOG.md, DOCS.mdCustomising#
Brand and colours#
All design tokens live in the @theme block at the top of src/styles/global.css. Tailwind turns each token into
utilities, so --color-berry-600 becomes bg-berry-600, text-berry-600 and so on. pnpm dev picks up changes
immediately.
| Token group | Used for |
|---|---|
--color-berry-50 to --color-berry-950 |
Brand blue-violet: buttons and links (600), dark sections and the footer (950) |
--color-bubble-50 to --color-bubble-800 |
Playful pink: emergency buttons and badges, tags |
--color-lemon-50 to --color-lemon-700 |
Sunny highlight: offer cards, squiggle underline, icon dots |
--color-mint-50 to --color-mint-800 |
Calm green: open-now badge, "included" tags, self-care states |
--color-ink, --color-body, --color-muted |
Headings, body text and secondary text |
--color-line, --color-cream, --color-cloud |
Borders, page background and soft panels |
To change the brand colour, replace the berry scale:
@theme {
--color-berry-50: #eef6ff;
--color-berry-100: #d9eaff;
/* ...keep 50 to 950 from light to dark... */
--color-berry-600: #1f6fd1;
--color-berry-950: #0c1f3d;
}Keep the 600 shade dark enough for white button text (at least 4.5:1 contrast). A few colours are written as hex
values in the SVG files and the inline SVGs (for example #5146d4 for berry in the logo), so search and replace those
too if you change the palette. The theme-color meta tag uses site.themeColor in src/lib/site.ts (the cream
background, #fffaf3).
Component classes (.btn, .btn-primary, .btn-lemon, .btn-pink, .btn-outline, .card, .chip, .field,
.option, .slot, .cal-day, .faq, .open-pill, .outcome, .squiggle and more) are defined in
@layer components in the same file.
Typography#
Two Google Fonts are loaded in src/layouts/Layout.astro:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400..700&family=Nunito:ital,wght@0,400..900;1,400..700&display=swap" />Fredoka is the rounded display face for headings (font-display, also applied to h1 to h4 in the base layer) and
Nunito is the body face (font-sans). To swap them, change that link and the two tokens in src/styles/global.css:
--font-display: "Fredoka", "Nunito", ui-rounded, "SF Pro Rounded", system-ui, sans-serif;
--font-sans: "Nunito", ui-rounded, "SF Pro Rounded", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;The fallbacks keep the site readable if Google Fonts can't load. To self-host, put the font files in public/fonts/,
add @font-face rules to global.css and remove the Google Fonts links.
Content#
Page text lives in the .astro files in src/pages/. Practice data that drives the interactive features lives in
src/data/practice.ts:
| Export | What it holds |
|---|---|
PRACTICE |
Name, time zone (America/Boise), time zone label, phone and address used in the booking flow and .ics file |
HOURS |
Opening hours per weekday (0 = Sunday, null = closed), used by the open-now badge and the booking slots |
HOURS_TABLE |
The printed hours list rendered by OpeningHours.astro |
CLOSED_DATES |
Whole-practice closures as YYYY-MM-DD |
LUNCH |
Lunch break applied on days longer than six hours |
CLINICIANS |
Each clinician's name, short name, role, portrait and working weekdays |
REASONS |
Visit types for booking: label, length in minutes, price note, who offers it and minimum notice |
BOOKING |
Slot interval, how far ahead people can book and the demo "taken" share |
ESTIMATE_ITEMS, MEMBERSHIP, FINANCE |
Cost estimator treatments and prices, Grin Club prices and discount, payment-plan terms |
TRIAGE |
Symptom guide questions, red flags, tips and outcome wording |
When you change HOURS, update HOURS_TABLE too: the badge reads HOURS, the printed lists read HOURS_TABLE.
Add a clinician. Add an entry to CLINICIANS (the img path is relative to public/), list their id in the
who array of each visit type they offer, then add a card to src/pages/team.astro:
export const CLINICIANS: Record<string, Clinician> = {
// ...
park: { name: "Dr. Grace Park", short: "Dr. Park", role: "Dentist", img: "/img/dr-park.svg", days: [1, 3, 5] },
};
export const REASONS: Record<string, Reason> = {
checkup: { label: "Checkup & clean", minutes: 60, price: "$165, or included in Grin Club", who: ["kowalski", "haddad", "brooks", "park"], notice: 240 },
// ...
};Team cards in team.astro use data-tags for the role filter (dentist, hygienist or care) and data-langs for
the language filter (codes that match the <option> values in the "Speaks" select):
<li id="park" class="reveal card card-hover flex flex-col p-6" data-filter-item data-tags="dentist" data-langs="en ko">Link straight to a clinician with url("/book?clinician=park").
Add a treatment. Copy a card in treatments.astro and set data-tags to one of the filter categories (general,
cosmetic, orthodontics, implants, kids, emergency). The booking button links to url("/book?reason=<id>")
with an id from REASONS. The chip counts update automatically. If the treatment should be in the estimator, add it
to ESTIMATE_ITEMS:
{ id: "veneer", name: "Porcelain veneer", price: 1150, qty: 0 },Add included: { per: "adult", count: 2 } (or per: "kid") when Grin Club covers it. Keep the fee table in
pricing.astro and the prices on treatments.astro in step with these numbers.
Images and artwork#
All artwork is SVG in public/img/, so it stays sharp and needs no image optimisation:
hero.svg(560 x 520) is Pip the tooth mascot on the home page. The two floating cards over it are HTML.- Team portraits are 240 x 240 SVGs with a rounded square background. To use photos, keep a square crop (at least
480 x 480 pixels), save them as
.jpgor.webpinpublic/img/and change thesrc, plus theimgpath inCLINICIANSso the booking flow shows the same picture. map.svg(600 x 480) is an illustrated map. You can replace it with a static map image, or an embedded map<iframe>(that adds an external request).- Smaller illustrations (why-us cards, page headers, 404, nervous-patient card) are inline SVG in the pages.
favicon.svgis the tooth logo. The same mark is inline inSiteHeader.astroandSiteFooter.astro.- The social image is
og-image.png(1200 x 630). Its source isog-image.svg. After editing the SVG, export it to PNG at 1200 x 630 with any vector editor or browser, because social sites don't display SVG.
Always link files in public/ through url() (for example src={url("/img/map.svg")}) so they work when the site
is served from a sub-folder.
Navigation, header and footer#
The header and footer are components used by Layout.astro, so each change applies to every page:
src/data/nav.tsholdsMAIN_NAV(desktop menu, also listed in the mobile menu after "Home"),FOOTER_EXPLOREandFOOTER_PATIENTS.- Each page passes
currentto<Layout>(for examplecurrent="pricing"). Links whosepagematches getaria-current="page", which highlights them. The "Emergency?" pill, the "Book a visit" buttons and the logo useemergency,bookandindex. - The "Emergency?" pill shows from 1280 px wide; below that, emergency is in the mobile menu and in each page's content.
To add a page, create src/pages/<name>.astro wrapped in <Layout title="..." description="..." current="<name>">
and add a link to nav.ts.
Features#
Booking flow#
/book has five steps: visit type, clinician, date and time, details and confirmation. The code is initBooking()
in src/scripts/main.ts; the markup uses data-booking, data-step and data-step-indicator.
- Visit type comes from the radio buttons named
reason. Their values must match keys inREASONS. - Clinician is built from the visit type's
wholist, plus "First available". Each option shows the next opening. - Date and time.
slotsFor(reasonId, clinician, dateKey)builds start times everyBOOKING.slotStepminutes inside each clinician's working windows (practiceHOURSon theirdays, minusLUNCHon long days), keeping only times where the whole visit (minutes) fits before the window ends. Times before now plus the visit'snoticeare skipped, as areCLOSED_DATESand anything beyondBOOKING.daysAhead. With "First available", each time is offered by the first free clinician inwhoorder and the slot shows their name. The "First available" button on the calendar step picks the earliest time for you. - Demo availability.
BOOKING.demoBusyShare(0.55) marks a stable, pseudo-random share of slots as taken so the demo looks realistic. Set it to0when you load real availability. - Details are validated (see Forms). The confirmation shows a summary, a clear demo notice and an "Add to calendar (.ics)" button that downloads a tentative event converted from Boise time to UTC.
- Links in.
/book?reason=emergencypreselects a visit type;/book?clinician=reyespreselects a clinician when they offer the chosen visit type.
All times are practice times (PRACTICE.timeZone), because patients come in person. To move the practice, change
timeZone and zoneName.
Accessibility: each step's heading receives focus when it opens; the progress bar marks the current step with
aria-current="step"; the calendar uses a roving tabindex (arrow keys move by day and week, Home and End jump to
the start and end of the week, Page Up and Page Down move four weeks) and each day has a full label such as
"Thursday, October 15, 2026, 6 times available". Unavailable days use aria-disabled. Errors are announced next to
the step's buttons.
Cost estimator#
On /pricing, initEstimator() renders a row with a stepper for each entry in ESTIMATE_ITEMS, plus steppers for
the number of adults and kids in the plan. It compares:
- Pay as you go: quantity x price for every item.
- With Grin Club: yearly membership (
MEMBERSHIP.adultYearper adult,MEMBERSHIP.kidYearper child), items covered byincludedfree up to their yearly count per member, and everything else atMEMBERSHIP.discountoff.
The cheaper option gets a "Best value" tag and a sentence explains the saving. Results are in an aria-live
region; each stepper is a labelled group with "Fewer" and "More" buttons and a number input (0 to 32 per item, 0 to 8
members).
Payment-plan calculator#
Below the estimator ([data-plan]), visitors enter a cost between FINANCE.min and FINANCE.max (or use the
slider, or "Use my estimate from above") and choose a term from FINANCE.terms. The monthly payment uses the standard
amortised loan formula, or a simple division for 0% terms. The rates are deliberately labelled as made-up examples;
replace them with your financing provider's published terms or remove the section.
Emergency symptom guide#
/emergency has a three-question guide (initTriage(), data in TRIAGE):
- The main problem (one of
TRIAGE.symptoms). - Red flags (checkboxes; "None of these" clears the others). A flag listed as
"911"inTRIAGE.flagssends people straight to the "Call 911" outcome;"call"sends them to "Call us now". - A yes or no follow-up question from the symptom's
ask. Symptoms withask: nullgo straight to theiroutcome.
Outcomes are 911, call, today and self. Each shows its own steps plus the symptom's tip (except 911) and
the matching buttons (elements with data-outcome-cta listing the levels they belong to). The wording is
intentionally cautious and the page repeats that it's general information, not a diagnosis. If you change the rules,
keep a route to emergency services for breathing problems, spreading swelling and head injuries.
New patient form#
See Forms for validation and connection. The form (initIntake()) has five fieldsets
(data-intake-step) with Back and Next buttons, a progress bar, a "None of these" option that clears the other
medical history boxes, a typed signature that must match the first and last name from step 1, and a review screen
with an "Edit" button per section.
Filters#
Treatments and team use one generic filter ([data-filter-root] in main.ts): chips with data-filter and
aria-pressed, items with data-filter-item and data-tags, an optional select (data-filter-select) matched
against data-langs, an aria-live status message, live counts on each chip, an empty state and a reset button. The
chip value is kept in the URL using the parameter named by data-filter-param (category or role), so filtered
views can be shared.
Opening hours and open-now badge#
renderHours() highlights today's row in every [data-hours] list and adds "(today)" for screen readers. Every
[data-open-status] badge (OpenPill.astro) shows "Open now · until 5:00 pm" or "Closed · opens tomorrow 8:00 am",
calculated in the practice's time zone, and refreshes every minute. The home page's "Next checkup" card uses the
booking engine to find the first open checkup slot.
Tabs, accordion and carousel#
- The nervous-patient guide uses the ARIA tabs pattern (
role="tablist", arrow keys, Home and End). - FAQ items on
/treatmentsare native<details>elements styled with.faq. - The review carousel (
[data-carousel]) is a horizontally scrolling list with scroll snapping and previous and next buttons that disable at each end. It's keyboard-scrollable and has no autoplay.
Scroll reveals, motion and back to top#
Elements with .reveal fade up as they enter the viewport. Revealing one element also reveals everything before it,
so jumping with the End key or an anchor never leaves gaps. The inline script in Layout.astro adds the js class to
<html>, so content is only hidden when JavaScript can show it. Floating cards, the open-now pulse and the card hovers
stop for visitors who prefer reduced motion. The back-to-top button is created by src/scripts/back-to-top.ts; delete
that file, its import in Layout.astro and the .back-to-top styles in global.css to remove it.
Forms#
Every form is a demo: it validates in the browser and shows a success state, but nothing is sent, booked or stored until you connect it. The confirmation screens say so.
Validation is driven by attributes, handled by validateFields() and fieldMessage() in main.ts:
| Attribute | Rule |
|---|---|
required |
Must be filled, ticked or chosen (use data-required-msg for a custom message) |
type="email" |
Must look like an email address |
data-phone |
At least 10 digits |
data-past-date |
A valid date in the past (date of birth) |
minlength |
Minimum length |
data-match |
Must equal data-match-value (set by the patient form for the signature) |
data-validate |
Opts the field into validation |
Errors appear under the field (or under the group for radio buttons), are linked with aria-describedby, set
aria-invalid and focus the first problem.
| Form | Page | Validates |
|---|---|---|
Contact form (data-contact-form) |
/contact |
Name, email, optional phone, topic, message of 10+ characters |
Booking details (data-booking-form) |
/book |
First and last name, email, mobile, new or returning, consent |
New patient form (data-intake) |
/new-patients |
Name, date of birth, phone, email, contact preference, visit reason, last visit, how nervous, medical history, consent and signature |
Connecting the contact form#
The contact form is a good fit for a form service such as Formspree. Add the endpoint to the form in
src/pages/contact.astro:
<form class="mt-7 grid gap-5 sm:grid-cols-2" novalidate data-contact-form action="https://formspree.io/f/your-id" method="POST">Then, in the [data-contact-form] submit handler in src/scripts/main.ts, send it before showing the success state:
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!validateFields($$<Field>("[data-validate]", form))) return;
try {
const res = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } });
if (!res.ok) throw new Error("Send failed");
} catch {
toast("Sorry, that didn’t send. Please call us instead.");
return;
}
// ...existing code that hides the form and shows [data-form-success]
});Ask people not to include medical details in this form (the page already does).
Connecting the booking flow#
Bookings belong in your practice-management system (PMS). Most offer an online-scheduling API or an embeddable
booking widget. Either replace the booking section in book.astro with the widget, or keep this flow and:
- Load real open times from the PMS (through your own server, so API keys stay private) and return them from
slotsFor()in the same{ mins, who }shape. SetBOOKING.demoBusyShareto0. - In the
data-booking-formsubmit handler, POST the visit type, clinician, date, time and details to your server, which creates the appointment in the PMS. - Show step 5 only after the PMS confirms, and change the confirmation copy to match.
This template builds to static files, so "your server" is a separate API (or serverless function on your host). If
you'd rather keep it in this project, add an Astro adapter for your host and an API route under src/pages/api/.
Booking details are health information. Use HTTPS and a vendor that will sign a Business Associate Agreement (BAA) in the US or a Data Processing Agreement (DPA) under the GDPR in the EU and UK.
Connecting the new patient form#
The patient form collects health information, so a general-purpose form service or plain email isn't appropriate:
- US (HIPAA): post it only to a HIPAA-eligible form or intake service that signs a BAA, or use your PMS's own patient intake forms and link to them instead.
- EU and UK (GDPR): health data is special category data. Use a processor with a DPA, keep the explicit consent checkbox in step 4, collect only what you need and state your retention period in your privacy notice.
Replace the success handling in initIntake() (the submit listener on [data-intake]) with a POST to that
service. The answers are deliberately never written to localStorage or cookies; keep it that way.
SEO and sharing#
Each page passes title and description to <Layout> (the home page also passes a shorter ogTitle). The layout
outputs the meta description, a canonical link, Open Graph tags (og:title, og:description, og:type,
og:site_name, og:url, og:image), twitter:card and theme-color. 404.astro passes noindex, which outputs
<meta name="robots" content="noindex"> and no canonical.
Canonical, og:url and og:image are absolute URLs built from site in astro.config.mjs. Set your domain when
building:
SITE_URL=https://www.example.com pnpm buildor change the fallback (https://smile.example.com) in astro.config.mjs.
Suggestions: add @astrojs/sitemap (or a hand-written public/sitemap.xml) and a public/robots.txt that points
to it, and add Dentist structured data (schema.org JSON-LD) to index.astro with your real name, address, phone,
hours and geo-coordinates. The template doesn't include structured data because it would describe a fictional
practice.
Accessibility#
Built in:
- A skip link, landmarks (
header,nav,main,footer), one<h1>per page and a logical heading order. - Visible focus outlines on every interactive element and colour contrast that meets WCAG AA.
- An accessible mobile menu (
aria-expanded,aria-controls, Escape closes it and returns focus). - ARIA patterns: tabs,
aria-pressedfilter chips and calendar days,aria-currentfor the current page and booking step,aria-liveregions for filter counts, estimates and slot headings. - Forms with real labels, grouped radio buttons and checkboxes in
fieldsetandlegend, inline errors linked witharia-describedby, and focus moved to the first error or the next step's heading. - Decorative SVGs are hidden with
aria-hidden="true"; meaningful images have alt text. - Reduced motion turns off reveals, floating cards, the pulse and hover lifts.
When you edit:
- Keep exactly one
<h1>per page and don't skip heading levels. - Give every new input a
<label>and adddata-validatewhen it needs checking. - Check contrast when you change colours, especially white text on the 600 shades.
- Keep the emergency guidance visible and plain: it's the content people need most when they're stressed.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as pricing.html rather than pricing/index.html
(build.format: "file" in astro.config.mjs), and links point to /pricing without a trailing slash. Netlify,
Cloudflare Pages and GitHub Pages serve /pricing from pricing.html automatically; Vercel does with cleanUrls
(below). 404.html is served for unknown URLs by most hosts.
- 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/pricingservespricing.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/pricingtopricing.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, for example DEMO_BASE_PATH=/smile pnpm build. It becomes Astro's base, and every link and image written with url() includes it (including the clinician
portraits the booking flow shows). astro.config.mjs also sets compressHTML: false, so the spaces between words and
inline tags that start on a new line are kept.
Tailwind scans src/ for class names, including the strings in src/scripts/main.ts. Classes built from strings in
TypeScript must appear in full somewhere (for example "tag tag-mint"), not assembled from pieces.
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it. Serve the site over HTTPS,
especially once forms are connected.
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#
- The open-now badge looks wrong. It uses
PRACTICE.timeZoneandHOURSinsrc/data/practice.ts, not the printedHOURS_TABLEand not the visitor's clock. Check both and keep them in step. - The booking calendar shows no times. Check that the visit type's
whoclinicians work on days that are open inHOURS, that the visitminutesfit inside the opening hours, and that the dates aren't inCLOSED_DATESor beyondBOOKING.daysAhead. Late in the day, today may have no times because of the visit'snotice. - Times in the downloaded .ics file are an hour off.
wallToInstant()converts practice time to UTC usingPRACTICE.timeZone. Make sure it's a valid IANA zone such asAmerica/Denver. - Links or images break when the site is in a sub-folder. Write internal links and
public/paths withurl()(href={url("/pricing")}), never as a bare"/pricing", and build withDEMO_BASE_PATH. /pricingshows a 404 on the host but/pricing.htmlworks. The host doesn't map extensionless URLs to.htmlfiles. Turn on clean URLs (VercelcleanUrls) or switchbuild.formatto"directory".- Fonts look different offline. Fredoka and Nunito come from Google Fonts. Without a connection the rounded
system font or
system-uiis used. Self-host the fonts if the site must work offline. - The social preview has no image. Build with
SITE_URLset to your real domain soog:imageis an absolutehttps://URL toog-image.png. - A filter link opens unfiltered. The parameter value must match a chip's
data-filterexactly, for example/treatments?category=orthodonticsor/team?role=hygienist.
License#
MIT. Free for personal and commercial use, including client work. The full license is in LICENSE.txt in the
download. All artwork (the mascot, portraits, map, icons and social image) is original to this template and covered by
the same license. The practice, people, reviews and prices are fictional.