Overview#
Mend is a multi-page website template for an independent physiotherapy and sports injury clinic. The demo
is Lavant Physiotherapy, a fictional clinic at 48 Whyke Road in Chichester, West Sussex, with five
physiotherapists and a sports massage therapist. It is built with plain HTML, Tailwind CSS v4 and one
vanilla JavaScript file (assets/js/main.js), with no other dependencies. This is version 1.1.0 (see
template.json and CHANGELOG.md). Live demo:
freeframe.dev/templates/mend.
The design is a clinician's notepad: biro-blue ink on warm paper, pale blue ruled lines, a red margin rule
down the left of each section with index numbers (§ 01) and margin notes, and a yellow highlighter for
whatever is selected. The way in is the home page's body map, "Where does it hurt?".
The demo never goes stale: opening hours, bank holidays, the booking diary, "next available appointment" and each clinician's "in clinic today" badge are worked out from today's date in UK time, so there are no fixed dates to update.
Quick start#
- Unzip the download.
- Open
index.htmlin a browser. Everything works from the file system, with no server or build step. - To preview over HTTP instead (closer to a real host), serve the folder, for example
npx serve .orpython3 -m http.server, and open the address it prints.
You only need Node.js (version 24 recommended) if you want to rebuild the CSS after adding Tailwind classes; see Building and deploying.
Pages#
| File | Purpose | Notable sections and features |
|---|---|---|
index.html |
Home | "Where does it hurt?" body map (front and back) with a notes panel for the chosen area and a booking link that carries ?area=, open-now line, "when not to wait", a first-visit timeline, a seven-day diary strip of free initial assessments, the treatment index, patient stories, team and a visit section with hours |
treatments.html |
Treatments | Ten treatments as ruled entries with art, length, price, what's included, who offers them and the next free slot; filters in the left rail (?for=) |
conditions.html |
Conditions | Index of eight areas, then one section per area (?area= jumps to it) with a locator figure, common problems, suggested appointments, matching exercises and red flags; a sticky side index marks the area in view |
exercises.html |
Exercise library | 18 exercises filtered by area, level and equipment (?area=, ?level=, ?equipment=), a saved "my plan" with daily ticks, a rep and hold counter |
team.html |
Our team | A board of six portraits, then each clinician's profile with special interests, working days, "in clinic today", what they offer and their first free slot; filter by treatment (?treatment=) |
book.html |
Book online | The clinic's next seven days, then four steps: treatment, clinician, day and time, details with health screening; live summary; confirmation with a reference (?treatment=, ?clinician=, ?area=, ?day=) |
prices.html |
Prices and insurance | Self-pay table, packages with savings, a cost estimator (?treatment=), insurer checker (?insurer=), with a sticky side index |
contact.html |
Contact | Contact details beside the map, hours with bank holiday notice, contact form (?topic=), getting here, FAQ |
404.html |
Not found | Friendly message and links |
Project structure#
mend/
index.html ... contact.html, 404.html the pages (header and footer repeated in each)
assets/
css/style.css compiled Tailwind CSS (generated by npm run build, committed)
js/main.js all clinic data and interactions
img/
favicon.svg logo mark: a page of notes with a red margin and a serif L
treatment-room.svg treatments hero: couch, wall chart of the spine, foam roller and kettlebell
exercise-sheet.svg exercises hero: a home exercise sheet on a clipboard with a stopwatch
receipt.svg prices hero: a receipt, card reader and appointment card
clinic-front.svg home "Visit us": the Georgian house with the green door
spine-study.svg lumbar spine and pelvis (conditions page, "Pain, explained simply")
hamstring.svg 404 illustration
map.svg illustrated map (contact)
team-*.svg six illustrated portraits
og-image.svg social image source (1200 x 630)
og-image.png social image used by og:image
src/input.css Tailwind source: theme tokens and components
package.json npm scripts to rebuild the CSS
README.md short introduction
DOCS.md this guide
CHANGELOG.md version history
LICENSE.txt license (added to the download)The body map is inline SVG in index.html. The treatment drawings (ART), the exercise figures
(figureSVG()) and the small locator figures on the conditions page (locatorSVG()) are drawn by main.js,
so they don't have separate files.
Customising#
Brand and colours#
All design tokens live in the @theme block at the top of src/input.css. The ones that matter:
| Token | Used for |
|---|---|
--color-biro-* |
The ink colour: buttons (biro-600), links, headings (biro-900 = --color-ink), the dark bands and footer (biro-900), pale fills (biro-50) |
--color-margin-* |
The red margin rule and "don't wait" warnings (margin-50 fill, margin-500 rule, margin-600/700 text) |
--color-hi, --color-hi-soft |
Highlighter: selected body map region, pressed chips, today in the diary, the .hl swipe behind headline words |
--color-ink, --color-body, --color-muted |
Heading, body and secondary text |
--color-paper, --color-paper-dark, --color-sheet |
Page background, warm panels and white sheets |
--color-rule, --color-rule-strong |
Ruled lines and borders |
The component layer in the same file holds the few repeated pieces: .wrap (page width), .notes and
.rail (the two-column notes layout with the red margin rule), .ruled (ruled-paper background), .idx,
.kicker and .note (mono index numbers, labels and margin notes), .hl, buttons (.btn-ink, .btn-line,
.btn-paper), .chip, .field, .tag, the booking pieces (.choice, .day, .slot, .step, .yesno), the
body map (.region), .side-index and .timeline. Everything else is Tailwind utilities in the markup.
To change the ink colour, edit the biro scale and rebuild:
@theme {
--color-biro-600: #2d5a4a; /* buttons and links */
--color-biro-800: #1f4034; /* hover */
}Keep biro-600 at a contrast of at least 4.5:1 against white, because button text is white, and keep
text on the highlighter in --color-ink. The SVG files
in assets/img/ use hex colours directly; search and replace them if you change the palette a lot.
Typography#
Headings use Piazzolla, body text uses Reddit Sans, and index numbers, labels and margin notes use
Reddit Mono, loaded from Google Fonts with one <link> in the <head> of every page:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Piazzolla:ital,opsz,wght@0,8..30,400..600;1,8..30,400..500&family=Reddit+Mono:wght@400;500;600&family=Reddit+Sans:wght@400;500;600;700&display=swap">To swap fonts, change that link on every page and the --font-display, --font-sans and --font-mono
tokens in src/input.css, then rebuild the CSS.
Content#
Page text is plain HTML in each file. Everything the scripts use is a constant at the top of
assets/js/main.js:
| Constant | What it holds |
|---|---|
CLINIC |
Name, time zone (Europe/London) and phone number |
HOURS |
Opening hours by weekday (0 = Sunday), 24-hour strings; null means closed |
CLOSE_ON_BANK_HOLIDAYS |
true to close on bank holidays in England and Wales |
CLOSED_EVERY_YEAR |
Dates closed every year (Christmas Day, Boxing Day, New Year's Day) |
EXTRA_CLOSED |
One-off closures, { date: 'YYYY-MM-DD', label: '...' } |
TREATMENTS |
Treatments: name, minutes, price, tags, blurb, what's included, who it's for |
TREATMENT_TAGS |
Filter names on treatments.html |
CLINICIANS |
People: name, role, the treatments they offer, shifts by weekday |
BOOKING |
Diary settings (see Booking diary) |
BODY_AREAS |
Body map areas: label, intro, conditions, suggested treatments, exercise filter, red flags |
EXERCISES, EXERCISE_AREAS, LEVELS, EQUIPMENT |
Exercise library and its filters |
PACKAGES, PACKAGE_MONTHS |
Packages on prices.html |
INSURERS |
Insurers: name, direct or claim, whether they need an authorisation number or GP referral |
The opening hours are also written out in the HTML (every dl[data-hours] in the footer, the home page's
"Visit us" section and the contact page). If you change HOURS, change those lists too.
Adding a treatment takes three steps: add an entry to TREATMENTS, add a drawing to ART with the same
key (a 96 x 96 SVG fragment), and add the key to the treatments list of each clinician who offers it.
var TREATMENTS = {
// ...
hydro: {
name: 'Hydrotherapy', minutes: 45, price: 55, tags: ['surgery', 'pain'],
blurb: 'Exercise in a warm pool, where the water takes some of your weight.',
includes: ['Sessions at a partner pool', 'A programme to carry on alone'],
who: 'After joint replacements and for widespread pain.'
}
};Adding a clinician: add an entry to CLINICIANS, a portrait at assets/img/team-<id>.svg (used by the
booking form) and a profile on team.html with data-clinician="<id>" (and a tile in the portrait board at
the top). The script fills in the profile's
[data-days], [data-in-clinic], [data-offers] and [data-clinician-next] elements.
pemberton: {
name: 'Ada Pemberton', first: 'Ada', role: 'Physiotherapist',
treatments: ['initial', 'followup'],
shifts: { 2: ['09:00', '17:00'], 4: ['09:00', '17:00'] }
}Body map areas, exercises and insurers follow the same pattern; the comments above each constant list the fields.
Images and artwork#
All artwork is SVG drawn for this template: no photos and no external images.
- Portraits (
team-*.svg) are 240 x 240. Replace them with square photos of the same name; they are shown in squares with a thin border. - The hero drawings (
treatment-room.svg,exercise-sheet.svg,receipt.svg) are 640 x 480 and sit bottom-aligned in the right half of each hero;clinic-front.svgis 640 x 460 andmap.svg640 x 420. Keep similar proportions or adjust thewidthandheightattributes on the<img>tags. - The body map is inline SVG in
index.html. Each region is a<g class="region" role="radio" data-area="...">whose key must matchBODY_AREAS. It holds the region's shapes (.r-fill, drawn over both arms or legs where it applies), a pin (.r-pin,.r-pin-dot), a leader line and the label (.r-label, hidden below 640px). The figures underneath are plain outlines in anaria-hiddengroup, so nothing that looks clickable is decoration: every pin and shaded region is a control. - Exercise drawings come from the
poseof each exercise: joint coordinates in a 160 x 100 box (floor at y = 90) for the torso, head and near and far arm and leg, plus optionalprops(equipment),ghost(the start position, drawn dashed),focus([x, y, r], a highlighter mark on the working muscles) andmarks(red-pencil arrows). - The social image is
assets/img/og-image.png(1200 x 630), made fromog-image.svg. After editing the SVG, regenerate the PNG (for example with Inkscape, orrsvg-convert -w 1200 og-image.svg > og-image.png). - The favicon is
assets/img/favicon.svg.
Navigation, header and footer#
The header (logo, numbered navigation, phone, "Book online" and the mobile menu) and the footer (with
"When not to wait", hours, address and links) are repeated in every HTML file. To
add a page, copy an existing page, then add a link to the desktop <nav aria-label="Main">, the mobile
<nav id="mobile-menu"> and, if you like, the footer's "Useful links" on every page. Mark the current page
with aria-current="page" on its links.
Features#
Opening hours and bank holidays#
renderHours() highlights today's row in every [data-hours] list, fills the open-now line
([data-open-status]: "Open now · until 8 pm" or "Closed now · opens tomorrow at 7:30 am") and writes the
next closure into each [data-holiday-note] element. The number in data-holiday-note="21" is how many
days ahead to look. It runs again every minute.
bankHolidays(year) works out the bank holidays in England and Wales for any year: New Year's Day, Good
Friday and Easter Monday (from the date of Easter), the early May, spring and summer bank holidays,
Christmas Day and Boxing Day, including the substitute weekdays when a holiday falls at a weekend.
closedReason(key) combines those with CLOSED_EVERY_YEAR and EXTRA_CLOSED. For Scotland or Northern
Ireland, add the extra days to bankHolidays(); for a one-off extra bank holiday, use EXTRA_CLOSED.
All times are in Europe/London (CLINIC.timeZone), whatever the visitor's own time zone.
Booking diary#
The diary is built from data, not stored appointments:
- For each clinician who offers the treatment,
shiftOn()takes their shift for that weekday and clamps it to the opening hours (no shift on closed days). slotsFor()steps through the shift everyBOOKING.slotStepminutes, keeps starts where the whole appointment fits, skips anything sooner thanBOOKING.noticeMinutesfrom now and marks a share of slots as taken (BOOKING.demoBusyShare) using a stable hash, so the diary looks lived-in.firstAvailable()scansBOOKING.daysPerPage * BOOKING.pagesdays (14 by default) for the first free start.
| Setting | Default | Meaning |
|---|---|---|
slotStep |
30 |
Minutes between start times |
daysPerPage |
7 |
Days shown at once in the day picker |
pages |
2 |
Pages of days (the diary covers 14 days, today included) |
noticeMinutes |
120 |
Earliest bookable start after now |
demoBusyShare |
0.45 |
Share of slots shown as taken; set to 0 when you connect a real diary |
refPrefix |
'LP' |
Prefix of booking and message references |
Because everything is worked out from today, the diary always rolls forward. Any element with
data-next-slot="<treatment>" shows the next free slot ([data-next-slot-text], [data-next-slot-who])
and its link is pointed at that treatment and clinician; the home and treatments pages use
data-next-slot="initial".
Diary strips. renderDiaryStrips() fills every <ol data-diary-strip> with the next seven days. With a
treatment key (data-diary-strip="initial" on the home page) each open day shows how many times are free
and the first one, and links to book.html?treatment=initial&day=YYYY-MM-DD; with data-diary-strip="hours"
(the booking page) it shows the opening hours. Closed days are hatched and say why: "Closed on Sundays",
"Christmas Day", "The spring bank holiday" and so on. It runs again every minute.
To show real availability, replace slotsFor() with a call to your booking system's API, or link the
"Book online" buttons to your practice management software's booking page instead.
Booking form#
book.html is one <form data-booking> with four <fieldset data-step> steps:
- Treatment: radio cards built from
TREATMENTS. - Clinician: "First available" plus everyone who offers the treatment, with their days and next slot.
- Day and time: seven days at a time (Earlier and Later move between the two pages). Days with no times are disabled and labelled Closed (with the reason for screen readers, such as "closed on Sundays" or "closed for Christmas Day"), Off (nobody working) or Full. Times are grouped into morning, afternoon and evening; with "First available" each time shows who it's with.
- Your details: name, email, phone, area (filled from
BODY_AREAS), notes and the health screening.
The step list and the summary sit in the left rail (above the form on phones) and update as you go. URL
parameters prefill it: ?treatment= (a TREATMENTS key, which skips to step 2), ?clinician= (a
CLINICIANS key, used if they offer the treatment), ?area= (a BODY_AREAS key: it fills the area select
and, on step 1, shows a note and marks that area's suggested treatments with "Suggested for the knee") and
?day= (a YYYY-MM-DD date inside the diary, used with ?treatment=: it opens step 3 on that day). Values
are checked with own-key lookups and the date against the diary, so anything else is ignored.
Health screening. Four yes/no questions ([data-redflag="cauda" | "trauma" | "systemic" | "weakness"]) must be answered. Any "yes" shows [data-redflag-panel] with the advice from
REDFLAG_ADVICE (A&E, 999 or NHS 111) and disables the submit button until the answer is changed. The
optional checkboxes (pregnancy, recent surgery, blood thinners, pacemaker) add notes for treatments where
they matter, such as shockwave or acupuncture. Review this wording with your own clinical team: it is
general guidance, not a triage tool.
On submit, the form shows [data-confirm] with the first name, a reference like LP-4K7Q, the appointment,
price and when reception will confirm (the end of the next open day, from confirmBy()).
Keyboard and screen reader notes: the step list uses aria-current="step", focus moves to each step's
legend, errors are announced with role="alert", and the yes/no answers are real radio buttons.
Body map#
The home page opens with "Where does it hurt?": an inline SVG with front and back line drawings. The SVG is
a role="radiogroup" ([data-body-map]); each region is a role="radio" with aria-checked and an
aria-label that matches its visible label. Only the selected region is in the tab order: arrow keys move
between areas (Home and End jump to the first and last), and Enter or Space selects. The buttons under the
map ([data-area-btn]) do the same for touch screens and small displays, where the labels inside the SVG
are hidden; on narrower screens choosing one scrolls to the notes.
Choosing an area fills the notes panel ([data-area-panel]) from BODY_AREAS: the title, intro and three
common problems, "Book an assessment" (book.html?treatment=initial&area=<area>) and "Read about the knee"
(conditions.html?area=<area>). ?area= selects an area on load (default lower-back) and is updated as
you choose. Regions are shaded on hover and turn highlighter yellow when chosen (the yellow multiplies over
the line drawing, so the anatomy stays visible).
Conditions pages#
conditions.html renders one <article data-area-section> per BODY_AREAS entry into
[data-area-sections]: the intro, a locator figure with the area marked, common problems (how they often
feel and what usually helps), the suggested appointments (booking links with ?area=), a link to the
matching exercises and "Don't wait for physio if…" with the red flags. The index at the top and the side
index ([data-area-link]) link to #<area>; an IntersectionObserver marks the area in view with
aria-current="true". ?area= scrolls to that area on load.
Exercise library and my plan#
exercises.html renders EXERCISES as cards with a drawing, area, level, equipment, dose and a "How to do
it" disclosure. Filters: the area chips (?area=), level (?level=) and equipment (?equipment=), all
validated against EXERCISE_AREAS, LEVELS and EQUIPMENT.
"Add to my plan" ([data-plan-toggle], with aria-pressed) saves the exercise to the plan on the right.
The library is a grid of ruled sheets; cards in the plan are marked "In my plan". Plan entries can be ticked off for today, moved up or removed; "Print" prints the page with the plan
(the counter is hidden in print) and "Clear plan" empties it. The plan holds up to PLAN_MAX (12)
exercises and is saved in localStorage (see Saved data).
Rep and hold counter#
The counter under the plan works with any exercise (plan exercises are listed first; "Start next
exercise" picks the first one not yet ticked today). For rep exercises, "+1 rep" counts towards the
target; for holds it runs a countdown, which can be paused. After each set it counts down the exercise's
rest seconds (or you can skip the rest), and when all sets are done it ticks the exercise off in your
plan. Progress is announced through a polite live region at the end of each set, not every second.
Team#
team.html profiles are static HTML; the script adds the weekly shift grid (Monday to Saturday), "In clinic
today, until 5 pm" or "Next in clinic tomorrow, from 9 am", the treatments each person offers and their
first free slot. The chips filter cards by treatment (?treatment=).
Prices, estimator and insurers#
prices.html builds the price table from TREATMENTS and the package list from PACKAGES (the saving is
price x sessions - package price). The table sits in a scrolling wrapper with tabindex="0",
role="region" and an aria-label, so keyboard users can scroll it if a long treatment name ever makes it
wider than the screen; keep those attributes if you add columns. The estimator multiplies the chosen treatment by the number of
sessions and, when "Use a package" is ticked, uses as many whole packages for that treatment as fit.
?treatment= preselects a treatment.
The insurer checker lists INSURERS and, for the one you pick, explains whether the clinic bills them
directly or you pay and claim, and whether an authorisation number or GP referral is needed. ?insurer=
preselects one.
Previewing another date#
Add ?now=YYYY-MM-DDTHH:MM (UK time) to any page to see it at another moment, for example
book.html?now=2027-12-24T16:30 to see the diary around Christmas. Invalid values are ignored.
Scroll reveals, mobile menu and back to top#
Elements with the reveal class fade in as they scroll into view (everything above them is revealed too,
so jumping down the page never leaves gaps). With prefers-reduced-motion, nothing animates. The mobile
menu button has aria-expanded and closes with Escape. The back-to-top button is created by the last block
in main.js; delete that block and the .back-to-top styles to remove it.
Forms#
| Form | Where | Validates |
|---|---|---|
| Booking request | book.html ([data-booking]) |
Treatment and time chosen; name, email, phone; all four screening questions answered and none "yes" |
| Contact | contact.html (#contact-form) |
Name, email (format), phone if given, message (at least 10 characters) |
Errors appear under each field and are linked with aria-describedby; the first invalid field gets focus.
The demo forms don't send anything until you connect them. The contact form then shows its
data-success-text ("Thanks, {name}. Your message is with us (reference {ref})...").
To connect a form service such as Formspree, give the contact form an action and send it from the submit
handler in main.js (look for "Connect here"), showing the success message only when the service answers
OK:
<form id="contact-form" action="https://formspree.io/f/your-id" method="POST" data-validate novalidate ...>fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } })
.then(function (res) {
if (!res.ok) throw new Error('Send failed');
ok.textContent = msg;
ok.hidden = false;
form.reset();
})
.catch(function () { ok.textContent = 'Sorry, that didn’t send. Please call us.'; ok.hidden = false; });The booking form has its own "Connect here" comment in the submit handler. Post new FormData(bookingForm)
along with the chosen slot (state.slot: date key, minutes and clinician id) to your booking system or form
service, and show [data-confirm] only on success. Health information is sensitive: use a service that is
suitable for it under UK GDPR, and keep the clinic's privacy notice up to date.
Saved data (localStorage)#
| Key | Shape | Validated on read |
|---|---|---|
mend-plan |
{ "items": ["glute-bridge", "calf-raise"], "done": { "date": "2026-10-21", "ids": ["calf-raise"] } } |
Only known exercise ids, no duplicates, at most PLAN_MAX; ticks are kept only if date is today (UK time) and the id is in the plan |
All access goes through one safeStorage object. When localStorage is unavailable (sandboxed iframes,
strict privacy settings), it falls back to memory, so the plan still works for the page view. To reset,
use "Clear plan" or remove the key in the browser's developer tools.
SEO and sharing#
Each page has its own <title>, meta description and Open Graph tags in the <head>. Before going live:
- Set
og:imageto an absolute URL, for example<meta property="og:image" content="https://www.yourclinic.example/assets/img/og-image.png">, and addog:urlwith each page's address. - Add a
sitemap.xmlandrobots.txtat the site root. - Consider
PhysiotherapyorMedicalClinicstructured data (JSON-LD) with your address, phone and opening hours.
Accessibility#
Built in: a skip link, landmarks, one <h1> per page, visible focus outlines, aria-current in the
navigation, an accessible mobile menu, a keyboard-operable body map (radio group with arrow keys, every drawn region a real control), focusable
scrolling regions, real
form controls with linked error messages, live regions for counts and the counter, and
prefers-reduced-motion support. Colours meet WCAG AA contrast.
When editing: keep one <h1> per page, give new inputs a <label>, keep aria-controls and
aria-labelledby pointing at ids that exist, and if you add body map areas, give each region an
aria-label.
Building and deploying#
The compiled CSS is committed, so you only rebuild when you change src/input.css or add new Tailwind
classes to the HTML or main.js:
npm install
npm run build # once
npm run watch # rebuild on every changeTo deploy, upload the folder to any static host: Netlify Drop, GitHub Pages, Cloudflare Pages or your own server. There is no server-side code. All links are relative, so it also works in a subfolder.
Troubleshooting#
- The diary shows times on a day we're closed. Add the date to
EXTRA_CLOSED, or checkHOURSand the clinician'sshifts(weekday 0 is Sunday). - A treatment has no times at all. Make sure at least one clinician lists its key in
treatments, and that their shifts are long enough for the treatment'sminutes. - Times are an hour out. All times are UK time. If your clinic is elsewhere, change
CLINIC.timeZoneandbankHolidays(). - New Tailwind classes have no effect. Rebuild the CSS (
npm run build). Classes used only inmain.jsmust be written out in full there. - The exercise plan disappears after reloading. The browser is blocking storage (private mode or a sandboxed preview); the plan then lasts only for the page view.
- The body map labels are missing on phones. That's intended: below 640px the labels inside the SVG are hidden, the drawing is cropped to the figures and the buttons under the map name the areas.
- A new body map region can't be reached with the keyboard. Give it
class="region" role="radio" tabindex="-1" data-area="<key>"and anaria-labelmatching its label, and add the key toBODY_AREAS.
License#
MIT. The code and all included artwork are free for personal and commercial use; see LICENSE.txt.
The fonts, Piazzolla, Reddit Sans and Reddit Mono, are licensed under the SIL Open Font License and served by Google Fonts.
Lavant Physiotherapy, its staff, patients, insurers and contact details are fictional. The health information is general and should be reviewed by a qualified clinician before you publish it.