Freeframe

Documentation

Launch

v1.0.1Updated Oct 9, 2026A polished SaaS product website with pricing, sign-in and dark mode.

Overview#

Launch is a complete marketing website for a software product, built for the fictional product-planning tool "Launch": a hero with a product screenshot built in HTML, feature deep-dives, a pricing page with a monthly/annual switch and comparison table, an about page, a validated contact form and a sign-in / create-account page, in light and dark themes. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.1. It builds to static HTML files with no UI framework and very little JavaScript, and can be hosted anywhere. See it live at freeframe.dev/templates/launch.

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/launch 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 with product screenshot built in HTML, logo cloud, features, keyboard section, count-up statistics, testimonials, pricing teaser, call to action
/features src/pages/features.astro Roadmaps, issues, releases, feedback and insights sections with HTML/SVG illustrations, integrations grid
/pricing src/pages/pricing.astro Plans with a monthly/annual switch, enterprise banner, comparison table, FAQ accordion
/about src/pages/about.astro Story and timeline, values, team, open roles
/contact src/pages/contact.astro Validated contact form, support options, office addresses
/login src/pages/login.astro Sign in / create account tabs with validation (demo only)
404 src/pages/404.astro Not-found page (most hosts show 404.html automatically)

Project structure#

launch/
├─ src/
│  ├─ pages/                 one .astro file per page (see above)
│  ├─ layouts/
│  │  └─ Layout.astro        <head> (title, meta, Open Graph, fonts, theme script), header, footer, scripts
│  ├─ components/
│  │  ├─ SiteHeader.astro    logo, desktop and mobile navigation, theme toggle, sign-in buttons
│  │  └─ SiteFooter.astro    footer links and copyright
│  ├─ data/
│  │  ├─ nav.ts              header navigation links
│  │  └─ pricing.ts          plans, comparison table and FAQ
│  ├─ lib/
│  │  └─ site.ts             site name, theme colours and the url() helper for links
│  ├─ scripts/
│  │  ├─ main.ts             all interactivity (theme, menu, reveals, pricing, FAQ, tabs, forms)
│  │  └─ back-to-top.ts      floating "Back to top" button
│  └─ styles/
│     └─ global.css          Tailwind theme (brand colours, font) and shared component classes
├─ public/img/               favicon.svg, og-image.png (and its og-image.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 a few lines of inline script in the layout that set the theme before the page paints. Layout.astro imports both, and Astro bundles them into one small module.

Customising#

Brand and colours#

All colours come from the @theme block at the top of src/styles/global.css. Buttons, links, accents, focus rings and the back-to-top hover state use the brand-* scale:

css
@theme {
  --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

  --color-brand-500: #7451fa;
  --color-brand-600: #6233ee;
  --color-brand-700: #5223d4;
  /* ...50 to 950 */
}

Replace all eleven --color-brand-* values (50 to 950) with a scale for your colour. Greys come from Tailwind's built-in zinc palette. The logo mark in SiteHeader.astro and SiteFooter.astro has its own gradient (#9277ff to #5223d4), so update it there too. The browser theme colour is site.themeColor in src/lib/site.ts.

Shared component classes (.btn, .btn-primary, .btn-secondary, .btn-dark, .btn-lg, .eyebrow, .text-gradient, .card, .label, .input, .field-error) are defined in src/styles/global.css. Tailwind rebuilds automatically in pnpm dev and pnpm build, so there's no separate CSS step.

Typography#

The site uses Inter, loaded from Google Fonts by a <link> in src/layouts/Layout.astro:

html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />

To change it, replace that link and update --font-sans in src/styles/global.css. To avoid Google Fonts, put the font files in public/fonts/, add an @font-face rule to global.css and remove the <link> and the two preconnect tags.

Content#

  • Page text lives in each src/pages/*.astro file. The page title and description are the title and description props on <Layout> at the top:

    html
    <Layout title="Pricing — Launch" description="Simple per-member pricing for Launch..." current="pricing">
  • Pricing lives in src/data/pricing.ts: plans (name, description, price.monthly / price.annual, notes, call to action, featured, feature list), comparison (groups of rows with a value per plan: true, false or text) and faqs. src/pages/pricing.astro renders them. Add a plan:

    ts
    {
      name: "Scale",
      description: "For organisations with many teams.",
      price: { monthly: "39", annual: "32" },
      note: { monthly: "billed monthly", annual: "billed annually ($384/member/year)" },
      cta: { label: "Contact sales", href: "/contact?topic=sales", primary: false },
      featured: false,
      features: ["Everything in Business", "Dedicated success manager"],
    },

    If you add a plan, add a matching value to every comparison row, add a column header to the comparison table, and change lg:grid-cols-3 on the plan grid in src/pages/pricing.astro.

  • Statistics on the home page are <span data-count="4000" data-suffix="+" data-decimals="0">4,000+</span>. The text inside is the final value (shown without JavaScript and with reduced motion); data-count, data-suffix and data-decimals drive the animation.

  • Contact topics are the <option> values of the #topic select in src/pages/contact.astro (sales, support, billing, partnerships, press, careers). A link to /contact?topic=sales preselects one.

  • Icons are inline Heroicons SVGs; copy one from the site and paste it in.

Images and artwork#

There are no photos: the product screenshot, charts and avatars are built from HTML, Tailwind classes and inline SVG. To use real images, put them in public/ and link them with the url() helper so they still work under a base path:

html
---
import { url } from "../lib/site";
---
<img src={url("/img/screenshot.png")} alt="The Launch roadmap view" width="1600" height="1000"
     class="rounded-2xl ring-1 ring-zinc-200" />

Keep screenshots around 16:10. For automatic resizing and WebP output, put images in src/ and use Astro's <Image /> component from astro:assets instead.

The favicon is public/img/favicon.svg. The social image is public/img/og-image.png (1200×630), exported from public/img/og-image.svg. Edit the SVG, export a new PNG, and replace the file.

  • Header links come from mainNav in src/data/nav.ts. The desktop and mobile menus both use it.
  • src/components/SiteHeader.astro is the header: logo, theme toggle, sign-in buttons and mobile menu. A link gets aria-current="page" when the page's current prop matches its href without the slash (current="pricing" highlights /pricing).
  • src/components/SiteFooter.astro is the footer.

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

html
---
import Layout from "../layouts/Layout.astro";
---

<Layout title="Careers — Launch" description="Join the team building Launch." current="careers">
  <section class="mx-auto max-w-7xl px-5 py-20 sm:px-6 lg:px-8">
    <h1 class="text-4xl font-bold tracking-tight">Careers</h1>
  </section>
</Layout>

Then add { href: "/careers", label: "Careers" } to mainNav and a link in the footer. Always write internal links as href={url("/careers")} (from src/lib/site.ts) so they keep working under a base path.

Dark mode#

src/layouts/Layout.astro has a small <script is:inline> in <head> that runs before the page paints. It adds the dark class to <html> from the saved choice or, if there isn't one, the system setting. The theme buttons ([data-theme-toggle]) are wired up in src/scripts/main.ts: they flip the class, update aria-pressed and the label, and save the choice in localStorage under theme. @custom-variant dark in global.css makes Tailwind's dark: classes follow that class.

  • Default to light: in the inline script, remove || (!t && window.matchMedia("(prefers-color-scheme: dark)").matches).
  • Disable dark mode: remove the theme toggle buttons from SiteHeader.astro, the dark line from the inline script, and the @custom-variant dark line from global.css.

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, so you can remove a feature from a page without touching the script.

Mobile menu#

Below the md breakpoint, #menu-button opens #mobile-menu. The script updates aria-expanded and the button label ("Open menu" / "Close menu"). It closes the menu on Escape (focus goes back to the button), when a link is chosen, and when the window widens past md. The header gets an is-scrolled class (border and shadow) once the page scrolls.

Scroll reveal#

Elements with the reveal class fade and slide in as they enter the viewport: an IntersectionObserver adds is-visible. reveal-delay-1 to reveal-delay-3 stagger them. The styles are in global.css and only apply when the inline script has added the js class, so content stays visible without JavaScript. With reduced motion everything shows immediately.

Pricing switch#

[data-billing-toggle] is a role="switch" button. Clicking it (or a [data-billing-label] word next to it) sets aria-checked, swaps every [data-price-monthly] number to its data-price-annual value and every [data-billing-note] to data-annual-note. pricing.astro writes those attributes from src/data/pricing.ts. The page starts on monthly.

FAQ accordion#

Each [data-accordion-button] toggles the panel named by its aria-controls. It sets aria-expanded, animates the panel open with grid-rows-[1fr], and makes closed panels inert so their content can't be focused. Questions and answers come from faqs in src/data/pricing.ts.

Sign-in tabs#

/login uses the ARIA tabs pattern: role="tablist", role="tab" with aria-selected and aria-controls, and role="tabpanel". Click a tab or use the Left/Right arrow keys. A URL hash matching a tab's data-tab opens it, so /login#signup (used by "Start free") opens "Create account".

Count-up statistics#

Each [data-count] element counts from zero to its value over 1.4 seconds when it scrolls into view, using data-decimals and data-suffix for formatting. With reduced motion it shows the final value at once.

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) and moves keyboard focus to #main. It hides while an in-page "Back to top" link is on screen and lifts itself 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). The styles are the .back-to-top rules in global.css. To remove it, delete the file, its import in Layout.astro and those styles.

Forms#

Forms marked data-validate are checked in the browser with the Constraint Validation API (required, type="email", minlength, pattern). On submit, every field is checked, each error appears in the element with the field's id plus -error (aria-invalid is set), and focus moves to the first problem. After a first submit, fields re-check as you type. A valid form hides itself and shows the element named in its data-success. A [data-form-reset] button in that message brings the form back.

Extra options on a field: data-message (custom error text) and data-match / data-match-message (must equal another field, e.g. "confirm password"). [data-password-toggle] shows or hides the password named by its aria-controls.

Form Page Fields and rules
#contact-form /contact Name, email (valid), company (optional), team size, topic, message (20+ characters), consent checkbox
#signin-form /login Email, password (8+ characters), "Keep me signed in"
#signup-form /login Name, email, password (8+ characters), terms checkbox

The demo forms don't send anything. onValid() in src/scripts/main.ts only waits 500 ms and shows the success message. To deliver messages with a form service such as Formspree, set the form's action in src/pages/contact.astro:

html
<form id="contact-form" action="https://formspree.io/f/your-id" method="post" data-validate data-success="contact-success" novalidate class="space-y-6" aria-describedby="form-note">

and send it from onValid():

ts
async function onValid(form: HTMLFormElement) {
  const success = document.getElementById(form.dataset.success ?? "");
  const res = await fetch(form.action, {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return; // show an error message here
  form.hidden = true;
  if (success) {
    success.hidden = false;
    success.focus();
  }
}

Every field has a name, so FormData includes them all. 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/contact.ts with export const prerender = false, then post to it. The sign-in and sign-up forms need a real authentication provider; treat them as layouts to connect.

Saved data (localStorage)#

Key Value Used for
theme "dark" or "light" The visitor's colour theme choice

Any other value is ignored and the system setting is used. If storage is blocked, the toggle still works for the current page. To reset, run localStorage.removeItem("theme") in the browser console.

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://launch.example.com). Set it to your domain when you build:

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

    or change the default in astro.config.mjs. Canonical URLs have no .html (https://example.com/pricing).

  • The 404 page passes noindex, which adds <meta name="robots" content="noindex"> and leaves out the canonical URL.

  • For a sitemap, add @astrojs/sitemap (pnpm astro add sitemap); it uses the same site. Add a public/robots.txt that points to it.

Accessibility#

Built in:

  • A "Skip to content" link to #main and landmarks (header, nav, main, footer).
  • Visible focus styles and keyboard support for the menu, tabs, accordion, billing switch, password toggle and the sideways-scrolling comparison table.
  • Correct ARIA: aria-current, aria-expanded, role="tab" / aria-selected, role="switch" / aria-checked, aria-pressed, aria-invalid with error messages linked through aria-describedby.
  • Reduced motion: reveals, count-ups, smooth scrolling and the back-to-top animation are turned off.
  • WCAG AA contrast in light and dark themes.

When editing, keep one <h1> per page and heading levels in order. Give every new field a <label> and an -error element, and re-check contrast if you change colours.

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 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=/launch pnpm build. It becomes Astro's base, and every link written with url() 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#

  • 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 the wrong domain or no image. Set SITE_URL to your domain at build time.
  • The theme flashes on load. Keep the <script is:inline> in the layout's <head>. Without is:inline, Astro would bundle it and run it too late.
  • Content stays invisible. .reveal elements are revealed by src/scripts/main.ts; make sure Layout.astro still imports it, and that the console shows no errors.
  • 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.
  • pnpm typecheck reports missing astro:* types. Run it through the script, which runs astro sync first to generate .astro/types.d.ts.

License#

MIT. The code and all included artwork are free for personal and commercial use; the download includes the license text in LICENSE.txt. Inter is licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation