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).
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/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.jsonPages 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:
@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:
<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/*.astrofile. The page title and description are thetitleanddescriptionprops 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,falseor text) andfaqs.src/pages/pricing.astrorenders 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
comparisonrow, add a column header to the comparison table, and changelg:grid-cols-3on the plan grid insrc/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-suffixanddata-decimalsdrive the animation. -
Contact topics are the
<option>values of the#topicselect insrc/pages/contact.astro(sales,support,billing,partnerships,press,careers). A link to/contact?topic=salespreselects 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:
---
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.
Navigation, header and footer#
- Header links come from
mainNavinsrc/data/nav.ts. The desktop and mobile menus both use it. src/components/SiteHeader.astrois the header: logo, theme toggle, sign-in buttons and mobile menu. A link getsaria-current="page"when the page'scurrentprop matches itshrefwithout the slash (current="pricing"highlights/pricing).src/components/SiteFooter.astrois the footer.
To add a page, create src/pages/careers.astro:
---
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, thedarkline from the inline script, and the@custom-variant darkline fromglobal.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:
<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():
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
titleanddescriptionto<Layout>. The layout turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://launch.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm buildor 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 samesite. Add apublic/robots.txtthat points to it.
Accessibility#
Built in:
- A "Skip to content" link to
#mainand 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-invalidwith error messages linked througharia-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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ 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 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=/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 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 the wrong domain or no image. Set
SITE_URLto your domain at build time. - The theme flashes on load. Keep the
<script is:inline>in the layout's<head>. Withoutis:inline, Astro would bundle it and run it too late. - Content stays invisible.
.revealelements are revealed bysrc/scripts/main.ts; make sureLayout.astrostill imports it, and that the console shows no errors. pnpm installskips a build script.pnpm-workspace.yamllists the packages allowed to run install scripts (esbuild,sharp,@tailwindcss/oxide); add any new one there.pnpm typecheckreports missingastro:*types. Run it through the script, which runsastro syncfirst 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.