Business templates

Full design · Business

Dental clinic

A calm dental clinic app with two sides.

Live preview. Click around, it works.

About this template

A calm dental clinic app with two sides. Patients see treatments with sample prices, book from a real slot engine, fill in medical history and consent forms that save as a draft, and move or cancel their own visits. Reception gets a day sheet by practitioner or chair, requests to confirm, patient records with visit history and forms, recalls with reminders, and settings for practitioners, hours and appointment types.

What the agents are asked

The first message sent when you use this template. You can edit the plan before anything is built.

Adapt this dental clinic app to my practice. Keep both sides and the flow: treatments, online booking with the slot engine, new patient forms with the privacy note, My appointments, and the reception day sheet, requests, patients, recalls and settings. Change the brand, practitioners, opening hours, appointment types, prices and address to mine, keep every price marked as a starting price, keep the general information note and the urgent care wording, and do not add medical advice. When I want real accounts, store health information only on the server behind the row level security in schema.sql, never in the browser.

A calm dental clinic app with two sides. Patients read about treatments, book from a real slot engine, fill in their medical history and consent before the visit, and move or cancel their own appointments. Reception runs the day from a day sheet, confirms online requests, keeps patient records and works the recall list.

"Tessaly Dental" is an invented brand. The practitioners, patients, address, phone numbers and prices are samples, and every one is marked as such in the app. Phone numbers come from the ranges ACMA sets aside for fiction, and emails use example.com, so nothing reaches a real person.

General information, not advice

The app gives no dental or medical advice. Every patient screen carries a general information note, the urgent care wording is deliberately general ("If you are in severe pain or have facial swelling, call us or go to an emergency department", plus 000 if breathing or swallowing is affected), and prices are "from" prices marked Sample, never quotes. Keep it that way when you adapt it: a booking app is not the place for clinical guidance.

Art direction

A boutique hotel more than a surgery: a check up that feels like checking in. White plaster, walnut, late morning sun and one bright colour that means "book".

Palette. Three solid colours, as tokens at the top of styles.css, with a complete dark theme.

TokenLightDarkUse
--brand (peony)#CE3570#F0719AEvery way to book, the next free time, focus rings
--walnut / --ink#241A16#2C2019 / #F4EEE9Text, the urgent care band, key cards and the date blocks
--bg (plaster)#FFFFFF#16100DThe ground; true white, not cream

White text on peony is about 4.8 to 1; walnut text on the dark theme's peony is about 6 to 1. No gradients.

Type. Marcellus for display (a flared, inscriptional face with the feel of a hotel's lettered sign; one weight, no italic, tight tracking on large sizes) and Atkinson Hyperlegible Next for text, chosen for forms and for older eyes. Its dotted zero is deliberate: it keeps 0 and O apart in booking references. Body line height 1.6.

Layout, screen by screen.

  • Home: an asymmetric hero (the room bleeds off the right edge, the words sit on a white panel over it, the next free check up is a peony slip on the photo); urgent care as a walnut band with words only; "What we do" as a numbered index beside a sticky photograph; children as an oversized photo with a card over its corner; the team as a gallery hang of portraits, every second one dropped; the first visit as five numbered steps on a tinted band; hours beside the reception photo; a Book line above the footer.
  • Treatments: a sticky photograph column that changes with the category filter, and treatments as rows, not cards.
  • Book: practitioners are portrait cards; the summary shows the chosen practitioner's portrait, or the room. "Book with Nadia" links (#/book?prac=p1) carry the practitioner through.
  • Request sent: the reference as a walnut key card with the practitioner's photo.
  • My appointments: a sticky reception photo beside the list; each visit's date is a small walnut block.
  • Forms: a wide crop of the patient in the chair above the form.
  • Reception: the same type and colours, still dense; round photo thumbnails on the day sheet columns, requests and settings. practitioners.photo_url feeds them; with no photo the initials show instead.

Photography. One shoot: late morning sun through one tall window on the left, 3800K warm, 50mm at f/2.8, medium format grain, white plaster, walnut and brushed brass, with peony as the only strong colour. Prompts are in media.json. Files live in public/template-media/dental-clinic/ and are referenced only as /template-media/dental-clinic/<name>.webp.

NameSizeWhere
treatment-room.webp1600 × 1211Poster of the Home hero video; booking summary; Treatments, General
hygienist-hands.webp1000 × 1321Home, "What we do" (sticky); Treatments, All and Preventive
patient-chair.webp1000 × 1241Home, first visit; Treatments, Cosmetic; Forms
reception.webp1000 × 667Home, hours and getting here; My appointments
child-first-visit.webp1000 × 667Home, children; Treatments, Children
portrait-nadia.webp, portrait-callum.webp, portrait-imogen.webp, portrait-theo.webp700 × 869Team, booking step, key card, reception thumbnails

Video. room-loop.mp4 (1280 wide, 5 seconds, about 370 KB, H.264, no audio), made from treatment-room (see videos in media.json): sunlight drifts across the wall, the plant sways, a slow push in, no people. It is the Home hero: autoplay muted loop playsinline preload="metadata", the room photograph as its poster, aria-hidden="true". Under reduced motion it is rendered without autoplay and stays on the poster. It plays only while on screen and pauses when the tab is hidden (an IntersectionObserver and visibilitychange, in wireVideos).

Motion. Each movement has one job, and all of it sits behind prefers-reduced-motion.

  • The one orchestrated moment, first load of Home: the room opens like a door (a clip-path: inset() from a vertical slit, with a slight scale settling), the headline arrives line by line, then the lead, the buttons, and last the peony "next check up" slip slides in. It runs once per page load; any later paint ends it.
  • First load of any other screen: the heading block settles in a short stagger.
  • Screen changes: document.startViewTransition cross fade (feature detected; the top bar and tab bar keep their place). Rerenders inside a screen never animate.
  • Scroll: animation-timeline: view() reveals inside @supports, with an IntersectionObserver fallback at 0.2.
  • Photographs: a clip-path reveal with a slight scale, once, when a fifth is on screen.
  • Sticky photo columns on Home, Treatments and My appointments (desktop).
  • Hovers 160 to 180 ms, eased, no bounce.

Files

FileWhat it is
index.htmlShell: demo strip, top bar with the "Viewing as" switch, sidebar (reception, desktop), bottom tab bar (phone), the sheet dialog, the toast. One <script type="module">.
styles.cssTokens first (plaster, walnut and peony, with a complete dark theme), then components, then motion. Marcellus for display, Atkinson Hyperlegible Next for text.
script.jsOne plain ES module, no dependencies. Sections: helpers, icons, clinic configuration, sample data, store, slot engine, router, patient screens, clinic screens, sheets, events, boot.
schema.sqlPostgres tables, row level security, grants, the booking functions, the setup seed, and a rehearsal block.
template.jsonCatalogue entry.
media.jsonThe photograph and video prompts, one shoot. The files are generated into public/template-media/dental-clinic/.

No build step. Serve the folder with any static server and open index.html.

Screens

"Viewing as: Patient / Reception" in the top bar switches sides. In demo mode anyone can switch; in a real app the reception side sits behind staff sign in.

Patient side

RouteScreen
#/Home. Hero with the next free check up (from the slot engine, one tap to book it), the urgent care panel with the next urgent appointment, the four treatment categories, the team, what happens at a first visit, opening hours (today highlighted), parking and access.
#/treatmentsTreatments. General, preventive, cosmetic and children. Each has what it involves, how long it takes and a "from" price marked Sample, and a Book button when it can be booked online. Filter chips by category (?cat=).
#/bookBook an appointment. New or existing patient, reason (only types allowed online, and for new patients only types they can book), practitioner or First available, a 21 day date strip with free time counts, slots grouped Morning, Afternoon and Evening, then details. New patients give name, date of birth and mobile; existing patients are found by last name and date of birth. Sends a request that reception confirms. Accepts ?type=, ?date= and ?prac= (the team's "Book with" links; a practitioner who does not offer the chosen visit falls back to First available).
#/formsNew patient forms. Medical history and consent in one form with proper labels: about you, conditions (with None of these), medicines, allergies, pregnancy, smoking, teeth and comfort, three consent statements and a typed signature. Saves as a draft while typing, validates with an error summary that links to each field, and shows a plain privacy note.
#/appointmentsMy appointments. Look up by booking reference and last name (sample: TD-2417, Harlow). Coming up, with Reschedule (slot picker in a sheet, back to "waiting for the clinic") and Cancel (reason, confirmed in the sheet). Inside the notice window both are disabled with the clinic's phone number. Next check up due, past visits.

Reception side

RouteScreen
#/clinicDay sheet. By practitioner or by chair (?by=chair), any date (?date=). Figures for the day (appointments and first visits, chair time booked, arrived or done, to confirm), a time grid with the lunch break and a now line, and blocks coloured by practitioner: dashed for requests, outlined for arrived, faded for completed, a warning mark when the patient's form lists health notes. Phones get an agenda list instead of the grid. Tap a block to confirm, mark arrived, complete (sets the next recall), record did not attend, move chair, change time or cancel.
#/clinic/requestsRequests to confirm. Online bookings with the patient's note, new patient flag and form status. Confirm, offer another time, or decline with a message.
#/clinic/patientsPatients. Search by name, phone or email; filters for new patients, recall overdue and forms to review. Last visit, next visit, recall due, form state.
#/clinic/patients/:idPatient record. Health notes from their form (marked as what the patient told us, not a clinical record), coming up, visit history, recall (edit due date and interval, send a reminder), forms (open, mark reviewed), and staff only reception notes.
#/clinic/recallsRecalls. Overdue, due in 30 days, due in 31 to 90 days, booked. Send reminder per patient or to everyone in the group not reminded in the last 14 days (demo: recorded, nothing sent), or book straight from the row.
#/clinic/settingsSettings. Clinic details and the online change window, opening hours and breaks (validated), practitioners (role, chair, days, taking new patients), appointment types (length, from price, who, online, new patients), and demo data: remove sample data or reset.

Data model

The script's data layer is one object, store, holding tables with the same names and columns as schema.sql:

clinic_settings    { id: 'clinic', name, phone, email, address, timezone, cancellation_hours }
chairs             { id, name, sort, active }
practitioners      { id, name, title, bio, chair_id, works_on[0..6], color, accepts_new, active, sort, photo_url }
clinic_hours       { id, weekday, is_open, opens, closes, break_start, break_end }
appointment_types  { id, name, category, duration_min, price_from_cents, price_note, online, new_patients,
                     practitioner_ids[], description, involves[], note, sort }
patients           { id, user_id, first_name, last_name, dob, phone, email, sms_reminders, created_at }
patient_notes      { id, patient_id, body, updated_at }                         staff only
appointments       { id, ref, patient_id, practitioner_id, chair_id, type_id, starts_at, ends_at,
                     status (requested | confirmed | arrived | completed | cancelled | declined | no_show),
                     source (online | phone | reception), patient_note, cancel_reason, created_at }
intake_forms       { id, patient_id, status (draft | submitted | reviewed), answers {}, consent {},
                     signed_name, signed_at, submitted_at, reviewed_at, reviewed_by }
recalls            { id, patient_id, due_on, interval_months, reason, status (due | done | paused) }
recall_reminders   { id, recall_id, patient_id, channel (sms | email), sent_at, sent_by }  append only

photo_url is new in this version: demo mode fills it with the sample portraits, and schema.sql adds the column (with an add column if not exists for older databases) but seeds it empty on purpose, so a real clinic never shows invented staff. Set it to the clinic's own photos; with no photo the app shows initials.

Every sample row carries sample: true (a demo only field, not in the schema). The saved data also carries sample_version, checked against SAMPLE_VERSION in script.js on every load: when they differ, the sample patients, appointments, forms, recalls and reminders are regenerated, while everything the visitor made themselves is kept (a sample patient the visitor has already booked against is kept too, so nothing they did is left pointing at a patient that no longer exists). Bump SAMPLE_VERSION whenever seed() changes, so a returning visitor is not left looking at stale samples. Demo rows only: device in localStorage remembers which patient this browser is, standing in for the signed in user.

The slot engine (slotsFor) offers 15 minute starts inside opening hours, outside the break, on days the practitioner works, where neither the practitioner nor their chair is busy, at least an hour ahead for patients. It reads only store.busyTimes(from, to): intervals with no patient details, the same shape as the busy_times function. In production the engine stays in the browser for speed, and the server checks again: assert_bookable() validates the time, and two exclusion constraints on appointments make a double booking impossible even when two people press Request at the same moment.

What is demo or simulated

  • Sample data, dated around today and moved forward in whole weeks when you come back, so the day sheet is always live: 22 named patients with stories (allergies, a joint replacement, a very anxious patient, children with parents noted), about 400 generated ones, three weeks of appointments, forms, recalls and reminders. Settings, Demo data removes it in one step, or resets everything.
  • Everything you do is kept in this browser's localStorage (every access wrapped in try/catch; if storage is blocked the app still works for the session and says so).
  • No texts or emails are sent. Confirmations, declines and recall reminders are recorded and labelled "Demo".
  • No accounts. Existing patients are matched on last name and date of birth; My appointments uses reference and last name. Both stand in for sign in and are not secure enough for production.
  • No payments. The subject does not take money online; fees are paid at the clinic.

Health information: how to run this for real

Health information is sensitive information under the Privacy Act 1988 (Cth). In general terms, and this is general information rather than legal advice:

  • Any business that provides a health service and holds health information is covered by the Australian Privacy Principles, whatever its turnover. The small business exemption does not apply to health service providers.
  • Collect only what you need, with consent (APP 3), and tell people why at the time (APP 5). The privacy note on the forms screen is the collection notice; replace it with the clinic's own wording and link its privacy policy (APP 1).
  • Use it for the care it was collected for (APP 6). Reminders are fine; marketing from health data is not.
  • Keep it secure (APP 11), let people see and correct it (APPs 12 and 13), and know where it is stored if a provider is overseas (APP 8). Choose an Australian region for the database and storage.
  • Data breaches that are likely to cause serious harm must be notified to the people affected and the OAIC under the Notifiable Data Breaches scheme.
  • Some states and territories have their own health records laws, and dental boards and professional bodies set record keeping and retention expectations. Check them for the clinic's state. The OAIC publishes guidance for health service providers.

What that means for this template:

  1. Store health information only on the server. Patients, appointments, forms, recalls and notes live in the Supabase tables in schema.sql, with row level security on every table. A patient reads only their own rows; staff are recognised by app_metadata.clinic_role, which only the service role can set.
  2. Never put health information in localStorage in production. Demo mode does it so the preview works; the real app must not. Remove readLS/writeLS for data (keep them only for the theme preference), keep the form draft in memory, and save drafts to intake_forms (status draft) through the signed in user. localStorage is readable by any script on the page, survives sign out and is shared by everyone who uses that computer.
  3. Sign in before anything personal. Use Supabase Auth (email magic link or SMS one time code). Booking can start anonymously; ask the patient to sign in at the details step. Linking an account to a record reception created (matching name, date of birth and phone) belongs in a server function after the patient proves the phone or email, never in the browser.
  4. Keys stay on the server. The anon key is public by design; the service role key, SMS and email provider keys live only in server functions.
  5. Keep the clinical record separate. This app handles bookings, intake forms and recalls. Clinical notes, charts and x-rays belong in the clinic's practice management system.

Plugging in Supabase

store is the only place that touches persistence. Replace it with calls through a Supabase client, and give the screens the same answers:

import { createClient } from '@supabase/supabase-js';
const db = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); // anon key only, never the service role key

const store = {
  data: {},
  async load() {
    // Setup is public; patient tables return only what RLS allows this user to see.
    for (const t of ['clinic_settings', 'chairs', 'practitioners', 'clinic_hours', 'appointment_types']) {
      const { data, error } = await db.from(t).select('*');
      if (error) throw error;
      this.data[t] = data;
    }
    for (const t of ['patients', 'appointments', 'intake_forms', 'recalls']) {
      const { data, error } = await db.from(t).select('*');
      if (error) throw error;
      this.data[t] = data;
    }
  },
  all(t) { return this.data[t] || []; },
  get(t, id) { return this.all(t).find((r) => r.id === id) || null; },
  async busyTimes(from, to) {
    const { data, error } = await db.rpc('busy_times', { p_from: from, p_to: to });
    if (error) throw error;
    return data;
  },
  async update(t, id, patch) {
    const { data, error } = await db.from(t).update(patch).eq('id', id).select();
    if (error) throw error;
    if (!data.length) throw new Error('Not saved: you may not have access to that record.'); // RLS refuses silently
    return data[0];
  },
};

Then route the patient actions through the functions, not direct writes:

Screen actionReal call
Request this appointmentdb.rpc('request_appointment', { p_type_id, p_practitioner_id, p_starts_at, p_note })
Reschedule (patient)db.rpc('reschedule_my_appointment', { p_id, p_starts_at, p_practitioner_id })
Cancel (patient)db.rpc('cancel_my_appointment', { p_id, p_reason })
Mark form reviewed (staff)db.rpc('mark_intake_reviewed', { p_id })
Confirm, arrive, complete, move chair (staff)db.from('appointments').update(...) (staff policy)
Send remindera server function (Supabase Edge Function send-recall-reminder): checks the caller is staff, sends through the SMS or email provider with keys held server side, then inserts into recall_reminders with the service role

The slot engine becomes async because busyTimes is a network call: fetch the 21 day window once when the picker opens, then compute slots locally from that result. Show the database's error message when a request fails; the functions return plain sentences ("That time was just taken. Please choose another.").

Before applying `schema.sql` to a live project: run it on a staging project, fill in the two user ids in the rehearsal block at the end, run it as authenticated, check every refusal returns zero rows or 42501, and roll back. The schema file was written to this standard but has not been executed against a database as part of building the template.

Left out on purpose

  • Payments and deposits, health fund claiming (HICAPS style) and invoices: they need a payment provider and a practice management integration.
  • Clinical notes, tooth charting and x-rays: they belong in the practice management system.
  • Waitlists, family bookings (one parent booking several children in a row) and multi location support: good next steps once real accounts exist.
  • Real SMS and email: each needs a provider account and a server function; the demo records instead.