Overview#
Orbit is a bright, product-led SaaS marketing website for Orbit, a fictional shift-scheduling app made by Orbit Works Ltd (Manchester and Lisbon) for restaurants, cafés, hotels and shops. The home page opens on a live schedule demo that visitors can click, drag and auto-fill, and the site continues with feature deep-dives, pricing with a calculator, an integrations directory, customer stories, a changelog, a company and careers page, a sales form and a three-step trial sign-up. This is the Astro version: Astro 7, TypeScript and Tailwind CSS v4, with no UI framework. Interactivity is plain TypeScript, content lives in typed data files, and it builds to static HTML that runs on any host. The design is light and friendly: warm paper backgrounds, pastel panels, a coral accent, rounded UI and the Gabarito display face. This guide covers version 1.0.0. See it live at freeframe.dev/templates/orbit.
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/orbit into demo/ (only used for the demo on freeframe.dev) |
Pages#
| File | URL | Purpose and notable sections |
|---|---|---|
src/pages/index.astro |
/ |
Hero with the interactive schedule demo (#schedule-demo), logo strip, before/after panels, feature bento, product tour tabs (#tour), testimonials with metrics, integrations orbit, call to action with count-up stats |
src/pages/features.astro |
/features |
Six deep-dives with mock-ups (#builder, #availability, #swaps, #labor, #time, #messages), comparison with spreadsheets and group chats (#compare) |
src/pages/pricing.astro |
/pricing |
Monthly / annual switch, four plans, price calculator (#calculator), comparison table with sticky header (#compare), FAQ accordion (#faq) |
src/pages/integrations.astro |
/integrations |
Search, category filter (?category=payroll), 20 integration cards, detail drawer (?integration=paywell), API teaser |
src/pages/customers.astro |
/customers |
Headline stats, featured story with an SVG chart, nine stories filterable by industry (?industry=hotels) |
src/pages/changelog.astro |
/changelog |
Dated updates on a timeline, topic filter (?tag=mobile), email subscription form |
src/pages/about.astro |
/about |
Story, figures, values, team, open roles (#careers), offices |
src/pages/contact.astro |
/contact |
Routing panel, demo request form (?size=500&topic=careers preselects fields) |
src/pages/signup.astro |
/signup |
Three-step sign-up form (?plan=team shows the chosen plan) |
src/pages/404.astro |
404.html |
Not-found page with links back into the site |
Each page builds to a file of the same name (pricing.astro → pricing.html) and is linked without the
extension (/pricing).
Project structure#
orbit/
astro.config.mjs site URL, base path, file-style output, Tailwind plugin
src/
pages/ one .astro file per page (see above)
layouts/Layout.astro <html>, <head> metadata, the "js" class script, header, footer, scripts
components/
SiteHeader.astro logo, main menu, "Talk to sales", "Start free trial", mobile menu
SiteFooter.astro logo, newsletter form, link columns, status line, big wordmark
Logo.astro the planet-with-ring mark (inline SVG)
Avatar.astro an illustrated portrait from src/data/avatars.ts
Wordmark.astro a fictional customer logo from src/data/customers.ts
IntegrationLogo.astro an integration logo from src/data/integrations.ts
data/
nav.ts header, mobile menu and footer links
schedule.ts schedule demo rules (DEMO) and starting week (DEMO_TEAM)
home.ts product tour steps and home page testimonials
features.ts features page jump links, checklists and comparison rows
pricing.ts calculator prices and limits (PRICING)
plans.ts plan card features, comparison table (COMPARE) and FAQ
integrations.ts integrations, categories and badges
customers.ts industries, wordmarks, logo strip and customer stories
changelog.ts changelog entries and topic filters
company.ts team and open roles
contact.ts sales routing (CONTACT_ROUTES) and the sales form's choices
signup.ts plan names, trial length, sign-up steps and choices
avatars.ts SVG avatar drawings
lib/
site.ts site name, theme colour and the url() helper
icons.ts line icons as SVG strings
money.ts dollar formatting shared by pages and scripts
scripts/
main.ts all interactive features
back-to-top.ts the back-to-top button
styles/global.css Tailwind: design tokens and component classes
public/img/ favicon and social image
README.md, DOCS.md, CHANGELOG.mdCustomising#
Brand and colours#
The design tokens live in the @theme block of src/styles/global.css. Every utility such as bg-cream,
text-ink-soft or bg-coral-500 comes from them.
| Token | Default | Used for |
|---|---|---|
--color-paper |
#fdfaf5 |
Page background |
--color-cream, --color-sand, --color-line |
#f7f1e7, #efe6d8, #e7dece |
Alternate panels, slider tracks, borders |
--color-ink |
#1f1d3d |
Headings, body text, primary buttons |
--color-ink-soft, --color-ink-faint |
#4a4766, #64617e |
Secondary text and captions (AA on every light surface) |
--color-coral-100 … --color-coral-700 |
coral scale | Brand accent: logo, highlights, CTA panel; coral-700 for links |
--color-peach, -mint, -lilac, -butter, -sky, -blush |
pastels | Panels, shift blocks, chips |
--color-peach-ink, -mint-ink, … |
deep partners | Text placed on the matching pastel |
To change the brand colour, edit the coral scale:
@theme {
--color-coral-300: #93c5fd;
--color-coral-500: #3b82f6;
--color-coral-700: #1d4ed8;
}The schedule demo's coverage ring uses two literal colours in update() in src/scripts/main.ts (#1fa16a
when covered, #ff6b4a otherwise); change them with the brand. Also update themeColor in src/lib/site.ts
if you change the page background.
The hero background blobs are the .bg-mesh class, the highlighter behind headline words is .marker (with
.marker-peach, .marker-mint and .marker-lilac), and dashed rings are .orbit-ring, all in
src/styles/global.css. Orbit is light-only (color-scheme: light); there is no dark theme.
Typography#
Two Google Fonts are loaded by a <link> in src/layouts/Layout.astro: Gabarito for headings
(--font-display, applied to h1–h4 in the base layer and the font-display utility) and Rethink Sans
for text (--font-sans). Both stacks have system fallbacks. To swap a font, change the Google Fonts URL in the
layout and the matching token in global.css. The customer wordmarks and the footer's big "orbit" are SVG
<text> and use these fonts too.
Content#
Page text is in the .astro files. Anything that repeats or drives a feature is a typed array in src/data/
rendered with .map():
| File | Exports | What it holds |
|---|---|---|
schedule.ts |
DEMO, DEMO_TEAM |
Hours per shift, the overtime limit, how many people each AM/PM shift needs per day, labels and spoken descriptions; the five people and their starting week |
pricing.ts |
PRICING |
Team and Business prices per location and per employee (monthly and annual), Starter limits, the Enterprise threshold |
plans.ts |
PLAN_FEATURES, COMPARE, FAQ |
Plan card bullet points, the comparison table rows, the pricing FAQ |
integrations.ts |
INTEGRATIONS, CATEGORIES, BADGE_CLASSES |
Every integration card and its drawer content, the filter categories, badge colours |
customers.ts |
STORIES, INDUSTRIES, WORDMARKS, LOGO_STRIP |
Customer stories, industry filters and chip colours, customer logos |
changelog.ts |
CHANGELOG, CHANGELOG_TAGS |
Changelog entries and topic filters |
company.ts |
TEAM, ROLES |
Team grid and open roles on the about page |
contact.ts |
CONTACT_ROUTES, TEAM_SIZES, TOPICS, … |
Who answers each team size, and the sales form's options |
signup.ts |
PLAN_LABELS, TRIAL_DAYS, SIGNUP_STEPS, … |
Plan names shown on /signup?plan=…, the trial length, steps and choices |
home.ts, features.ts |
TOUR_STEPS, TESTIMONIALS, FEATURE_POINTS, … |
Product tour tab text, quotes, feature checklists, the spreadsheets comparison |
nav.ts |
mainNav, mobileNav, footerColumns |
Navigation |
For example, a new integration in src/data/integrations.ts:
{
slug: "rotabridge",
name: "Rotabridge",
category: "hr",
badge: "New",
summary: "Contracts and pay rates from Rotabridge, kept in sync.",
description: "Connect Rotabridge and new starters appear in Orbit with their contracted hours and pay rate.",
syncs: ["New starters and leavers", "Contracted hours", "Pay rates"],
setup: "10 minutes",
plan: "Team and up",
logo: '<rect width="40" height="40" rx="11" fill="#0f766e"/><circle cx="20" cy="20" r="8" fill="#fff"/>',
},The card, its search text (name, summary and category), the drawer, the filter counts and the
?integration=rotabridge link all follow. A new changelog entry is one object in CHANGELOG (newest first):
{
id: "tips-report",
date: "2026-11-02",
tags: ["new"],
title: "Tips report by shift",
paragraphs: ["What changed and why it matters."],
},The date is shown as "2 November 2026" by formatDate(). Four entries also have a small illustration, written
in src/pages/changelog.astro and matched by entry.id.
The plan card prices (per employee and per location, monthly and annual) are rendered from PRICING, the same
data the calculator uses, so changing a price there updates both. The calculator's starting figures in
pricing.astro ($177, 3 locations × $23…) are replaced by the script as soon as the page loads.
Use url() from src/lib/site.ts for every internal link (url("/pricing#faq"), url("/signup?plan=team"))
so it keeps working when the site is served from a sub-folder. Inside .astro markup, a literal { or } must
be written as {"{"} (as in the API teaser on the integrations page).
Images and artwork#
There are no photos. Everything is SVG or CSS:
- Logo:
src/components/Logo.astro, used in the header, footer, features page and integrations orbit. The same mark ispublic/img/favicon.svg. - Customer wordmarks (Lantern Coffee, Fernhill Hotels and others) are
WORDMARKSinsrc/data/customers.ts, drawn withcurrentColorand rendered byWordmark.astrowithrole="img"and anaria-label. To use a real logo, replace the component's<svg>with an<img>(keep a 170×40 aspect ratio and a realalt). - Integration logos are coloured rounded squares with a white glyph (
logoinsrc/data/integrations.ts, 40×40), rendered byIntegrationLogo.astrowitharia-hiddenbecause the name is always next to them. - Avatars are illustrated portraits in
src/data/avatars.ts, rendered byAvatar.astro(<Avatar name="maya" class="h-10 w-10" />). The same drawing is reused for several fictional people. To use photos, render an<img>with the same size classes (square; at least 640×640 px for the team grid, 96×96 px elsewhere) and a realalt. - Product mock-ups (schedule grids, phone screens, timesheets, charts) are HTML and inline SVG in the pages.
Decorative ones are
aria-hidden="true"; the Fernhill chart on the customers page has a text label. - Social image:
public/img/og-image.png(1200×630) is what the pages reference. Editpublic/img/og-image.svgand export it to PNG at 1200×630; most social sites don't display SVG.
Navigation, header and footer#
The header and footer are src/components/SiteHeader.astro and SiteFooter.astro, included once by the layout.
Their links come from src/data/nav.ts: mainNav (desktop header), salesLink, mobileNav (adds Changelog and
Talk to sales) and footerColumns. Each link can have a key; a page that passes the same current to
<Layout> gets aria-current="page" on those links, which also styles them:
<Layout title="Pricing — Orbit shift scheduling" description="…" page="pricing" current="pricing">To add a page, create src/pages/<name>.astro with a <Layout> and add its link to nav.ts. The header gains a
white background and shadow after scrolling (class is-scrolled, set in section 3 of src/scripts/main.ts).
Features#
All interactive code is src/scripts/main.ts, imported once by the layout and bundled by Astro. Each feature
checks that its markup is on the page, so the same script runs everywhere.
Schedule demo#
The home page hero has a week schedule (#schedule-demo) for the five people in DEMO_TEAM. Each cell is a
button with a data-shift of am, pm, off (approved time off, locked) or empty.
- Click or Enter/Space cycles a cell: empty → AM → PM → empty. A and P set AM or PM, Delete or Backspace clears. Arrow keys, Home and End move between cells (one tab stop for the grid).
- Drag an AM or PM shift onto another cell to move it; if that cell has a shift, the two swap. Time-off cells can't be changed or dropped on.
- The Needs row shows a tick or the number of missing people per day; the ring and percentage show total
coverage; under each name are scheduled hours, with "over" when someone goes past
DEMO.maxHours. - Auto-fill gives each open shift to the free person with the fewest hours without going over the limit. Reset restores the starting week, and Publish shows a toast (it sends nothing).
- Code:
initScheduleDemo()in section 4. The starting week isweekon each person insrc/data/schedule.ts; changeDEMO.needsto change what counts as covered. The coverage note and ring in the markup show the starting figures and are recalculated on load. - Accessibility: cells have labels such as "Maya, Friday: evening shift, 14:00 to 22:00"; the table has a caption with the keyboard instructions; the coverage note is a polite live region, and drag moves are announced. Drag is an extra: everything also works by click or keyboard.
Product tour#
#tour on the home page is a vertical role="tablist" with four tabs (TOUR_STEPS in src/data/home.ts)
that swap the HTML mock-ups written in index.astro (#tour-panel-<id>). initTabs() (section 5) handles
click, Arrow keys, Home and End with a roving tabindex. Any role="tablist" gets the same behaviour; panels
are linked with aria-controls.
Pricing and calculator#
On the pricing page, initPricing() (section 6) wires up:
- Billing switch
#billing, arole="radiogroup"of two buttons witharia-checked(annual by default). Elements withdata-price-monthlyanddata-price-annualshow one or the other;[data-billing-note]swaps between itsdata-monthlyanddata-annualtext. - Calculator
#calcwith#calc-locations(1–30),#calc-employees(5–600) and theplanradios. The total is locations × location fee + employees × employee fee fromPRICING, following the billing switch. It shows the yearly saving, suggests Starter for one location with up to 15 people, and suggests sales from 500 people (changing the button link too). - Comparison table
#compare, rendered fromCOMPAREinsrc/data/plans.ts: a value oftrueshows a tick,falsea dash,"unlimited"an ∞ on phones (the word is kept for screen readers) and any other string is shown as written. The header row isposition: stickybelow the floating header; its wrapper usesoverflow-clip(notoverflow-hidden) so sticky keeps working.
Sliders announce readable values (aria-valuetext), and the total is announced politely after changes stop.
Filters and search#
initFilters() (section 8) powers the integrations directory, customer stories and changelog. A container
[data-filter="name"] holds buttons with data-filter-value (all plus one per tag); items are
[data-filter-item="name"] with space-separated data-tags. Counts fill .pill-count, and the
[data-filter-status="name"] line says "Showing 3 integrations" (the noun comes from data-filter-noun and
data-filter-plural).
data-filter-paramkeeps the choice in the URL (category,industry,tag), so filtered views can be linked to and shared.- On the integrations page,
#int-search(data-search-input="integrations") matches every word against each item'sdata-searchtext and is kept in the URL as?q=.[data-filter-empty]shows when nothing matches, with a[data-filter-reset]button that clears the search. - Buttons use
aria-pressed; status lines are polite live regions.
Integration drawer#
Each integration card's name is a button with data-drawer-open. initDrawer() (section 9) copies the card's
<template data-detail> into the native <dialog id="int-drawer"> and opens it with showModal(), so focus is
trapped and Escape closes it. Clicking the dimmed backdrop or the close button also closes it, and focus returns
to the card. The open integration is kept in the URL (/integrations?integration=paywell) and opens on load.
The slide-in animation is the .drawer class in src/styles/global.css.
FAQ accordion#
[data-accordion] on the pricing page contains .acc-item rows (from FAQ); each button has aria-expanded
and aria-controls. One answer is open at a time; remove the loop over items in initAccordions()
(section 7) to allow several. Without JavaScript every answer is visible. Customer stories use native
<details> for "How they did it".
Header, menu, reveals and back to top#
The mobile menu (#menu-toggle, #mobile-menu) uses aria-expanded, closes on Escape and when the window grows
to desktop width. Elements with .reveal fade up as they scroll into view (reveal-d1 to reveal-d3 add a
delay), and everything above a revealed element is revealed too. Numbers with data-count count up once. The
integrations orbit on the home page turns slowly (.spin-slow); its logos are ORBIT_OUTER and ORBIT_INNER
at the top of index.astro. The back-to-top button (src/scripts/back-to-top.ts) appears after one screen of
scrolling and lifts itself above the demo's toast; delete the file, its import in the layout and the
.back-to-top styles to remove it. All motion is off for visitors who prefer reduced motion.
Forms#
All forms are validated by section 10 of src/scripts/main.ts with inline messages, aria-invalid,
aria-describedby and focus on the first problem. Rules come from required, type, minlength and
data-validate (work-email, phone, count, password, email-list). Fields inside a hidden container are
skipped.
| Form | Where | Checks |
|---|---|---|
data-form="newsletter" |
Footer of every page | Email required and well-formed |
data-form="updates" |
Changelog sidebar | Email, plus a frequency choice |
data-form="contact" |
Contact page | Names, work email (rejects FREE_EMAIL), optional phone, business, industry, team size, locations (whole number), rollout timing (only for 100+ people), at least one interest, optional message of 10+ characters, consent |
data-form="signup" |
Sign-up page | Step 1: name, email, password (8+ characters with a number or symbol). Step 2: business, industry, locations, team size. Step 3: optional invite emails (each checked), terms |
Sales routing. Choosing a team size on the contact page updates the "Who you'll talk to" panel from
CONTACT_ROUTES in src/data/contact.ts (avatar, name, text, reply time) and shows the rollout question for
100 people or more. ?size= accepts a number or small, mid, large, enterprise; ?topic= preselects the
topic.
Sign-up steps. Continue validates only the current step. The stepper marks steps as done, current
(aria-current="step") or upcoming, and focus moves to each step's heading. The final step shows a summary with
the trial end date (TRIAL_DAYS from today).
The demo forms don't send anything. On success they show a message that says so. To connect a form service
such as Formspree, replace the successSwap(...) call in initForms() with a request:
$$<HTMLFormElement>('[data-form="contact"]').forEach((form) => {
initContactRouting(form);
setupValidation(form, async () => {
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (res.ok) successSwap(form, '<p class="font-semibold">Thanks, we’ll be in touch.</p>');
else must("[data-form-status]", form).textContent = "Something went wrong. Please try again.";
});
});For the sign-up form, send new FormData(form) to your own backend in the last-step branch of initSignup()
(marked with a comment) instead of building the summary. Never create accounts or store passwords from
client-side code alone. Every input has a name, so FormData sends all fields. Astro builds a static site, so
there is no server code to receive forms; if you need it, add an
Astro adapter and an API endpoint
(src/pages/api/contact.ts) and post to that instead.
SEO and sharing#
Each page passes title and description to <Layout>; the layout writes the <title>, meta description,
canonical URL, og:* tags, theme-color and twitter:card. Canonical and og:url use the clean URL
(/pricing, not /pricing.html), and og:image is the absolute URL of /img/og-image.png. Both are built
from site in astro.config.mjs, which reads SITE_URL:
SITE_URL=https://orbit.example pnpm buildThe 404 page passes noindex, which outputs <meta name="robots" content="noindex"> and no canonical. For a
sitemap, add the official @astrojs/sitemap integration. There is no structured data; Organization,
SoftwareApplication (with offers from PRICING) and FAQPage JSON-LD (from FAQ) are good additions to the
layout or the pricing page.
Accessibility#
Built in:
- Skip link to
#main, landmarks (header, labellednavs,main,footer) and one<h1>per page with ordered headings. - Visible coral focus rings and colour contrast that passes WCAG AA; pastel chips always use their deep
-inkpartner for text. - ARIA patterns: menu button with
aria-expanded, tabs with rovingtabindex, a radiogroup billing switch, accordion buttons,aria-pressedfilters, a modal<dialog>, a step indicator witharia-current, and live regions for the demo, calculator, filters and toasts. - The schedule demo works fully by keyboard, with a caption that explains the keys.
prefers-reduced-motion: no reveals, orbit spin, floating badges, count-ups or drawer slide.
When editing, keep one <h1> per page, give every new input a <label>, keep decorative SVG aria-hidden, and
check contrast if you change the ink, pastel or coral tokens.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Tailwind is compiled by @tailwindcss/vite during the build, so
there is no separate CSS step; it scans src/ for class names, so classes built from data must be written out
in full (as the chip and badge colours in src/data/ are). Pages are written as pricing.html rather than
pricing/index.html (build.format: "file" in astro.config.mjs), and links point to /pricing without a
trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /pricing from pricing.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/pricingservespricing.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/pricingtopricing.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=/orbit pnpm build. It becomes Astro's base, and every link written with url() (including the
links the scripts write, such as the calculator's button and the success messages) 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 class built from data has no effect. Tailwind only sees complete class names in the source. Write
"bg-sky text-sky-ink"in the data, not`bg-${color}`. - Dragging shifts doesn't work on my phone. Drags start after a small movement; vertical swipes over the grid still scroll the page. Tapping cells always works.
- The comparison table header stops sticking. A parent with
overflow: hiddenbreaksposition: sticky. Useoverflow-clipinstead, as the table wrapper does. - A
{in page text breaks the build. Astro reads{and}in.astromarkup as expressions. Write{"{"}or move the text intosrc/data/. - Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and asset paths withurl()fromsrc/lib/site.tsrather than plain"/..."strings. /pricingshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Social previews show no image. Build with
SITE_URLset to your domain soog:imageis an absolute URL on that domain. 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, as in the repository's license (included as LICENSE.txt in the download). Free for personal and
commercial use. All artwork (logo, customer wordmarks, integration logos, avatars, mock-ups and social image) is
original SVG and covered by the same license.