Freeframe

Documentation

Orbit

v1.0.0Updated Oct 14, 2026A bright, friendly SaaS website for a shift-scheduling app, with a live drag-and-drop schedule demo in the hero.

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

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/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.md

Customising#

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:

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

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

ts
{
  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 is public/img/favicon.svg.
  • Customer wordmarks (Lantern Coffee, Fernhill Hotels and others) are WORDMARKS in src/data/customers.ts, drawn with currentColor and rendered by Wordmark.astro with role="img" and an aria-label. To use a real logo, replace the component's <svg> with an <img> (keep a 170×40 aspect ratio and a real alt).
  • Integration logos are coloured rounded squares with a white glyph (logo in src/data/integrations.ts, 40×40), rendered by IntegrationLogo.astro with aria-hidden because the name is always next to them.
  • Avatars are illustrated portraits in src/data/avatars.ts, rendered by Avatar.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 real alt.
  • 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. Edit public/img/og-image.svg and export it to PNG at 1200×630; most social sites don't display SVG.

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 is week on each person in src/data/schedule.ts; change DEMO.needs to 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, a role="radiogroup" of two buttons with aria-checked (annual by default). Elements with data-price-monthly and data-price-annual show one or the other; [data-billing-note] swaps between its data-monthly and data-annual text.
  • Calculator #calc with #calc-locations (1–30), #calc-employees (5–600) and the plan radios. The total is locations × location fee + employees × employee fee from PRICING, 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 from COMPARE in src/data/plans.ts: a value of true shows a tick, false a dash, "unlimited" an ∞ on phones (the word is kept for screen readers) and any other string is shown as written. The header row is position: sticky below the floating header; its wrapper uses overflow-clip (not overflow-hidden) so sticky keeps working.

Sliders announce readable values (aria-valuetext), and the total is announced politely after changes stop.

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-param keeps 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's data-search text 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:

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

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

The 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, labelled navs, 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 -ink partner for text.
  • ARIA patterns: menu button with aria-expanded, tabs with roving tabindex, a radiogroup billing switch, accordion buttons, aria-pressed filters, a modal <dialog>, a step indicator with aria-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#

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

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /pricing serves pricing.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 /pricing to pricing.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=/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: hidden breaks position: sticky. Use overflow-clip instead, as the table wrapper does.
  • A { in page text breaks the build. Astro reads { and } in .astro markup as expressions. Write {"{"} or move the text into src/data/.
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, and write internal links and asset paths with url() from src/lib/site.ts rather than plain "/..." strings.
  • /pricing 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.
  • Social previews show no image. Build with SITE_URL set to your domain so og:image is an absolute URL on that domain.
  • 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, 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.

Search documentation

Search the guides and every template's documentation