Overview#
Launch is a complete marketing website for a software product, built for the fictional product-planning tool "Launch" by Launch Labs, Inc. in San Francisco: a hero with a product screenshot built in HTML, feature deep-dives with HTML/SVG charts, a pricing page with a monthly/annual switch, a comparison table and an FAQ, an about page with team and open roles, a validated contact form and a sign-in / create-account page, in light and dark themes. This is the Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4), version 1.0.2, ported page for page from the HTML template, and it generates to static files. The template page, with a link to the live demo, is at freeframe.dev/templates/launch.
The site is a front end only: the forms check what visitors type and show a success message, but don't send anything until you connect them (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/launch into demo/ |
Pages#
| Route | File | Purpose and features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with a roadmap screenshot built in HTML, customer logos, feature grid, command menu, count-up statistics (STATS), testimonials, pricing summary, call to action |
/features |
app/pages/features.vue |
Section links, roadmaps (Gantt chart), issues, releases (changelog), feedback, insights (chart), integrations grid, security |
/pricing |
app/pages/pricing.vue |
Three plans with a monthly/annual switch, Enterprise banner, comparison table and FAQ accordion (#faq), all from app/data/pricing.ts |
/about |
app/pages/about.vue |
Story and timeline, values, team portraits, open roles (#careers) |
/contact |
app/pages/contact.vue |
Validated contact form (?topic= preselects the topic), other ways to reach us (#support), offices |
/login |
app/pages/login.vue |
"Sign in" and "Create account" tabs (#signup opens the second) with validated forms |
| 404 | app/error.vue |
Links back to features, pricing and support (generated as 404.html, noindex) |
Project structure#
nuxt.config.ts base path, head defaults (theme script, favicon), SITE_URL, prerendered routes, Tailwind
app/
app.vue <NuxtLayout> around <NuxtPage>
error.vue the 404 page
router.options.ts query strings written like the HTML version (?topic=sales)
layouts/default.vue skip link, header, the page's <main>, footer, the back-to-top button
pages/*.vue one file per page
components/
SiteHeader.vue sticky header, current page, theme toggle, phone menu
SiteFooter.vue footer links and the copyright year
LaunchLogo.vue the logo mark
ArrowIcon.vue the arrow on buttons
CheckIcon.vue the check mark in plan and comparison lists
CountUp.vue a statistic that counts up when it scrolls into view
FaqAccordion.vue the pricing FAQ
BackToTop.vue the floating "Back to top" button
composables/
useFormValidation.ts inline validation, success panel and reset for the demo forms
useCurrentPage.ts aria-current for menu links
usePageSeo.ts title, description, canonical and Open Graph tags
data/
nav.ts NAV (header and phone menu) and FOOTER_NAV (footer columns)
pricing.ts PLANS, COMPARISON and FAQS
stats.ts STATS, the home page numbers
contact.ts TEAM_SIZES and TOPICS for the contact form
utils/
reference.ts makeRef(): the LA-7K2Q reference on the contact confirmation
safeStorage.ts localStorage with an in-memory fallback
plugins/
reveal.client.ts scroll reveals
stable-build.server.ts removes the build timestamp so builds are reproducible
assets/css/main.css Tailwind theme (brand colors, font), shared classes, reveals, back-to-top styles
assets/fonts/ Inter (self-hosted) and its license
public/img/ favicon.svg, og-image.png (1200×630) and its source og-image.svg
package.json, pnpm-workspace.yaml, pnpm-lock.yaml, tsconfig.json, eslint.config.mjs
README.md, DOCS.md, CHANGELOG.mdCustomizing#
Brand and colors#
All colors come from the @theme block in app/assets/css/main.css. Buttons, links, accents and focus rings 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 color. Grays come from Tailwind's
built-in zinc palette. The logo mark in app/components/LaunchLogo.vue has its own gradient colors; update
those too. The browser theme colors are the theme-color entries in app.head.meta in nuxt.config.ts.
Shared component classes (.btn, .btn-primary, .btn-secondary, .btn-dark, .btn-lg, .eyebrow,
.text-gradient, .card, .label, .input, .field-error) are defined in the same file.
Typography#
Inter is self-hosted from app/assets/fonts/Inter-latin.woff2 (the variable Latin file Google Fonts serves, so
text sets exactly like the HTML version) and declared with @font-face at the top of main.css. To use another
font, drop its woff2 file into app/assets/fonts/, change the @font-face rule and the first name in
--font-sans:
@font-face {
font-family: "Manrope";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("../fonts/Manrope-latin.woff2") format("woff2");
}Content#
-
Page text lives in each
app/pages/<page>.vue. Titles and descriptions are theusePageSeo()call at the top of each page. -
Pricing lives in
app/data/pricing.ts:PLANS(name, description,price.monthly/price.annual, the notes under the price, call to action,featured, feature list),COMPARISON(groups of rows with one value per plan:true,falseor text) andFAQS. Add a plan:ts{ name: "Scale", description: "For organizations 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 value to every
COMPARISONrow, a column to the table header inapp/pages/pricing.vue, and widen the grid (lg:grid-cols-3). -
Statistics on the home page are
STATSinapp/data/stats.ts:{ value: 4000, decimals: 0, suffix: "+", label: "Teams shipping with Launch" }. -
Contact choices are
TEAM_SIZESandTOPICSinapp/data/contact.ts. A topic'svalueis what/contact?topic=<value>preselects (sales,support,billing,partnerships,press,careers). -
Icons are Heroicons written inline as
<svg>in the pages.
Images and artwork#
There are no photos: the product screenshot, charts, logos and team portraits are built from HTML, Tailwind
classes and inline SVG in the pages. To use real images, put them in public/img/ and write the base path in
front:
<img :src="`${$config.app.baseURL}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 and portraits square. The favicon is public/img/favicon.svg; the social image is
public/img/og-image.png (1200×630), drawn from public/img/og-image.svg.
Navigation, header and footer#
- Header and phone menu links come from
NAVinapp/data/nav.ts; the footer columns fromFOOTER_NAV. - The header (logo, theme toggle, sign-in buttons, phone menu) is
app/components/SiteHeader.vue; the footer isapp/components/SiteFooter.vue.
To add a page, create app/pages/<name>.vue with <main id="main"> as its root and a usePageSeo() call, add
its route to routes in nuxt.config.ts (so it is generated), then link it from NAV or FOOTER_NAV.
Dark mode#
nuxt.config.ts adds a small themeScript to <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 moon/sun button in
SiteHeader.vue flips the class and saves "dark" or "light" under theme in localStorage. Its label
("Switch to dark mode" / "Switch to light mode") and aria-pressed follow the current theme.
- Default to light: in
themeScript, remove||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches). - Disable dark mode: remove the
data-theme-togglebutton from the header, the script fromapp.head, and the@custom-variant darkline frommain.css.
Features#
Header and phone menu#
The header is sticky and gains a border and shadow (is-scrolled) once the page scrolls past 8px. Below the
md breakpoint (768px) a menu button opens the phone menu. It updates aria-expanded and its label ("Open menu"
/ "Close menu"), closes on Escape (returning focus to the button), when you choose a link, and when the window
grows past 768px. Menu links to the current page get aria-current="page".
Scroll reveal#
app/plugins/reveal.client.ts fades in every .reveal element as it scrolls into view, on every page and after
client-side navigation. Elements above one that becomes visible are revealed too, so fast scrolling or jumping
to an anchor never leaves hidden content behind. reveal-delay-1 to reveal-delay-3 stagger them. The styles are in main.css; content
is only hidden while JavaScript runs (the js class), and with reduced motion everything shows immediately. The
plugin adds is-visible with classList, so don't put .reveal on an element whose class changes with state.
Pricing switch#
app/pages/pricing.vue keeps one annual value. The role="switch" button flips it, and the "Monthly" and
"Annual" labels next to it pick their side. Every price and note changes together, and the active label is
shown darker. The page always opens on monthly.
FAQ accordion#
FaqAccordion.vue renders FAQS. Each question is a button with aria-expanded and aria-controls; its
answer slides open, and closed answers are inert so keyboard and screen reader users skip them. Several can be
open at once.
Sign-in tabs#
app/pages/login.vue has "Sign in" and "Create account" tabs (role="tab", aria-selected, roving
tabindex). Click a tab or use the Left and Right arrow keys. /login#signup (used by "Start free" and the plan
buttons) opens "Create account" when the page loads. Each password field has a Show/Hide button with
aria-pressed.
Count-up statistics#
CountUp.vue counts from zero to its value over 1.4 seconds, easing out, when the number scrolls into view.
The generated HTML already shows the final value, so it is right without JavaScript and with reduced motion
(which skips the animation). Numbers are formatted US style (4,000+, 99.99%).
Back to top button#
BackToTop.vue is added to <body> by the layout. It appears after you scroll about one screen, scrolls back
up (instantly with reduced motion) and moves keyboard focus to <main>. It lifts itself above anything marked
data-back-to-top-avoid, and hides while it would cover a sticky element marked data-back-to-top-hide. Its
styles are .back-to-top in main.css; to remove it, delete the component, its line in
app/layouts/default.vue and those styles.
Forms#
All three forms use useFormValidation() (app/composables/useFormValidation.ts), built on the browser's own
checks: required, type="email" and minlength on the fields. The listeners go on the <form>, and each
field binds its aria-invalid and error text:
<form id="contact-form" ref="form" novalidate :hidden="f.sent" @submit="f.submit" @input="f.input" @change="f.input" @focusout="f.blur">
<input id="email" name="email" type="email" required class="input" aria-describedby="email-error" :aria-invalid="f.invalid['email']">
<p id="email-error" class="field-error" :hidden="!f.errors['email']">{{ f.errors['email'] }}</p>
</form>On submit every field with an id is checked, errors appear under each field and focus moves to the first
problem. After that, text fields re-check as you type and checkboxes and selects when they change; a filled-in
field is also checked when you leave it. A valid form disables its button for half a second, then hides itself
and shows (and focuses) its success panel. The panel's button ("Back", "Send another message") empties the form
and puts focus in its first field.
| Form | Page | Fields and rules |
|---|---|---|
contact-form |
/contact |
Full name, work email (valid), company (optional), team size, topic, message (20+ characters), consent checkbox |
signin-form |
/login |
Email (valid), password (8+ characters), "Keep me signed in" (not checked) |
signup-form |
/login |
Full name, work email (valid), password (8+ characters), terms checkbox |
The messages ("This field is required.", "Please enter a valid email address.", "Please use at least 8
characters.", "Please check this box to continue.") are in MESSAGES and errorFor() in the composable. The
contact confirmation shows a reference such as LA-7K2Q, made in the browser by makeRef()
(app/utils/reference.ts); if your form service returns its own reference, show that instead.
The forms don't send anything until you connect them. send() in useFormValidation.ts only shows the
success panel. To deliver messages through a form service such as Formspree, give the
form an action and post it from send():
async function send() {
sending.value = true;
const el = form.value!;
const res = await fetch(el.action, { method: "POST", body: new FormData(el), headers: { Accept: "application/json" } });
sending.value = false;
if (!res.ok) return;
reference.value = makeRef("LA");
sent.value = true;
nextTick(() => success.value?.focus());
}<form id="contact-form" ref="form" action="https://formspree.io/f/your-id" method="post" novalidate …>Every field has a name, so FormData includes them all. With pnpm build (a Node.js server) you can post to
your own server route instead, for example server/api/contact.post.ts with defineEventHandler() and
readFormData(event). The sign-in and create-account 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 color theme choice |
Any other value is ignored and the system setting is used. safeStorage (app/utils/safeStorage.ts) keeps the
choice in memory when storage is blocked, so the toggle still works for that page view. Remove the key in the
browser's developer tools to go back to the system setting. Tabs, billing and forms are not saved.
SEO and sharing#
-
Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts), which sets the title, description, canonical link,og:*tags andtwitter:card. -
Canonical,
og:urlandog:imageare absolute:SITE_URL(defaulthttps://launch.example) plus the base path. Set it to your domain when you build:shSITE_URL=https://example.com pnpm generate -
The social image is
public/img/og-image.png(1200×630). Replace the file to change it. -
The 404 page is
noindexwith no canonical link. Add a sitemap androbots.txttopublic/if you need them.
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 toggles and the sideways-scrolling comparison table.
- ARIA where it helps:
aria-expanded,role="tab"/aria-selected,role="switch"/aria-checked,aria-pressed,aria-current, andaria-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, keep heading levels in order, give every new field a <label>, an id
and an error paragraph (<id>-error), and re-check contrast if you change colors.
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=/launch 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#
- The theme flashes on load. Keep the
themeScriptentry inapp.head.scriptinnuxt.config.ts. - Content stays invisible.
.revealelements are shown byapp/plugins/reveal.client.ts; keep the plugin, and don't add.revealto an element with a:classbinding that changes. - A form says it was sent but nothing arrives. The forms are not connected; see Forms.
- A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. - Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, use<NuxtLink>for pages and write$config.app.baseURLin front of files inpublic/. - Social previews have no image. Set
SITE_URLto your domain when you build. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.- 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.
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. Inter is under
the SIL Open Font License 1.1 (app/assets/fonts/Inter-OFL.txt). Launch, Launch Labs, its customers and team
are fictional.