Overview#
Fade is a nine-page website for a barbershop. The demo brand is Fade Bros. Barber Co., a fictional independent barbershop with two shops in Chicago: Logan Square (2741 N Milwaukee Ave) and Pilsen (1532 W 18th St). This version is built with Astro 7, TypeScript and Tailwind CSS v4, and builds to a static site with no UI framework: the interactivity is plain TypeScript. Highlights are an illustrative "current wait" board for each shop, a five-step booking flow that works out time slots from each barber's schedule and the length of the chosen services, filterable services and barbers, a grooming product shop with a saved bag, and a gift card builder with a live preview. Shops, services, barbers and products live in one typed data file that both the pages and the script read. This guide covers version 1.0.0. Live demo: freeframe.dev/templates/fade.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/fade into demo/ (only used for the demo on freeframe.dev) |
Pages#
| File | Route | Notable sections and features |
|---|---|---|
src/pages/index.astro |
/ |
Hero with the current-wait board, services preview, barbers, house rules, reviews, both shops with open-now status, gift card call to action |
src/pages/services.astro |
/services |
Price list by category with times, category filter with live count, "Book" link per service, FAQ accordion |
src/pages/barbers.astro |
/barbers |
Six profiles, filters by shop and speciality, days in the chair, next free time, "Book with" links |
src/pages/book.astro |
/book |
Five-step booking flow with a running summary, confirmation and .ics download |
src/pages/shop.astro |
/shop |
Product grid with category filter, the bag (quantities, pickup or shipping, demo checkout), gift card builder |
src/pages/locations.astro |
/locations |
SVG map, each shop's hours, open-now status, transit, parking, walk-in wait, how the estimate works |
src/pages/about.astro |
/about |
Story, timeline, standards, open jobs and chair rental |
src/pages/contact.astro |
/contact |
Validated contact form with topics, both shops' details, group and wedding bookings |
src/pages/404.astro |
/404 |
Links back into the site |
Every page uses src/layouts/Layout.astro, which adds the header (with the bag count), the footer (open-now badges
for both shops and the newsletter form) and the back-to-top button.
Project structure#
fade/
├── src/
│ ├── pages/ One .astro file per page (index, services, barbers, book, shop, locations, about, contact, 404)
│ ├── layouts/Layout.astro <html>, <head> metadata, header, <main>, footer and the script imports
│ ├── components/
│ │ ├── SiteHeader.astro Skip link, logo, main nav, bag link, mobile menu
│ │ ├── SiteFooter.astro Logo, links, both shops with open-now badges, newsletter form
│ │ ├── Logo.astro Barber-pole mark and wordmark (light and dark versions)
│ │ ├── StatusBadge.astro "Open now" badge for a shop, filled in by the script
│ │ ├── HoursTable.astro A shop's opening hours, Monday first
│ │ ├── ShopMap.astro The map on the locations page (inline SVG)
│ │ └── Storefront.astro The storefront illustration on the about page (inline SVG)
│ ├── data/
│ │ ├── fade.ts FADE: shops, hours, services, barbers, traffic model, products, gift card and shipping settings
│ │ ├── content.ts Repeated copy: home picks, reviews, FAQ, timeline, standards, jobs, contact topics, booking steps
│ │ └── nav.ts Header, mobile menu and footer links
│ ├── lib/
│ │ ├── site.ts Site name, theme color and the url() helper
│ │ ├── format.ts money(), dur(), t12() and DAYS for build-time text
│ │ ├── icons.ts Inline SVG icons (I, CAT_ICON, CLOCK_SM)
│ │ └── art.ts Product packaging, logo mark and stamp drawn as SVG strings
│ ├── scripts/
│ │ ├── main.ts All interactivity (sections 2 to 12, see below)
│ │ └── back-to-top.ts The back-to-top button
│ └── styles/global.css Tailwind: @theme tokens and component styles
├── public/img/
│ ├── barbers/ Barber portraits, one SVG per barber id (400 x 480)
│ ├── storefront.svg Standalone copy of the storefront illustration
│ ├── favicon.svg
│ └── og-image.svg, og-image.png
├── astro.config.mjs site (SITE_URL), base (DEMO_BASE_PATH), build.format "file", Tailwind plugin
├── eslint.config.mjs, tsconfig.json, package.json, pnpm-workspace.yaml
├── template.json Template metadata (name, version, pages, features)
├── README.md Short introduction
├── DOCS.md This guide
└── CHANGELOG.md Version historyCustomising#
Brand and colors#
All design tokens live in the @theme block at the top of src/styles/global.css:
| Token | Default | Used for |
|---|---|---|
--color-paper |
#f2ead9 |
Page background |
--color-card |
#fbf7ef |
Cards, inputs |
--color-sand |
#e8dcc4 |
Soft panels and alternate sections |
--color-ink |
#151515 |
Text, borders, hard shadows, dark sections |
--color-coal |
#24211e |
Raised surfaces on dark (the wait board) |
--color-mute |
#5a5249 |
Secondary text |
--color-red |
#b5281c |
Barber red: buttons, eyebrows, links |
--color-navy |
#1d3557 |
Barber blue in the stripes |
--color-brass |
#e0a83a |
Accents on dark backgrounds only |
--color-go |
#2c6a37 |
"Open" dots and positive states |
Each token becomes Tailwind utilities (bg-red, text-ink, border-brass...). To change the accent:
@theme {
--color-red: #1f6f5c; /* a deep green instead of barber red */
--color-red-dark: #165243;
}Keep text colors at 4.5:1 contrast or more against paper, card and sand. Brass is only for text on ink.
The barber-pole stripes are the pole-stripe utility (a repeating-linear-gradient of red, paper and navy); the
header and footer use it through .pole-band.
The SVG artwork has its colors written in. src/lib/art.ts has the same values as constants at the top (INK,
PAPER, RED, NAVY, BRASS); the portraits in public/img/barbers/, ShopMap.astro, Storefront.astro and the
social image use the hex values directly, so a find-and-replace of #b5281c updates them. The browser theme color is
themeColor in src/lib/site.ts.
Typography#
Two Google Fonts are loaded in the <head> in src/layouts/Layout.astro:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Archivo:wdth,wght@62..125,400..900&display=swap" />- Anton (
--font-display) for headings. Allh1andh2elements, and anything withfont-display, are set in Anton, uppercase. - Archivo (
--font-sans) for everything else. It's a variable font with a width axis: the.condensedclass and the button, tag and label styles usefont-stretchfor the narrow, sign-painter look.
To swap fonts, change the link in Layout.astro and the two --font-* tokens in src/styles/global.css.
Content#
Page copy lives in the .astro files. Everything that is a list or that the script needs lives in data files:
| File and export | What it holds |
|---|---|
src/data/fade.ts FADE.shop |
Name, email, timeZone (America/Chicago), zoneLabel, currency and locale |
FADE.locations |
The two shops: id, address, phone, chairs, hours per weekday (0 = Sunday, null = closed), transit, parking, note, directions link |
FADE.categories |
Service categories (cuts, beard, shaves, kids, packages) with their blurbs |
FADE.services |
Each service: id, cat, name, mins, price, desc, optional needs and popular |
FADE.specialties |
The speciality filter on the barbers page |
FADE.barbers |
Each barber: id, short name, name, role, years, skills, specialties, bio, signature and schedule per shop per weekday |
FADE.traffic |
The demo walk-in traffic curves used by the wait estimate |
FADE.products, FADE.productCategories |
Shop products (with the packaging kind and colors drawn by productArt()) and their filter categories |
FADE.giftDesigns, FADE.gift, FADE.shipping |
Gift card designs and limits, flat shipping and the free-shipping threshold |
src/data/content.ts |
featuredServices and homeTeam (ids shown on the home page), marqueeWords, homeStandards, reviews, faqs, timeline, standards, jobs, contactTopics, bookingSteps |
src/data/nav.ts |
mainNav, footerNav, socialLinks |
The price list, booking checkboxes, barber cards, product cards, shop details and hours tables are all rendered from
FADE when the site is built, so a change in src/data/fade.ts updates every page and the script at once. For
example, to add a "Hot towel & line-up" service, add one entry to FADE.services:
{ id: 'towel-lineup', cat: 'cuts', name: 'Hot towel & line-up', mins: 20, price: 24, desc: 'A line-up finished with two hot towels.' },It appears on the services page, in step 2 of the booking flow and in the category counts. needs lists the skills
a barber must have; it defaults to the service's own category. Packages use it, e.g. needs: ['cuts', 'shaves'] for
The Full Service, so only barbers with both skills are offered.
To add a barber, add an entry to FADE.barbers and a portrait at public/img/barbers/<id>.svg:
{
id: 'nia', short: 'Nia', name: 'Nia Brooks', role: 'Barber', years: 4,
skills: ['cuts', 'beard', 'kids'], specialties: ['fades', 'kids'],
bio: '…', signature: 'Burst fade',
schedule: { logan: { 4: ['12:00', '20:00'], 5: ['12:00', '20:00'] } }
},Keep barber schedules inside the shop's opening hours. The home page shows the barbers listed in homeTeam, and
some copy mentions "six barbers"; update it in index.astro and barbers.astro if your team size changes.
Images and artwork#
Everything is SVG, so there are no photos to license:
- Barber portraits:
public/img/barbers/<barber id>.svg, 400 x 480 (5:6). The pages and the booking flow load them by id throughurl()(/img/barbers/<id>.svg). To use photos, keep the 5:6 ratio (e.g. 800 x 960 JPG) and change the path inindex.astro,barbers.astroandrenderBarbers()insrc/scripts/main.ts. - Products: drawn from each product's
kind,colorandlabelbyproductArt()insrc/lib/art.tsand placed in.product-art(square). Replace theset:html={productArt(p)}inshop.astrowith an<img>of a square product shot. - Storefront (
Storefront.astro, about page) and map (ShopMap.astro, locations page) are inline SVGs so they use the page fonts.public/img/storefront.svgis a standalone copy. - Social image:
public/img/og-image.svgis the source; social sites need the PNG,og-image.png(1200 x 630). Export it from any vector editor. - Favicon:
public/img/favicon.svg.
Navigation, header and footer#
The header lives in src/components/SiteHeader.astro and the footer in src/components/SiteFooter.astro; both read
their links from src/data/nav.ts. To add a page, create src/pages/<name>.astro with
<Layout title="…" description="…" page="<name>" current="<name>">, then add { key: "<name>", href: "/<name>", label: "…" } to mainNav (header and mobile menu) or footerNav. current marks the matching link with
aria-current="page", which styles it. Always write internal links with url() from src/lib/site.ts
(url("/book?service=fade")), never as a bare "/…" path, so they keep working under a base path.
Features#
All interactivity is in src/scripts/main.ts, imported once by Layout.astro. Each feature starts only if its markup
is on the page.
Booking flow#
book.astro, section 9 of main.ts. Five steps inside one <form id="booking-form">:
- Shop: radio cards with open-now status and who is in today.
- Services: checkboxes; the duration and total add up in the summary and the running total.
- Barber: "First available" plus every barber who works at the chosen shop and has the skills for every chosen service, with their next free time. Barbers left out are listed with the reason (e.g. "Rosa (no straight-razor shaves)").
- Time: a month calendar and the open times for the chosen day, grouped into morning, afternoon and evening.
- Details: name, mobile, email, notes, reminder and cancellation policy, all validated.
If nobody at the chosen shop can do every chosen service in one visit (kids cut + hot towel shave in Pilsen),
step 2 explains it instead of continuing. The confirmation shows a summary, a reference and a button that downloads
an .ics file. It clearly says nothing was booked.
URL parameters preselect choices: /book?location=pilsen, ?service=fade, ?barber=rosa (a barber also sets their
shop). Only ids that exist in FADE are accepted.
Time slots#
Slots come from slotsFor(locId, key, ids, barberId, now) in main.ts:
- The shop's hours that day, the barber's
scheduleat that shop, and the total minutes of the chosen services. - Start times every
SLOT_STEP(15) minutes; today's earliest isLEAD_TIME(30) minutes from now. - Visitors can book
BOOK_AHEAD(42) days ahead. isBooked()marks about a third of each barber's half-hours as taken, using a stable hash so the diary looks real and doesn't change on reload. Replace it with your booking system's availability.
const SLOT_STEP = 15; // minutes between start times
const LEAD_TIME = 30; // earliest booking is this many minutes from now
const BOOK_AHEAD = 42; // days visitors can book ahead (6 weeks)All times are the shop's local time (FADE.shop.timeZone), whatever the visitor's own time zone is.
Connecting real bookings#
The flow is a front end only. To take real bookings, either embed your provider's widget in place of the form in
book.astro (Square Appointments, Fresha, Booksy and Cal.com all offer one), or keep this flow and send the request
where confirm() says so:
// Demo only: nothing is sent. Post { location, services, barber, date, time, details } to
// your booking provider here (Square Appointments, Fresha, Booksy or Cal.com APIs/embeds).Booking APIs need a secret key, so call them from a server endpoint, never directly from the browser. With Astro you
can add an on-demand endpoint (e.g.
src/pages/api/book.ts with export const prerender = false) and an adapter for your host, or use your host's
functions (Netlify Functions, Cloudflare Workers, Vercel Functions).
Current wait estimate#
index.astro (hero board) and locations.astro, section 6 of main.ts. Any element with data-wait="<shop id>" is
filled in: [data-wait-num], [data-wait-detail], [data-wait-unit], [data-wait-chart] and
[data-wait-chart-label].
estimateWait() multiplies the traffic level for this hour (FADE.traffic, 0 to 1, separate curves for weekdays,
Saturday and Sunday) by 70 minutes, divides by the number of barbers working, adds a small stable variation and rounds
to 5 minutes. It is illustrative: the page says so next to every number and /locations#wait-model explains it.
To show real waits, replace estimateWait() with a call to your queue system (e.g. a JSON endpoint), and remove the
"Illustrative estimate" labels.
Add ?at=2026-10-17T10:30 to any page URL to preview the site at a shop-local time (useful for testing busy and
closed states).
Open now and hours#
Section 5 of main.ts. <StatusBadge id="logan" /> renders a [data-open-now="logan"] badge that shows "Open now ·
until 8 pm", "Closing soon · 8 pm" (last 45 minutes) or "Closed · opens tomorrow 9 am". [data-today-hours] shows
today's hours, and the current weekday's row in every <HoursTable> ([data-hours]) is highlighted and marked
aria-current="date". Everything refreshes every minute. Without JavaScript the badges say "See opening hours".
Services filter#
services.astro, section 7. Chips with data-filter show one category; the count is announced with
aria-live="polite". /services?category=beard preselects a category.
Barber filters and next free time#
barbers.astro, section 8. Two chip groups filter by shop (data-barber-loc) and speciality (data-barber-spec);
cards carry data-locs and data-specs, built from each barber's schedule and specialties. If no barber matches,
an empty state offers to reset. ?location=pilsen and ?speciality=curly preselect filters. Each card shows the
barber's next free time for a Classic cut.
Bag and checkout#
shop.astro, section 10. "Add to bag" buttons (data-add="<product id>") add products; the bag lists products with
quantity steppers (1 to MAX_QTY, 10) and gift cards, offers pickup at either shop or shipping (FADE.shipping: $6,
free over $50 in products) with a progress meter, and totals everything. "Checkout" shows a demo summary. The header
bag icon shows the item count on every page and links to /shop#bag.
To sell for real, send the bag lines from readBag() to Shopify (Buy Button or Storefront API), Square Online or
Stripe Checkout where the [data-checkout] click handler is.
Gift card builder#
/shop#gift-cards, section 11. Preset amounts (FADE.gift.presets) or a custom whole amount between FADE.gift.min
and FADE.gift.max ($20 to $300), three designs (gift-pole, gift-stamp, gift-blackout in
src/styles/global.css), recipient, sender, message (140 characters, with a counter) and "right away" or a date
within a year. The preview updates as you type and a screen-reader summary is announced politely. A valid card is
added to the bag as a gift line.
Header, menu, reveals and back to top#
The header is sticky and gets a shadow once you scroll. Below 1024px the #menu-toggle button opens a full-screen
menu (aria-expanded, Esc closes it and returns focus). Elements with .reveal fade up once as they scroll into
view; everything above the viewport is revealed too, and nothing is hidden with reduced motion. The marquee and the
stamp badge stop moving with reduced motion. The back-to-top button is src/scripts/back-to-top.ts; delete it, its
import in Layout.astro and the .back-to-top styles to remove it.
Forms#
| Form | Where | Validates |
|---|---|---|
| Booking details | book.astro step 5 |
Name, 10-digit US mobile, email, policy checkbox |
| Gift card | shop.astro |
Custom amount range, recipient name and email, sender, delivery date |
| Contact | contact.astro |
Name, email, optional phone (10 digits if given), message of 20+ characters |
| Newsletter | SiteFooter.astro, every page |
Errors appear inline next to each field, fields get aria-invalid, and focus moves to the first problem. On success
each form shows a message that says it's a demo. No form sends anything until you connect it.
/contact?topic=careers (or group, chair-rental, feedback, press, the values in contactTopics) preselects
the topic.
Connecting to Formspree#
Create a form at Formspree, then replace the comment in the contact form handler (section 12 of main.ts):
// Send new FormData(contact) to your endpoint here
fetch('https://formspree.io/f/your-id', {
method: 'POST',
body: new FormData(contact),
headers: { Accept: 'application/json' }
}).then((res) => {
if (!res.ok) throw new Error('Send failed');
contact.reset();
ok.hidden = false;
ok.focus();
});Move the contact.reset() and success lines into the then so the message only shows after a real send, and update
the success text in contact.astro. The newsletter handler has the same kind of comment; point it at Mailchimp,
Buttondown or your email tool. To handle the form on your own server instead, add an on-demand Astro endpoint
(src/pages/api/contact.ts with export const prerender = false and an adapter) and post to it the same way.
Saved data (localStorage)#
| Key | Shape | Validation on read |
|---|---|---|
fade-bag |
{ items: [...], delivery: "logan" | "pilsen" | "ship" } |
See below |
Each item is either { type: "product", id, qty } or { type: "gift", key, amount, design, to, toEmail, from, message, date } (the ProductLine and GiftLine types in main.ts). readBag() drops unknown product ids, duplicate lines,
quantities that aren't whole numbers (and caps them at 10), gift cards with an amount outside the limits, an unknown
design or an invalid email, and trims text to its maximum length. A cleaned version is written back. Bags open in two
tabs stay in sync through the storage event.
To reset, empty the bag on /shop, or run localStorage.removeItem('fade-bag') in the browser console. Nothing else
is stored.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>, which outputs<title>, the meta description,og:title,og:description,og:site_name(fromsite.nameinsrc/lib/site.ts) and asummary_large_imageTwitter card. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://fade.example.com). Set it to your domain when you build:shSITE_URL=https://fadebros.com pnpm build -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
For a sitemap, add
@astrojs/sitemap(pnpm astro add sitemap) and apublic/robots.txtthat points to it. -
For local search, consider adding
BarberShopstructured data (JSON-LD) with each shop's address, phone and opening hours tolocations.astro; you can build it fromFADE.locations.
Accessibility#
- Skip link, landmarks (
header,nav,main,footer), oneh1per page and a logical heading order. - Visible focus styles (a red outline, brass on dark sections) on every interactive element.
- Filters are buttons with
aria-pressed; result counts usearia-live="polite". - The booking flow moves focus to each step's heading, marks the current step with
aria-current="step", and the calendar uses a roving tabindex with arrow keys, Home/End and PageUp/PageDown. Each day's label says how many times are open. - The FAQ uses native
<details>/<summary>. - Text meets WCAG AA contrast; decorative SVGs are
aria-hidden, portraits and the map have text alternatives. prefers-reduced-motionturns off reveals, the marquee and the stamp rotation, and makes scrolling instant.
When editing, keep one h1 per page, give new inputs a <label>, and keep brass text on dark backgrounds only.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as shop.html rather than shop/index.html
(build.format: "file" in astro.config.mjs), and links point to /shop without a trailing slash. Netlify,
Cloudflare Pages and GitHub Pages serve /shop from shop.html automatically; Vercel does with cleanUrls (below).
- 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/shopservesshop.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/shoptoshop.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/fade pnpm build. It
becomes Astro's base, and every link written with url(), including the portrait paths the script builds, includes
it.
Tailwind finds class names in src/ automatically, including the ones inside strings in src/scripts/main.ts, as
long as they're written in full. demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
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 wait board says "Closed" during your opening hours. Times are worked out in
FADE.shop.timeZone. Set it to your shop's IANA zone (e.g.America/New_York) and checkhoursinFADE.locations(0 is Sunday). - A barber never shows up in booking. They must have the shop in
scheduleand every skill in the chosen services'needs(or category). Checkskillsspelling against the category ids. - No times on any day. The chosen services may be longer than the barber's shift, or the shift falls outside the
shop's hours. Shorten the services or widen the
schedule. - Links or portraits 404 when the site is in a sub-folder. Build with
DEMO_BASE_PATHset to the folder, and write new links withurl()rather than a bare"/…"path. /shopshows a 404 after deploying. Your host doesn't map extensionless URLs to.htmlfiles. Enable clean URLs (VercelcleanUrls) or switchbuild.formatto"directory".- New Tailwind classes have no effect. Class names built from pieces (
'bg-' + color) aren't found; write them out in full. - The bag shows old items after you changed product ids. Old ids are dropped automatically on the next read; if you renamed an id, ask testers to clear the bag.
- Social previews show no image. Set
SITE_URLwhen building soog:imageis an absolute URL to the PNG.
License#
MIT, free for personal and commercial use. See LICENSE.txt in the download (the repository's MIT License). All
illustrations, portraits, product art, the map and the icons are original SVG artwork included under the same license.