Overview#
Fitness is a bold, dark website for a gym or fitness studio: a weekly class timetable with booking, class and coach profiles, membership pricing with a BMI and calorie calculator, and a three-step free trial sign-up. The demo is Forge Athletic Club, a fictional club with two locations, six class types, six coaches and 36 weekly sessions. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.3. It builds to static HTML files with no UI framework and can be hosted anywhere. See it live at freeframe.dev/templates/fitness.
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/fitness into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | What's on it |
|---|---|---|
/ |
src/pages/index.astro |
Hero, class-name strip, "next up today" sessions, class cards, count-up results, membership teaser, coaches, member stories, facilities, free-trial banner |
/classes |
src/pages/classes.astro |
Timetable: week grid (day tabs on phones), filters by class, intensity and coach, spots left, booking dialog |
/class |
src/pages/class.astro |
One class type (/class?type=box): what to expect, intensity meter, equipment, coaches, upcoming sessions |
/trainers |
src/pages/trainers.astro |
Six coach cards with a specialty filter |
/trainer |
src/pages/trainer.astro |
One coach (/trainer?id=lena): bio, quote, certifications, classes, booking link |
/membership |
src/pages/membership.astro |
Three plans with monthly/yearly switch, comparison table, add-ons, BMI and calorie calculator, FAQ |
/trial |
src/pages/trial.astro |
Three-step free-trial sign-up: goal, details, review |
/contact |
src/pages/contact.astro |
Two clubs with maps, live open/closed status, today's hours, contact form |
| 404 | src/pages/404.astro |
Not-found page with hours and links (most hosts show 404.html automatically) |
The footer on every page has a newsletter form.
Project structure#
fitness/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), skip link, header, footer, toast, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro logo, desktop navigation, free-trial button, full-screen mobile menu
│ │ └─ SiteFooter.astro footer links, newsletter form
│ ├─ data/
│ │ └─ forge.ts TIMEZONE, classes, coaches, locations (opening hours) and the weekly sessions
│ ├─ lib/
│ │ └─ site.ts site name, theme colour and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts timetable, bookings, class/coach pages, calculator, trial form, forms, menu
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (colours, fonts) and shared component classes
├─ public/img/ class-*.svg, coach-*.svg, hero.svg, favicon, og-image.png (and its SVG source)
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonPages are rendered to HTML at build time. The two files in src/scripts/ are the only JavaScript sent to
the browser, plus one inline line in the layout that adds the js class to <html> before the page
paints. Layout.astro imports both scripts, and Astro bundles them (with the data they import from
src/data/forge.ts) into one small module.
Customising#
Brand and colours#
The palette and fonts are tokens in the @theme block at the top of src/styles/global.css:
| Token | Used for |
|---|---|
--color-bg, --color-surface, --color-raised |
Dark page and card backgrounds |
--color-line |
Borders |
--color-ink, --color-muted |
Text |
--color-volt |
Main accent (#c8ff2e): buttons, highlights, focus rings |
--color-volt-dim |
Accent hover |
--color-heat |
High intensity, "few spots left" alerts |
--color-cool |
Low intensity |
For example, an orange accent:
@theme {
--color-volt: #ff8a1f;
--color-volt-dim: #e5730a;
}The accent is used as a background behind dark text, so keep it bright. pnpm dev picks changes up
instantly. The browser theme colour is site.themeColor in src/lib/site.ts (#0a0b0c).
Typography#
Headlines use Anton (--font-display, a tall condensed face) and text uses Inter
(--font-sans), loaded from Google Fonts by a <link> in src/layouts/Layout.astro. To change them,
replace that link and the two tokens. To self-host, put the font files in public/fonts/, add
@font-face rules to global.css and remove the <link> and the two preconnect tags.
Content#
Page text lives in each src/pages/*.astro file; the title and description are the title and
description props on <Layout>. Classes, coaches, locations and the timetable are typed data in
src/data/forge.ts:
| Export | Contents |
|---|---|
TIMEZONE |
The club's IANA time zone (default "America/New_York") |
classes |
Class types keyed by id: strength, ignite, ride, box, flow, engine (ClassInfo) |
coaches |
Coaches keyed by id: maya, dev, jonah, lena, sami, chloe (Coach) |
locations |
Clubs keyed by id (harbor, northgate) with weekly opening hours (Location) |
sessions |
The weekly timetable, 36 sessions (Session) |
A class type:
strength: {
name: "Strength Lab",
short: "Small-group barbell training with coached progressions every week.",
desc: "Strength Lab is where Forge members get genuinely strong. ...",
intensity: 2, // 1 = Low, 2 = Medium, 3 = High
duration: 60, // minutes
kcal: "350–500",
expect: ["A 10-minute movement-prep warm-up tailored to the day's lift", "..."],
equipment: ["Olympic barbell & bumper plates", "..."],
who: "All levels. New lifters start on technique blocks with a lighter bar.",
tag: "Strength",
},A coach has name, role, tags (specialties), classes (class ids they teach), years, certs,
quote, bio (an array of paragraphs) and handle (social handle without @).
A location's hours is an array of seven [open, close] pairs in 24-hour "HH:MM" text, starting on
Monday:
harbor: {
name: "Forge Harbor",
hours: [["05:30", "22:00"], /* Tue … Sat */ ["08:00", "18:00"]],
},A session in the timetable:
{ id: "s00", day: 0, time: "06:00", type: "ignite", coach: "dev", loc: "northgate", cap: 20, booked: 9 },day counts from Monday (0) to Sunday (6); type, coach and loc are ids from the other exports
(TypeScript rejects unknown ones); cap is the class size and booked the number of spots already taken
(demo data). Give every session a unique id: bookings are saved by id.
Adding a class type needs: its id in the ClassId type and an entry in classes, an illustration at
public/img/class-<id>.svg, its id in some coaches' classes, and sessions using it. Update the class
cards in index.astro, the class filter <select data-filter="type"> in classes.astro and the "other
classes" links (data-other-class) in class.astro too. Adding a coach is the same pattern: the
CoachId type, an entry in coaches, public/img/coach-<id>.svg, a card in trainers.astro and an
option in the coach filter.
The printed opening hours (the data-hours lists in contact.astro) are markup; keep them in step
with locations.
Time zone#
TIMEZONE in src/data/forge.ts is the club's IANA time zone. businessNow() in src/scripts/main.ts
reads "now" there with Intl.DateTimeFormat, so the open/closed status, today's row and timetable day,
"next up" classes and the earliest trial date are the same for every visitor, wherever their device is.
Images and artwork#
All artwork in public/img/ is original SVG: class images (class-<type>.svg), coach portraits
(coach-<id>.svg), hero.svg, favicon.svg and the social image (og-image.svg source, og-image.png
export at 1200×630). The class and coach pages build image paths from ids, so photos must use the same
file names (change the /img/class- and /img/coach- paths in the "Class detail page" and "Coach
profile page" blocks of src/scripts/main.ts, and the <img> tags in the pages, if you switch to
.jpg). In .astro files, write image paths with url() so they work under a base path:
<img src={url("/img/coach-lena.jpg")} alt="Lena Brandt" width="600" height="750" />Navigation, header and footer#
src/components/SiteHeader.astrois the header and full-screen mobile menu. A link getsaria-current="page"(and its highlighted style) when the page'scurrentprop matches it, for examplecurrent="membership";class.astropassescurrent="classes"andtrainer.astrocurrent="trainers".src/components/SiteFooter.astrois the footer with links and the newsletter form.
To add a page, create src/pages/pt.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Personal training — Forge Athletic Club" description="One-to-one coaching." current="pt" page="pt">
<main id="main">
<section class="container-x pt-36 pb-20">
<h1 class="display text-6xl">Personal training</h1>
</section>
</main>
</Layout>Each page provides its own <main id="main"> (the skip link's target); page sets data-page on
<body>. Then add the link to the desktop and mobile menus in SiteHeader.astro (with the same
aria-current={(current === "pt") ? "page" : undefined} pattern) and to the footer. Always write
internal links as href={url("/pt")}.
Features#
All interactive code is in src/scripts/main.ts, one commented block per feature. Each block looks for
its elements by id or data-* attribute and does nothing if they're missing. HTML strings built in the
script (timetable, booking badges, class and coach details) use Tailwind classes too; Tailwind scans
src/, so classes you add there are generated automatically.
Timetable#
/classes renders the week from sessions into [data-timetable]:
- On desktop all seven days show side by side; on phones day tabs (
[data-day-tab], a tablist with Left/Right, Home and End keys) show one day at a time, starting on today inTIMEZONE. - Filters (
<select data-filter="type">,"intensity","coach") narrow the sessions; Reset (data-filter-reset) clears them, and[data-tt-count]shows a live count ("36 classes this week"). - Each session shows its time, intensity meter, coach, club and a badge: "12 spots", "2 left", "Full · waitlist" or "Booked".
- Links can preselect filters and the day:
/classes?type=box,?coach=lena,?day=4. Ids match regardless of case; ids that don't exist are ignored.
Booking dialog#
Clicking a session opens the native <dialog id="booking-modal"> with the class details and one action
button: Book my spot, Cancel booking, or Join waitlist when full. Bookings are saved in
localStorage (see Saved data) so they show as "Booked" after a reload; the
waitlist only shows a toast. Esc, the close button or clicking outside closes the dialog, and focus
returns to the session. "Clear my bookings" (data-clear-bookings) removes them all.
This is a demo: bookings live only in the visitor's browser and booked counts never change for other
people. For real bookings, connect a gym-management or class-booking service (Mindbody, Glofox,
TeamUp...) and replace the [data-bm-action] click handler, or link each session to their booking page.
Next up today#
On the home page [data-next-up] lists the next four sessions later today (or tomorrow's first four if
none are left), each linking to that day on the timetable (/classes?day=2).
Class and coach pages#
class.astro and trainer.astro contain static content for one default record
(data-default="strength" and data-default="maya"), so they work without JavaScript. With ?type= or
?id=, the script fills in another class or coach from the data, including the page title and meta
description, and lists that class's upcoming sessions (soonest first) or that coach's classes. Ids match
in any letter case (?id=DEV shows Dev); unknown ids keep the default.
Coach filter#
Buttons with data-coach-filter on /trainers show cards whose data-tags (space-separated, for
example data-tags="Strength Nutrition") include that specialty; All shows everyone. Matching ignores
case, and [data-coach-count] shows how many match.
Membership pricing switch#
The switch [data-billing-toggle] (role="switch", aria-checked) swaps every [data-price] between
its data-monthly and data-annual values and every [data-billing-note] between its
data-monthly-note and data-annual-note:
<span data-price data-monthly="39" data-annual="32">39</span>BMI and calorie calculator#
The calculator on /membership (form[data-calc]) takes units (metric or imperial), height, weight, age,
sex and activity level and shows:
- BMI = weight (kg) ÷ height (m)², with a category (under 18.5, 18.5–25, 25–30, 30 and over) and a marker on a scale.
- BMR with the Mifflin–St Jeor equation, and daily calories (BMR × the activity factor in
#c-activity). - Suggested targets for cutting (−450 kcal) and building (+300 kcal).
Switching units changes the labels and the fields' min, max and placeholders. Results are in an
aria-live region, so screen readers announce them. Change the offsets in the "BMI & calorie
calculator" block of src/scripts/main.ts. The figures are general estimates, not medical advice; keep
the disclaimer on the page.
Free trial sign-up#
/trial contains a three-step form (form[data-trial]):
- Goal (
goalradio tiles). - Details:
first,last,email,phone,location(home club),start(no past dates inTIMEZONE),level(experience) andplan. - Review, then a required
termscheckbox.
Each step is validated before moving on, focus moves to the step's legend, the progress list marks the
current step with aria-current="step", and ?plan= preselects the plan (the value must match an
<option> in #t-plan, in any letter case: ?plan=Elite works like ?plan=elite). The confirmation
says nothing was sent.
Open/closed status and today's hours#
Elements with data-open-status="harbor" (or another location id) show "Open now · until 22:00",
"Closed · opens 06:00" or "Closed · opens 06:00 tomorrow". Hours lists with data-hours highlight
today's [data-hours-row] (counted from Monday) in the accent colour and mark it with
aria-current="date".
Mobile menu, header, reveals and counters#
The header gains a background on scroll. The mobile menu (data-menu-toggle, #mobile-menu) opens full
screen, locks page scrolling, updates aria-expanded and its label, and closes with Esc (focus
returns to the button) or when the window reaches the lg breakpoint. Elements with reveal fade in
when scrolled into view (everything above is revealed too). Numbers with data-count-to count up once
when visible. With reduced motion, reveals and counters appear immediately.
Back to top button#
src/scripts/back-to-top.ts adds a .back-to-top button that appears after you scroll down about one
screen. It scrolls back up (instantly with reduced motion), moves keyboard focus to #main and lifts
above toasts. Mark your own fixed UI with data-back-to-top-avoid (lift above it) or
data-back-to-top-hide (hide while covering it). To remove it, delete the file, its import in
Layout.astro and the .back-to-top rules at the end of global.css.
Forms#
| Form | Page | Notes |
|---|---|---|
| Contact | /contact |
topic can be preselected with ?topic=membership, pt, corporate or other (any letter case) |
| Newsletter | Footer of every page | Email only |
Free trial (data-trial) |
/trial |
See Free trial sign-up |
Calculator (data-calc) |
/membership |
Calculates in the browser; sends nothing |
Forms use the browser's constraints (required, type="email", pattern, minlength, min/max)
with friendly messages from fieldMessage(). Errors appear under each field, fields get aria-invalid
and aria-describedby, and the first problem is focused. Simple forms (data-validate) reset and reveal
their [data-success] message on success, which receives focus.
The demo forms don't send anything. For the contact and newsletter forms, add an action (for
example a Formspree endpoint) to the <form> and send the data in onValid() in
src/scripts/main.ts:
async function onValid(form: HTMLFormElement) {
const res = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) {
toast("Sorry, that didn't send. Please try again.");
return;
}
form.reset();
const ok = $("[data-success]", form);
ok.classList.remove("hidden");
ok.setAttribute("tabindex", "-1");
ok.focus();
}For the free trial, follow the comment "To send for real" in the trial's submit handler and post
new FormData(trial) before showing the confirmation. To run your own server code instead, add an Astro
adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel) and an
API endpoint such as src/pages/api/trial.ts with
export const prerender = false, then post to it.
Saved data (localStorage)#
| Key | Shape | Purpose |
|---|---|---|
forge-bookings |
Array of session ids, e.g. ["s04", "s17"] |
Classes the visitor booked |
getBookings() validates the value on every read: anything that isn't an array of known, unique session
ids (unreadable JSON, objects, numbers, unknown or duplicate ids) is dropped and the stored value is
repaired, so corrupt or tampered data can't break the timetable. If storage is blocked, booking still
works within the open dialog but isn't remembered. Visitors can clear bookings with the "Clear my
bookings" button; developers can run localStorage.removeItem("forge-bookings"). If you change session
ids, old bookings for removed ids are simply ignored.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>. The layout turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://fitness.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm buildCanonical URLs have no
.html(https://example.com/classes). -
/classand/trainerupdate their title and description in the browser when?type=/?id=is given; search engines that don't run JavaScript only see the default class and coach. If those pages matter for search, generate one static page per class or coach with a dynamic route (for examplesrc/pages/classes/[type].astrowithgetStaticPaths()overclasses). -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
Consider ExerciseGym structured data with each club's address and hours, and a sitemap with
@astrojs/sitemap.
Accessibility#
Built in:
- Skip link, labelled landmarks, one
<h1>per page and strong focus rings. - Day tabs follow the tabs pattern; the booking dialog is a native
<dialog>with focus return. - Filter buttons use
aria-pressed; the pricing switch usesrole="switch"andaria-checked. - Intensity is shown as a meter with a text label (
role="img", "Intensity: Medium"), not colour alone. - The multi-step form moves focus to each step's legend and marks the current step with
aria-current. - Toasts and calculator results are announced with
aria-live. - Reveals, counters and smooth scrolling respect
prefers-reduced-motion.
When editing, keep text contrast at WCAG AA on the dark background (the accent should only sit behind
dark text), label every new form field and keep one <h1> per page.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as classes.html rather than
classes/index.html (build.format: "file" in astro.config.mjs), and links point to /classes
without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /classes from classes.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/classesservesclasses.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/classestoclasses.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=/gym pnpm build. It becomes Astro's base; every link written with url() and every
link the script builds (timetable, class and coach pages, images) includes it.
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#
- A session doesn't appear. Its
type,coachandlocmust be existing ids (pnpm checkreports unknown ones), anddaymust be 0–6 (Monday–Sunday). - A class page shows Strength Lab instead of the class I linked. The
?type=value must be a key inclasses(any letter case:?type=BOXworks like?type=box, as on the timetable). - A coach page shows Maya Torres instead of the coach I linked. The
?id=value must be a key incoaches(any letter case:?id=DEVworks like?id=dev, as with the timetable's?coach=). - A coach card disappears with every filter. Its
data-tagsmust contain the filter values (space-separated; case doesn't matter). - The open/closed status is an hour or more out. Set
TIMEZONEinsrc/data/forge.tsto the club's IANA zone name. - Bookings vanished. They're stored per browser; private windows and cleared site data reset them.
/classesshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Forms "send" but nothing arrives. Connect them as described in Forms.
pnpm installskips a build script.pnpm-workspace.yamllists the packages allowed to run install scripts (esbuild,sharp,@tailwindcss/oxide); add any new one there.
License#
MIT. The code and all included SVG illustrations and portraits are free for personal and commercial use;
the download includes the license text in LICENSE.txt. Anton and Inter are licensed under the SIL Open
Font License. Forge Athletic Club, its coaches and members are fictional.