Freeframe

Documentation

Fitness

v1.0.4Updated Oct 16, 2026A bold, dark gym and fitness studio site with a bookable class timetable.

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).

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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.json

Pages 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:

css
@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:

ts
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:

ts
harbor: {
  name: "Forge Harbor",
  hours: [["05:30", "22:00"], /* Tue … Sat */ ["08:00", "18:00"]],
},

A session in the timetable:

ts
{ 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:

html
<img src={url("/img/coach-lena.jpg")} alt="Lena Brandt" width="600" height="750" />
  • src/components/SiteHeader.astro is the header and full-screen mobile menu. A link gets aria-current="page" (and its highlighted style) when the page's current prop matches it, for example current="membership"; class.astro passes current="classes" and trainer.astro current="trainers".
  • src/components/SiteFooter.astro is the footer with links and the newsletter form.

To add a page, create src/pages/pt.astro:

html
---
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 in TIMEZONE.
  • 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:

html
<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]):

  1. Goal (goal radio tiles).
  2. Details: first, last, email, phone, location (home club), start (no past dates in TIMEZONE), level (experience) and plan.
  3. Review, then a required terms checkbox.

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:

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 title and description to <Layout>. The layout turns them into <title>, the meta description, og:title, og:description and a summary_large_image Twitter card.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://fitness.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm build

    Canonical URLs have no .html (https://example.com/classes).

  • /class and /trainer update 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 example src/pages/classes/[type].astro with getStaticPaths() over classes).

  • 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 uses role="switch" and aria-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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /classes serves classes.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /classes to classes.html, change build.format to "directory" in astro.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, coach and loc must be existing ids (pnpm check reports unknown ones), and day must be 0–6 (Monday–Sunday).
  • A class page shows Strength Lab instead of the class I linked. The ?type= value must be a key in classes (any letter case: ?type=BOX works 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 in coaches (any letter case: ?id=DEV works like ?id=dev, as with the timetable's ?coach=).
  • A coach card disappears with every filter. Its data-tags must contain the filter values (space-separated; case doesn't matter).
  • The open/closed status is an hour or more out. Set TIMEZONE in src/data/forge.ts to the club's IANA zone name.
  • Bookings vanished. They're stored per browser; private windows and cleared site data reset them.
  • /classes shows a 404 on my host. The host doesn't map extensionless URLs to .html files. Set build.format: "directory" in astro.config.mjs and rebuild.
  • Forms "send" but nothing arrives. Connect them as described in Forms.
  • pnpm install skips a build script. pnpm-workspace.yaml lists 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.

Search documentation

Search the guides and every template's documentation