Overview#
Orbit is a product-led marketing website for Orbit, a fictional shift-scheduling app made by Orbit Works (Overland Park and Lenexa) for restaurants, cafés, hotels and shops. The home page opens on a live week schedule that visitors can click, drag and auto-fill while coverage updates, followed by a product tour, customer metrics and an integrations orbit. The site also has feature deep-dives, pricing with a price calculator and a comparison table, a searchable integrations directory with a detail drawer, filterable customer stories, a changelog, an about and careers page, a sales form that routes by team size and a three-step free trial sign-up. This is the Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4), version 1.0.1, ported page for page from the HTML template. The design is bright and friendly: warm paper surfaces, a deep indigo ink, a coral brand color, pastel panels and illustrated SVG avatars. It generates to static files. Live demo: https://freeframe.dev/templates/orbit
The site is a front end only. The schedule demo runs in the browser, and the forms don't send anything until you connect your own services (see Forms).
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev # http://localhost:3000Other scripts:
| Script | What it does |
|---|---|
pnpm generate |
Static site in .output/public/ (nuxt generate): every page prerendered to HTML |
pnpm build |
A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint (@nuxt/eslint) |
pnpm typecheck |
nuxt prepare and vue-tsc |
pnpm build:demo |
Generate for freeframe.dev under /demos/nuxt/orbit into demo/ |
Pages#
| Route | File | Purpose and features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with the interactive schedule demo (#schedule-demo), customer wordmarks, before/after panels, feature bento, product tour tabs (#tour), testimonials, integrations orbit, count-up figures and a call to action |
/features |
app/pages/features.vue |
Schedule builder (#builder), availability (#availability), swaps (#swaps), labor cost (#labor), time clock (#time), announcements (#messages) and an Orbit vs spreadsheets table (#compare) |
/pricing |
app/pages/pricing.vue |
Monthly / annual switch, four plans, price calculator (#calculator), sticky comparison table (#compare), FAQ (#faq) |
/integrations |
app/pages/integrations.vue |
Search and category filter (?q=, ?category=), 20 integration cards, a detail drawer (?integration=paywell), an API call to action |
/customers |
app/pages/customers.vue |
Headline figures, a featured story with an SVG chart, nine stories with an industry filter (?industry=hotels) |
/changelog |
app/pages/changelog.vue |
Updates on a timeline, topic filter (?tag=mobile), email subscription form |
/about |
app/pages/about.vue |
Story, figures, values, team, open roles (#careers), offices |
/contact |
app/pages/contact.vue |
"Who you'll talk to" card and the sales form (?size=500, ?topic=careers) |
/signup |
app/pages/signup.vue |
Three-step free trial sign-up (?plan=starter, team or business) |
| 404 | app/error.vue |
Orbit illustration and links back to the site (generated as 404.html, noindex) |
Project structure#
nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind (@tailwindcss/vite)
app/
app.vue <NuxtLayout> around <NuxtPage>
layouts/default.vue skip link, header, the page's <main>, footer, live region, toast, back to top
error.vue 404 page (and any other error)
router.options.ts keeps query strings in links as they were written (%20, not +)
pages/ one file per page (index.vue, pricing.vue...)
components/
SiteHeader.vue sticky header, current page, mobile menu
SiteFooter.vue footer columns, NewsletterForm.vue and CurrentYear.vue
ScheduleDemo.vue hero schedule demo
PriceCalculator.vue locations + employees estimate
FaqAccordion.vue FAQ accordion
IntegrationDrawer.vue integration details in a <dialog>
ContactForm.vue sales form; ContactRouteCard.vue is the "Who you'll talk to" card
SignupWizard.vue three-step sign-up
UpdatesForm.vue changelog subscription
FieldError.vue a field's error message
SuccessBox.vue the message that replaces a sent form (SuccessTick.vue is its check mark)
OrbitMark.vue, OrbitAvatar.vue, IntegrationLogo.vue artwork
SiteAnnouncer.vue, SiteToast.vue, BackToTop.vue
composables/
useValidation.ts inline validation shared by every form
useFilter.ts filters, search, counts and empty states for lists
usePageSeo.ts title, description, canonical and Open Graph tags
useCurrentPage.ts aria-current for menu links
useSignupPlan.ts the plan from ?plan=
data/
nav.ts header, mobile and footer links
schedule.ts schedule demo rules and team
pricing.ts prices, calculator settings, FAQ
integrations.ts integrations, categories and badges
contact.ts team sizes, sales routing and form options
signup.ts plan labels, trial length and sign-up options
avatars.ts illustrated portraits
plugins/
reveal.client.ts scroll reveals and count-up numbers
stable-build.server.ts keeps generated pages free of build timestamps
utils/
validation.ts form rules (required, email, work email, phone, count, password, email list, groups)
url.ts URL parameter helpers and reduced-motion check
announce.ts screen-reader announcements and toasts
format.ts money(), plural(), makeRef()
assets/
css/main.css Tailwind import, fonts, @theme tokens, component classes
fonts/ woff2 files and their OFL licenses
public/img/
favicon.svg, og-image.png (1200×630) and og-image.svg (its source)Nuxt imports components, composables and utilities automatically, so pages use <ScheduleDemo />,
useFilter() or money() without import lines. Data files are imported by path (~/data/pricing).
Customizing#
Brand and colors#
The design tokens live in the @theme block of app/assets/css/main.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 sections and inputs, hovers and tracks, hairlines |
--color-ink |
#1f1d3d |
Headings, body text, primary buttons |
--color-ink-soft, --color-ink-faint |
#4a4766, #64617e |
Secondary text, captions (both AA on every surface) |
--color-coral-100 … --color-coral-700 |
coral scale | Brand: 500 for fills, 700 for text and links |
--color-peach, -mint, -lilac, -butter, -sky, -blush |
pastels | Panels, shift blocks and charts, each with an -ink partner for text on top |
To change the brand color, edit the coral scale (keep 700 dark enough for text on white):
@theme {
--color-coral-300: #93c5fd;
--color-coral-500: #3b82f6;
--color-coral-700: #1d4ed8;
}A few colors are written into SVG art and the schedule demo: the coverage ring (#1fa16a when everything is
covered, #ff6b4a otherwise, in app/components/ScheduleDemo.vue), the Orbit mark in
app/components/OrbitMark.vue and public/img/favicon.svg, the avatars and the charts. Update those too.
Orbit is a light-only design (color-scheme: light); there is no dark theme. The theme-color meta tag is in
nuxt.config.ts.
Typography#
Two fonts are self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css. They are
the Latin woff2 files Google Fonts serves the HTML version, so text sets at exactly the same width: Rethink
Sans (text, variable weight 400–700 plus italic) and Gabarito (headings and display, variable weight
500–800). --font-sans and --font-display in @theme name them, and headings get Gabarito from
h1, h2, h3, h4 { font-family: var(--font-display) }. To swap a font, put its woff2 file in
app/assets/fonts/, change the @font-face rule (family name, weights and src) and update the token.
Content#
Page copy is in each file in app/pages/. The content behind the interactive parts lives in app/data/:
| File | Export | What it holds |
|---|---|---|
schedule.ts |
DEMO |
Location and week labels, shift length, the 40-hour limit, people needed per shift and day, labels, times and spoken names |
TEAM |
The people in the demo, their portraits and the week they start with | |
pricing.ts |
PRICING |
Team and Business prices per location and per employee (monthly and annual), free limits and the sales threshold |
DEFAULT_BILLING, BILLING_NOTES |
Annual by default, and the note under the switch | |
CALCULATOR |
Slider ranges, starting values and the plan choices | |
FAQ |
FAQ questions and answers | |
integrations.ts |
INTEGRATIONS |
Every integration: name, category, badge, summary, drawer text, what syncs, setup time, plans and logo |
CATEGORIES, BADGE_CLASSES |
Filter categories and badge colors | |
contact.ts |
CONTACT_ROUTES, DEFAULT_ROUTE |
Who the visitor talks to for each team size |
SIZES, TOPICS, INDUSTRIES, INTERESTS, TIMELINES |
Sales form options | |
signup.ts |
PLAN_LABELS, DEFAULT_PLAN, TRIAL_DAYS |
Plan names for ?plan=, the default plan and the trial length |
STEPS, BUSINESS_TYPES, LOCATION_OPTIONS, TEAM_OPTIONS, CONNECT_LATER |
Sign-up steps and options | |
nav.ts |
NAV, MOBILE_NAV, FOOTER_NAV |
Header menu, mobile menu and footer columns |
avatars.ts |
AVATARS |
Illustrated portraits by name, drawn by <OrbitAvatar name="maya" /> |
To add an integration, add an object to INTEGRATIONS; the card, the drawer, the counts and the search pick it
up. category must be a value of CATEGORIES, and logo is the inside of a 40×40 SVG:
{
slug: "shiftcloud",
name: "Shiftcloud",
category: "hr",
badge: "New",
summary: "Bring PTO balances into Orbit.",
description: "Shiftcloud shares PTO balances so managers see them next to time-off requests.",
syncs: ["PTO balances", "Approved time off"],
setup: "5 minutes",
plan: "Team and up",
logo: '<rect width="40" height="40" rx="11" fill="#0f766e"/>',
},To change the demo week, edit week in TEAM ("am", "pm", "off" for approved time off or ""), and
needs in DEMO for how many people each shift needs per day (Monday first).
Customer stories and changelog entries are written in their pages. Each story or update is an <li> with
v-bind="f.item(STORIES[0])" (or UPDATES), whose topics come from the STORIES or UPDATES array at the top
of the page, in the same order; add an entry there when you add an item.
Images and artwork#
There are no photos. Everything is SVG or CSS:
- Logo:
app/components/OrbitMark.vue(header and footer); the same mark ispublic/img/favicon.svg. - Integration logos:
<IntegrationLogo slug="paywell" />draws thelogoof an entry inapp/data/integrations.ts. - Customer wordmarks: inline SVG (170×40, drawn in
currentColor) in the home and customers pages. Each hasrole="img"and the name asaria-label; keep those when you replace them. - Avatars:
<OrbitAvatar>for the schedule demo and the sales card, inline SVG portraits (80×80) in the pages. To use photos, replace each<svg>with an<img>of the same size classes, square and at least 160×160 px, with a realalt(oralt=""where a name is already next to it). Files inpublic/are served from the site root; write their paths with the base path, e.g.:src="`${$config.app.baseURL}img/team/maya.jpg`". - Product mock-ups: the schedule, tour panels, phone screens and charts are HTML and inline SVG, marked
aria-hidden="true"where they are decorative. - Background: the pastel blobs (
.bg-mesh) and dotted rings (.orbit-ring) are classes inmain.css. - Social image:
public/img/og-image.png(1200×630). Editpublic/img/og-image.svgand export it to PNG at 1200×630 to change it; most social sites don't display SVG.
Navigation, header and footer#
The header (app/components/SiteHeader.vue) and footer (app/components/SiteFooter.vue) render on every page
from app/layouts/default.vue. Menu items come from NAV, MOBILE_NAV and FOOTER_NAV in
app/data/nav.ts. To add a page, create app/pages/<route>.vue with its own usePageSeo() call and a
<main id="main" tabindex="-1" class="flex-1 focus:outline-none"> root, add the route to routes in
nuxt.config.ts so nuxt generate writes it, then add it to the menus. The current page gets
aria-current="page" (from useCurrentPage()), which also styles it. The header pill gains a shadow after
scrolling (class is-scrolled).
Links between pages use <NuxtLink>, so they open without a full page load and keep working under a base path.
NuxtLink marks a link to the current page with aria-current on its own; the menus pass their own value, and
the logo passes :aria-current="undefined" so the home page doesn't mark it.
Features#
Schedule demo#
The home page hero (#schedule-demo, app/components/ScheduleDemo.vue) is a 5×7 grid of buttons, one per
person and day.
- Mouse: click a cell to cycle empty → AM → PM → empty. Drag an AM or PM shift onto another cell to move it, or to swap with the shift already there (a drag starts after 6 px; time off can't be a drop target).
- Keyboard: the grid is one tab stop. Arrow keys move between cells (no wrapping), Home and End jump to Monday and Sunday, Enter or Space cycles, A and P set a morning or evening shift, Delete or Backspace clears.
- Time off (
"off") is locked: clicking it announces "Priya has approved time off on Sunday." - Live totals: the coverage ring and percentage, the "Needs" row (−1, −2 or a check mark per day), hours per
person (flagged "over" above
DEMO.maxHours) and the open-shifts note update on every change. - Auto-fill gives each open shift to the free person with the fewest hours, never over 40 hours; from the
starting week it reaches 100% with 40/40/40/40/24 hours. Reset returns to
TEAM; Publish shows a toast. - Accessibility: each cell's
aria-labelsays who, which day and what shift; changes are announced in the polite live region; the table has a caption explaining the keys. With reduced motion the pop and shake animations are skipped.
Product tour#
The tour on the home page (#tour) is a vertical role="tablist" (two columns on phones) with
aria-selected, a roving tabindex and one role="tabpanel" per step; tour in app/pages/index.vue is the
selected step. Arrow keys move and wrap, Home and End jump. To add a step, add a tab button and a panel with the
next index.
Pricing and calculator#
On app/pages/pricing.vue:
- Billing switch (
#billing,role="radiogroup"with tworole="radio"buttons, arrow keys switch). Annual is the default (DEFAULT_BILLING). The plan prices and the note under the switch followbilling. - Calculator (
app/components/PriceCalculator.vue,#calc): locations (1–30), employees with shifts (5–600) and the Team or Business plan. The estimate is locations × location price + employees × employee price for the current billing period; with the defaults that's $177 a month annually (saving $540 a year) or $222 monthly. One location with up to 15 people suggests the free Starter plan; 500 or more people suggests talking to sales (/contact?size=500). The call to action follows the plan (/signup?plan=team). - Comparison table (
#compare) is written out in the page; its header row sticks below the site header while you scroll the table. - FAQ:
<FaqAccordion :items="FAQ" />(app/components/FaqAccordion.vue). The first answer starts open, one is open at a time, buttons usearia-expandedandaria-controls. Without JavaScript every answer shows.
The sliders announce readable values (aria-valuetext) and the new estimate is read out 0.6 s after you stop
changing them.
Filters and search#
useFilter() (app/composables/useFilter.ts) powers the integrations directory, the customer stories and the
changelog. It gets every item's tags (and search text), the URL parameter (category, industry or tag) and
the noun for "Showing 9 stories", and gives the page f.current, f.count(value) for the button counts,
f.item(entry) for each item (its tags, and hidden when it doesn't match), f.status, f.empty,
f.search() (every word must match; kept in ?q=) and f.reset(), which clears both and puts focus back in
the search box. Filter buttons use aria-pressed, and the status line is a polite live region. The choice is
written to the URL with history.replaceState, so filtered views can be shared; unknown values show everything.
Integration drawer#
IntegrationDrawer (app/components/IntegrationDrawer.vue) shows an integration's details in a native
<dialog> that slides in from the right (.drawer in main.css). Each card's name is a button that covers the
card and calls the drawer's open(slug, button). ?integration=paywell opens it on load. Escape, the close
button or a click on the dimmed backdrop close it, the URL parameter is removed and focus returns to the card.
Sales routing#
On /contact, the team size chosen in ContactForm (v-model:size) picks the person in the "Who you'll talk
to" card (ContactRouteCard) from CONTACT_ROUTES (Fergus for 1–24 with a free-trial link, Amara for 25–499,
Siobhan for 500+). For 100+ people a required "When would you like to roll out?" question appears. ?size=
takes a head count (?size=500) or a key (small, mid, large, enterprise); ?topic= preselects the
topic.
Sign-up#
SignupWizard (app/components/SignupWizard.vue) has three steps with a progress stepper
(aria-current="step", check marks on finished steps). Each step is validated before the next and focus moves
to the new step's heading. Step 1 has a show/hide password button (aria-pressed) and a four-part strength
meter; step 3 takes optional teammate emails (every address is checked). The welcome message names the plan,
the business and the trial end date (TRIAL_DAYS from today). ?plan=starter, team or business changes the
plan label on the page and the hidden plan field (useSignupPlan()).
Header, menu, reveals and back to top#
The mobile menu (#menu-toggle, #mobile-menu) uses aria-expanded, closes on Escape (returning focus to the
button), on navigation and when the window grows to desktop width. Elements with .reveal fade up as they
enter the viewport (reveal-d1 to reveal-d3 add a delay); app/plugins/reveal.client.ts also reveals
everything above. Numbers with data-count count up once. Nothing animates with reduced motion or without
JavaScript. A back-to-top button (app/components/BackToTop.vue) appears after one screen of scrolling and
lifts itself above the toast; remove it from app/layouts/default.vue and delete the .back-to-top styles to
drop it.
Things that read the browser#
Anything that depends on the URL, the clock or the screen is read in onMounted(), after Vue has taken over
the generated HTML: query parameters, the copyright year (CurrentYear.vue shows the year written in it until
then), the trial end date and the filters. The generated pages show the default state, so there are no
hydration mismatches. Keep new code the same way: read window, Date or localStorage in onMounted() or
an event handler, never while rendering. Orbit saves nothing in localStorage.
Forms#
All forms use useValidation() from app/composables/useValidation.ts, which reads the rules from each
field's attributes (required, type, data-validate, minlength, max, data-label, data-msg,
data-group, data-min-checked) and applies app/utils/validation.ts: messages appear under the field
(<FieldError>), linked with aria-describedby and aria-invalid, focus moves to the first problem, a status
line says "Please fix the highlighted fields." and errors update as the visitor types.
| Form | Where | Checks |
|---|---|---|
| Product updates | app/components/NewsletterForm.vue (footer) |
Email required and well-formed |
| Changelog updates | app/components/UpdatesForm.vue (/changelog) |
Email; every release or monthly digest |
| Talk to sales | app/components/ContactForm.vue (/contact) |
Names, work email (data-validate="work-email" rejects FREE_EMAIL domains), optional phone, business name, industry, team size, locations (whole number 1–999), rollout timing for 100+ people, at least one interest, optional message of at least 10 characters, consent |
| Sign-up | app/components/SignupWizard.vue (/signup) |
Name, email, password (8+ characters with a number or symbol); business name, type, locations and team size; optional invite list; terms |
Success messages. The newsletter and updates forms ask the visitor to look out for a confirmation email. The
sales form says who has the request (from CONTACT_ROUTES), repeats their reply time and shows a short reference
(OR- and four characters, made in the browser by makeRef(); use your form service's own reference instead if
it returns one). The sign-up summary says a link to confirm the email address has been sent. Change any of these
if your service works differently.
The forms don't send anything until you connect them, although their success messages read as if they had.
To connect a form service such as Formspree, change the callback passed to
useValidation(), for example in ContactForm.vue:
const v = useValidation(async (form) => {
const data = new FormData(form);
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: data,
headers: { Accept: "application/json" },
});
if (res.ok) sent.value = { first: String(data.get("first_name") ?? "").trim(), size: size.value, ref: makeRef() };
});Every field has a name, so FormData includes it. If you deploy the server build (pnpm build) instead of
static files, you can also post to a Nuxt server route: create server/api/contact.post.ts with
export default defineEventHandler(async (event) => { const body = await readFormData(event); … }) and send the
form there with $fetch("/api/contact", { method: "POST", body: new FormData(form) }). Create real accounts
from the sign-up's callback on your backend, never in the browser.
SEO and sharing#
Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which uses Nuxt's
useSeoMeta() for the title, description, Open Graph and Twitter card tags, and adds a canonical link. URLs are
absolute: set SITE_URL to your domain when building.
SITE_URL=https://orbit.example.com pnpm generateThe 404 page passes noindex: true (robots noindex, no canonical). The social image is
public/img/og-image.png. A sitemap and robots.txt are easy to add with the @nuxtjs/sitemap and
@nuxtjs/robots modules, or as files in public/. There is no structured data; Organization and
SoftwareApplication JSON-LD (with useHead({ script: [...] })) are good additions.
Accessibility#
Built in:
- Skip link to
#main, landmarks (header, labelednavs,main,footer) and oneh1per page. - Visible focus rings (
:focus-visiblein coral) and text colors that pass WCAG AA on every surface. - ARIA patterns: menu button with
aria-expanded, a grid of labeled buttons with a single tab stop, tabs with a rovingtabindex, a radio group for billing, accordion buttons,aria-pressedfilters, a modal<dialog>with focus return, a step indicator witharia-current="step", and live regions for the schedule, calculator, filters, routing card and forms. prefers-reduced-motion: no reveals, count-ups, orbit spin, floating badges or pop animations; instant back-to-top.- Decorative SVG and mock-ups are
aria-hidden; meaningful graphics (wordmarks, charts) have labels.
When editing, keep one h1 per page, give every new input a <label>, keep alt="" on decorative images, and
check contrast if you change the ink or coral tokens.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (pricing.html,
linked as /pricing), plus 404.html from app/error.vue. Next to each page is a folder with its
_payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host
(Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /pricing to pricing.html on
their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/orbit pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are
still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint
load; replace it with typescript@7 once they support it.
Troubleshooting#
- Auto-fill leaves gaps. Nobody free can take the shift without going over
DEMO.maxHours; the announcement says so. Raise the limit or add a person toTEAM. - Prices changed but a plan card still shows the old feature list. Prices come from
PRICING, but the plan cards' text and feature lists are written inapp/pages/pricing.vue; edit them there. - A filter link opens the full list. The value must match a filter value exactly (case doesn't matter):
a
CATEGORIESvalue, or one of thevaluespassed touseFilter()at the top of the page. - A new story or update is never hidden by the filter. Add its topics to
STORIESorUPDATESin the same position as the item. - A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.- A form shows its success message but nothing arrives. The forms send nothing until you connect them; see Forms.
- A Tailwind class written in a template string has no effect. Write class names in full inside quotes; Tailwind can't find classes built from pieces.
- The links break under a sub-folder. Build with
DEMO_BASE_PATHset to that folder.
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and
at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Gabarito and
Rethink Sans are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt).