Overview#
Bloom is a nine-page website for a hair salon and day spa. The demo brand is Bloom Hair & Spa, a fictional independent salon at 48 Orchard Lane, Bristol. It's built with plain HTML5, Tailwind CSS v4 and one vanilla JavaScript file with no dependencies. Highlights are a five-step booking wizard that works out real time slots from opening hours, staff working days and treatment length, level-based pricing (stylist, senior, director), a gift card builder with a live preview, filterable service and team pages, a gallery with saved looks and a lightbox, a before/after slider and a live "Open now" status. This guide covers version 1.0.3. Live demo: freeframe.dev/templates/bloom.
Quick start#
- Unzip the download.
- Open
index.htmlin a browser. Everything works from the file system, including booking, gift cards and saved looks. - For a local server instead (closer to production), run one from the folder:
npx serve .You only need Node.js 24 if you change the styles and rebuild the CSS (see
Building and deploying). The compiled assets/css/style.css is included.
Pages#
| File | Page | Notable sections and features |
|---|---|---|
index.html |
Home | Hero, signature treatments, before/after slider, team, reviews, gift cards, newsletter |
services.html |
Services and prices | Full menu by category, category filter with count, "Book" links per treatment |
team.html |
Team | Specialty filter, working days, pricing levels, "Book with" links |
book.html |
Book online | Five-step booking wizard with a running summary |
gift-cards.html |
Gift cards | Amount, design, message and delivery date with a live card preview |
gallery.html |
Gallery | Look filters, saved looks, lightbox with "Book with" link |
about.html |
About | Story, values and milestones |
contact.html |
Visit and contact | Map, opening hours with today highlighted, open-now status, contact form |
404.html |
Not found | Links back into the site |
Every page shares the header, footer (with an open-now badge and the newsletter form) and the back-to-top button.
Project structure#
bloom/
├── index.html … 404.html The nine pages (header and footer are repeated in each)
├── assets/
│ ├── css/style.css Compiled Tailwind CSS (generated from src/input.css)
│ ├── js/main.js All interactivity and the BLOOM data object
│ └── img/
│ ├── looks/ Gallery looks, one SVG per look id
│ ├── team/ Team portraits, one SVG per team member id
│ ├── before.svg, after.svg, salon.svg, map.svg
│ ├── favicon.svg
│ └── og-image.svg, og-image.png
├── src/input.css Tailwind source: @theme tokens and component styles
├── package.json Tailwind CLI scripts (build, watch)
├── template.json Template metadata (name, version, pages, features)
├── README.md Short introduction
├── CHANGELOG.md Version history
└── DOCS.md This guidemain.js is organised in numbered sections (listed in its header comment): data, helpers, header, reveal, opening
hours, before/after, services, team, gallery, booking, gift cards, forms and back to top.
Customising#
Brand and colours#
The design tokens are in the @theme block at the top of src/input.css. Tailwind turns each --color-* token into
utilities such as bg-shell, text-plum and border-line.
| Token | Default | Used for |
|---|---|---|
--color-shell |
#faf6f2 |
Page background |
--color-cream |
#fffdfb |
Cards |
--color-blush |
#f3e4dd |
Soft panels |
--color-rose |
#e8cfc4 |
Deeper panels |
--color-line |
#e3d3ca |
Borders |
--color-ink |
#2a2127 |
Text |
--color-mute |
#685a5f |
Secondary text (meets AA on shell, cream and blush) |
--color-plum, --color-plum-dark |
#7a3e5b, #5e2c45 |
Accent: buttons, links, selected states |
--color-sage |
#5a6954 |
Secondary accent, open-now badge |
--color-gold |
#a5804f |
Decorative details |
--color-error |
#a23a3a |
Form errors |
To change the accent:
@theme {
--color-plum: #2f5d62;
--color-plum-dark: #1f4044;
}Rebuild the CSS afterwards. The gift card designs (.gift-petal, .gift-sage, .gift-noir in src/input.css) and
the SVG artwork have their own colours; update them too, along with accent in template.json.
Typography#
Two Google Fonts are loaded by a <link> in the <head> of every page:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400..700;1,6..96,400..700&family=Jost:wght@300..600&display=swap">--font-displayis Bodoni Moda (headings, prices, the wordmark), used through thefont-displayclass.--font-sansis Jost (body text).
To swap a font, change the <link> on every page and the family name in @theme, then rebuild:
@theme {
--font-display: "Cormorant Garamond", "Didot", ui-serif, Georgia, serif;
}Content#
Page text is plain HTML. The data the scripts use is the BLOOM object at the top of assets/js/main.js:
| Key | What it holds | Used by the script for |
|---|---|---|
salon |
Name, address, phone, email, currency (ISO code, e.g. GBP), locale (e.g. en-GB) |
Price formatting in the booking wizard and gift cards; otherwise reference only (the pages have their own copy) |
hours |
Seven entries, day 0 (Sunday) to 6, with open/close as "HH:MM" or null when closed |
Open-now status, booking slots |
categories |
Service categories: id, label, blurb |
Services filter count label |
services |
id, cat, name, mins, price, desc, optional signature |
Booking durations, prices, summary |
levels |
stylist (×1), senior (×1.2), director (×1.4) with label and blurb |
Level-based pricing |
team |
id, name, role, level, cats, days, bio, specialties |
Booking stylists, availability, lightbox credit |
looks |
id, cat, title, by (team id), palette, kind |
Gallery lightbox |
lookCategories |
Gallery filter ids and labels | Lightbox category label |
giftAmounts, giftDesigns |
Preset amounts and designs | Design labels in the gift card preview |
The comment says the data is "generated from one source shared with the Next.js version". In the HTML template, the pages themselves are not built from it: the service menu, the booking checkboxes, the team cards, the gallery and the hours tables are written out in HTML. When you change the data, change the HTML to match.
To add a treatment, for example a fringe trim:
- Add it to
BLOOM.services:
{
"id": "fringe-trim",
"cat": "hair",
"name": "Fringe trim",
"mins": 15,
"price": 12,
"desc": "A quick tidy between cuts."
}- Add a row to the matching
data-service-groupsection inservices.html, with a link tobook.html?service=fringe-trim. - Add a checkbox to step 1 of
book.htmlinside the right category<fieldset>, copying an existing one:
<label class="option"><input type="checkbox" name="services" value="fringe-trim" class="option-input"><span class="option-body">…</span></label>The checkbox value must equal the service id; the script reads name, duration and price from BLOOM.services.
To add a team member, add an entry to BLOOM.team (days uses 0 for Sunday to 6 for Saturday; cats lists the
categories they can do), add assets/img/team/<id>.svg (the booking wizard loads the portrait from that path), and add
their card to team.html with data-team-member data-cats="hair colour".
Contact details are repeated in every page and in BLOOM.salon. Search and replace +44 117 496 0123,
+441174960123 (used in tel: links), hello@bloomhairspa.example and 48 Orchard Lane.
Images and artwork#
All artwork is SVG in assets/img/. Replace any file with a photo (JPG or WebP) at the same aspect ratio:
| Files | Size | Ratio |
|---|---|---|
looks/<look id>.svg (12 files) |
480×600 | 4:5 |
team/<member id>.svg (6 files) |
400×480 | 5:6 |
before.svg, after.svg |
640×520 | Both must be the same size |
salon.svg |
640×480 | 4:3 |
map.svg |
640×420 | Illustrative map on the contact page |
favicon.svg |
32×32 | 1:1 |
Two paths are built in JavaScript: the lightbox loads assets/img/looks/<id>.svg and the stylist list in the booking
wizard loads assets/img/team/<id>.svg. If you switch to JPGs, change those two strings in main.js as well as the
HTML.
og-image.png (1200×630) is the social sharing image; its editable source is og-image.svg. Export it again as a
1200×630 PNG after changing the SVG. Replace map.svg with a real map embed or a static map image of your location.
Navigation, header and footer#
The header, mobile menu and footer are repeated in every HTML file. To add a page or menu item, edit all nine files:
the desktop <nav> in #site-header, the #mobile-menu list, and the footer columns. Mark the current page with
aria-current="page".
The menu button (#menu-toggle) toggles the hidden attribute on #mobile-menu and aria-expanded on itself.
Escape closes the menu and returns focus to the button; the menu also closes on link click and at the desktop
breakpoint.
Features#
Booking wizard#
book.html holds [data-booking] with <form id="booking-form">, five <section data-step="1"> to data-step="5"
steps, a progress list (.steps with data-step-indicator) and a running summary (<aside class="booking-summary">).
| Step | What happens | Validation |
|---|---|---|
| 1. Treatments | Checkboxes name="services" |
At least one |
| 2. Stylist | Radio list built by renderStylists(): "Anyone available" plus everyone who can do all the chosen treatments |
None |
| 3. Date and time | 21 day buttons from today (renderDates()), then time buttons (renderSlots()) |
A time is chosen |
| 4. Your details | Name, email, mobile, notes, first visit, remember details, cancellation policy | Name 2+ characters, valid email, 10–15 digit phone, policy ticked |
| 5. Review | Rows with "Change" buttons that jump back to a step | None |
"Continue" validates the current step and moves on; on step 5 it becomes "Confirm booking" and calls confirm(), which
hides the wizard and shows [data-booking-done] with the first name, a summary and a demo reference (BLM- plus four
random characters). Each step's heading gets focus when it opens, and the active indicator has aria-current="step".
"Book something else" ([data-restart]) starts again.
Preselect from links:
book.html?service=balayageticks a treatment (must be a serviceid).book.html?stylist=amarapreselects a team member (must be a teamid). If they can't do the chosen treatments, the wizard falls back to "Anyone available".
Changing the treatments clears the chosen date and time.
Time slots and availability#
slotsFor(date, serviceIds, stylist, now) in section 2 of main.js returns the bookable start times. The rules:
- Opening hours: from
BLOOM.hours[date.getDay()]. A day withopen: nullhas no slots (Monday in the demo). - Length: the sum of the chosen services'
mins. A slot must finish by closing time, so a 180-minute balayage on a Thursday (09:00–20:00) can start at 17:00 at the latest. - Step: start times every 15 minutes.
- Today: the earliest start is at least an hour from now, rounded up to the next 15 minutes (at 10:07 the first slot is 11:15).
- Who can do it:
eligibleMembers()returns team members whosecatsinclude every chosen category. With "Anyone available", each start time is offered if any eligible member is free; the slot remembers who (data-member), and the summary and confirmation name them. - Working days: a member is only considered on days listed in their
days. - Diary:
isBusy(memberId, dateKey, block)marks about one in five 30-minute blocks as already booked, using a deterministic hash so the same diary appears on every visit. A member is free only if every block the treatment touches is free. - Mixed treatments: if nobody can do all the chosen categories (for example a cut and a gel manicure), the wizard
books "Best available team" back to back.
teamPlan()takes the treatments in menu order and gives each one to the first team member (inBLOOM.teamorder) who does that category, works that day and is free for the whole treatment; a start time is only offered if every treatment can be covered. The slot keeps the plan inteam(one member id per treatment).
To connect real availability, replace isBusy() (or memberFree()) with a lookup of your booking system's busy times:
// busy = { "theo|2026-10-08": [20, 21, 26] } -> 30-minute block numbers (block 20 = 10:00)
function isBusy(memberId, key, block) {
var list = busy[memberId + '|' + key];
return Boolean(list && list.indexOf(block) !== -1);
}Load busy before the wizard renders step 3, for example from a JSON endpoint. To change the slot interval, edit the
t += 15 loop in slotsFor(); to change the notice period, edit the + 60 in the earliest line; to show more days,
change the 21 in renderDates().
Level-based pricing#
priceFor(service, levelId) multiplies the base price by the team member's level multiplier, but only for the
hair and colour categories. Nails, skin and massage cost the same with anyone. Results are rounded to whole pounds.
| Treatment | Stylist | Senior (×1.2) | Director (×1.4) |
|---|---|---|---|
| Signature cut & finish (£68) | £68 | £82 | £95 |
| Balayage (£210) | £210 | £252 | £294 |
Until a stylist or a time (and so a team member) is chosen, the summary shows hair and colour prices and the total as
"from" the stylist price. To change the levels, edit BLOOM.levels; to make another category scale with level, add it
to the check in priceFor() and to the matching scaled checks in paintSummary(). The prices printed on
services.html and team.html are HTML; update them by hand.
Opening hours and open now#
Section 5 of main.js reads BLOOM.hours:
- Every
[data-open-now]badge gets text such as "Open now · until 19:00" or "Closed · opens tomorrow at 09:00" in its[data-open-text]child, and the classis-closedwhen closed. - In any
.hours-table, the<tr data-day="N">for today gets the classis-todayandaria-current="date".
It runs on load and every minute. It uses the visitor's clock and time zone, so a visitor abroad sees the status for
their local time. The rows in the hours tables are HTML; keep them in step with BLOOM.hours.
Services filter#
On services.html, [data-services] contains filter buttons with data-filter (all, hair, colour, nails,
skin, massage) and one <section data-service-group="hair"> per category. Choosing a filter hides the other groups,
sets aria-pressed and updates [data-services-count] ("5 treatments in Hair"), a polite live region. Link to
services.html?category=colour to open with a category selected.
Team filter#
On team.html, [data-team] has buttons with data-team-filter and cards with data-team-member and
data-cats="hair colour" (space-separated). The count ([data-team-count]) is a live region. Each card links to
book.html?stylist=<id>.
Gallery and saved looks#
On gallery.html, [data-gallery] contains:
- filter buttons with
data-look-filter(all,colour,cuts,bridal,nails,skin), - a "Saved" toggle (
data-saved-toggle) and its count (data-saved-count), - one
<li data-look="<id>" data-cat="<cat>">per look, with an open button (data-look-open="N", the index intoBLOOM.looks) and a heart button (data-save="<id>",aria-pressed), - a count (
data-gallery-count, live region) and an empty message (data-gallery-empty) for when no saved looks match, - a
<dialog data-lightbox>.
Saved looks are stored in localStorage (see Saved data) and stay in sync across tabs
through the storage event. The lightbox shows the look, who did it, its category, "N of M" and a "Book with" link
to book.html?stylist=<id>. The left and right arrow keys and the buttons step through the visible looks; Escape or a
backdrop click closes it and focus returns to the look.
To add a look, add it to BLOOM.looks (with by set to a team id and cat to a lookCategories id), add
assets/img/looks/<id>.svg, and add a <li> to gallery.html with data-look-open set to its index in BLOOM.looks.
Gift cards#
On gift-cards.html, [data-gift] contains <form id="gift-form"> and a preview ([data-gift-preview]):
- Amount: radios
name="amount"(50, 75, 100, 150, 250 orcustom). A custom amount must be a whole number from £25 to £500. - Design: radios
name="design"(petal,sage,noir); the preview gets the classgift-<design>. - To, recipient email, from, your email, message: the message has a 160-character counter.
- Delivery:
noworlater; later shows a date input limited to today through one year ahead.
The preview and the total update as you type, and [data-preview-desc] gives screen readers a text description of
the card. On a valid submit, the script shows [data-gift-done] with a demo code (BLOOM-XXXX-XXXX) and a summary.
Nothing is charged; see Forms for taking payment.
The preset amounts in the HTML aren't read from BLOOM.giftAmounts; edit the radios to change them.
Before/after slider#
On index.html, [data-ba] stacks the before and after images with a native range input ([data-ba-range]) on top.
Its value sets --pos, and aria-valuetext reads "Showing 50% before". It works with mouse, touch and the arrow keys.
Header and scroll reveals#
The header gets is-scrolled after 8px. Elements with reveal fade in as they enter the viewport, with a short
stagger; anything scrolled past is revealed too, so fast jumps never leave content hidden. Reveals are skipped with
reduced motion.
Back to top#
The last block of main.js adds a round button after about one screen of scrolling. It scrolls to the top (instantly
with reduced motion) and moves focus to the main content. It lifts above toasts and anything marked
data-back-to-top-avoid, hides while it would cover a fixed or sticky element marked data-back-to-top-hide, and
hides while an in-page link whose text contains "Back to top" is on screen. Restyle it with .back-to-top in
src/input.css; to remove it, delete the block and those styles.
Forms#
| Form | Where | Validation |
|---|---|---|
Booking (#booking-form) |
book.html |
Per step, see Booking wizard |
Gift card (#gift-form) |
gift-cards.html |
Amount, names, both emails, delivery date |
Contact (#contact-form) |
contact.html |
Name 2+ characters, valid email, message 20+ characters |
Newsletter ([data-newsletter]) |
Every page footer | Valid email |
Errors are written into the element with the matching data-error="<field name>" and the field gets
aria-invalid="true"; the first invalid field receives focus. Errors clear as you type. The contact form accepts
contact.html?topic=careers to preselect a topic (appointments, gift-cards, events, careers, other).
None of the forms send anything. Each has a comment at the point to connect it:
- booking: "Demo only. Send { services, stylist, date, time, details } to your booking system here." in
confirm(), - gift card: "Demo only: create a payment session with your provider (e.g. Stripe Checkout) here.",
- contact: "Send new FormData(contact) to your endpoint here.",
- newsletter: before
nf.reset().
Connecting to Formspree#
For the contact form, add an action and post it where the comment is:
<form id="contact-form" class="mt-8 grid gap-5 sm:grid-cols-2" action="https://formspree.io/f/your-form-id" method="POST" novalidate>// Send new FormData(contact) to your endpoint here.
var ok = $('[data-form-success]', contact);
fetch(contact.action, { method: 'POST', body: new FormData(contact), headers: { Accept: 'application/json' } })
.then(function (res) {
if (!res.ok) throw new Error('Send failed');
contact.reset();
ok.hidden = false;
ok.focus();
})
.catch(function () { cErr('message', 'Sorry, that did not send. Please call or email us.'); });Remove the original contact.reset() and success lines that follow the comment, since the then does that.
For a booking request by email, post the wizard state from confirm():
var data = new FormData(form);
data.append('treatments', selected().map(function (id) { return serviceById[id].name; }).join(', '));
data.append('with', state.member ? memberById[state.member].name : state.stylist);
data.append('date', dateKey(state.date));
data.append('time', hhmm(state.slot));
fetch('https://formspree.io/f/your-form-id', { method: 'POST', body: data, headers: { Accept: 'application/json' } });A form service only delivers the request; it doesn't block the time in a diary. For real bookings, send the same data
to your booking system's API and use its availability in isBusy(). For gift cards, create a checkout session on your
server (for example Stripe Checkout) and redirect to it instead of showing the demo code.
Saved data (localStorage)#
| Key | Shape | Written |
|---|---|---|
bloom-saved-looks |
Array of look ids, for example ["honey-balayage", "french-bob"] |
When a heart is toggled |
bloom-details |
{"name": "…", "email": "…", "phone": "…"} |
On booking confirmation, only if "Remember my details on this device" is ticked |
Validation on read:
readSaved()keeps only strings that are ids inBLOOM.looks, without duplicates, and writes the cleaned list back if anything was dropped.readDetails()requires an object, keepsname(up to 80 characters),email(up to 120, and only if it looks like an email) andphone(up to 30), and removes the key if nothing valid is left.
Confirming a booking with "remember" unticked removes bloom-details. All storage access is wrapped in try/catch
(the store helper), so private browsing just disables saving. To reset, delete the keys in your browser's developer
tools (Application, Local Storage).
SEO and sharing#
Each page has its own <title>, <meta name="description">, og:type, og:title, og:description and og:image.
Edit them per page when you rebrand.
Social networks need an absolute image URL. When you deploy, change og:image on every page to your domain:
<meta property="og:image" content="https://example.com/assets/img/og-image.png">There is no structured data, sitemap or robots.txt. For a salon, consider adding a HairSalon or BeautySalon
JSON-LD block to index.html with your address, phone, opening hours and booking URL, a sitemap.xml listing the
pages, and a robots.txt that points to it.
Accessibility#
Built in:
- A "Skip to content" link, landmarks (
header,nav,main,footer) and one<h1>per page. - Visible focus styles.
- Menu button with
aria-expanded; Escape closes the menu. - Toggle buttons with
aria-pressedfor filters, saved looks, dates and time slots; unavailable dates are disabled and labelled "unavailable". - Live regions for the services, team and gallery counts.
- Wizard steps with focusable headings,
aria-current="step"on the progress list and focus on the confirmation. - Form errors next to each field with
aria-invalid, focus on the first error. - Today's opening hours row marked with
aria-current="date". - Native
<dialog>lightbox with focus return, and a native range input for the before/after slider. - Reduced motion: no reveal animations and instant scrolling.
When editing, keep one <h1> per page, label every new input, give new error messages a data-error element, and
write alt text for meaningful photos. --color-mute is tuned for AA contrast on the light backgrounds; check contrast
if you darken the panels.
Building and deploying#
Rebuilding the CSS#
assets/css/style.css is compiled and included. Rebuild it after editing src/input.css or using Tailwind classes that
aren't in the files yet (Node.js 24):
npm install
npm run build # or: npm run watch while you editTailwind scans the HTML and main.js, so classes written in JavaScript strings (such as those in the stylist list) are
included.
Deploying#
The folder is a static site. Upload it as it is:
- Netlify Drop: drag the folder onto app.netlify.com/drop.
- GitHub Pages: push the files to a repository and enable Pages for the branch.
- Cloudflare Pages / any static host: upload the folder; no build command is needed.
Configure your host to serve 404.html for missing pages (Netlify and GitHub Pages do this automatically). You can
leave out src/, package.json and the Markdown files when uploading.
Troubleshooting#
- A new treatment doesn't appear in the booking wizard. Add both the
BLOOM.servicesentry and the checkbox inbook.html; the checkboxvaluemust equal theid. - The booking breaks when a new treatment is ticked. The checkbox
valuehas no matching service inBLOOM.services. Check spelling. - No times appear for a day. The salon is closed that day in
BLOOM.hours, nobody eligible works it (days), or every slot is taken in the demo diary. Try "Anyone available". - A stylist link opens with "Anyone available". The
?stylist=id doesn't exist, or that person can't do the chosen treatments. - The "Open now" badge looks wrong. It uses the visitor's local time and
BLOOM.hours. UpdateBLOOM.hourswhen you change the hours tables. - A look or portrait image is missing in the lightbox or wizard. The file must be named after the id:
assets/img/looks/<id>.svgorassets/img/team/<id>.svg. - New Tailwind classes have no effect. Run
npm run build. - Social previews show no image. Make
og:imagean absolute URL on your domain.
License#
MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this
template, including the SVG artwork. Bloom Hair & Spa, its team and its clients are fictional.