Freeframe

Documentation

Launch

v1.0.2Updated Nov 13, 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" 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).

sh
pnpm install      # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev          # http://localhost:3000

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

Customizing#

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:

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

css
@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 the usePageSeo() 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, false or text) and FAQS. 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 COMPARISON row, a column to the table header in app/pages/pricing.vue, and widen the grid (lg:grid-cols-3).

  • Statistics on the home page are STATS in app/data/stats.ts: { value: 4000, decimals: 0, suffix: "+", label: "Teams shipping with Launch" }.

  • Contact choices are TEAM_SIZES and TOPICS in app/data/contact.ts. A topic's value is 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:

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

  • Header and phone menu links come from NAV in app/data/nav.ts; the footer columns from FOOTER_NAV.
  • The header (logo, theme toggle, sign-in buttons, phone menu) is app/components/SiteHeader.vue; the footer is app/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-toggle button from the header, the script from app.head, and the @custom-variant dark line from main.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:

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

ts
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());
}
html
<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 and twitter:card.

  • Canonical, og:url and og:image are absolute: SITE_URL (default https://launch.example) plus the base path. Set it to your domain when you build:

    sh
    SITE_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 noindex with no canonical link. Add a sitemap and robots.txt to public/ if you need them.

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 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, and 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, 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#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

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

sh
DEMO_BASE_PATH=/launch pnpm generate

pnpm 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 themeScript entry in app.head.script in nuxt.config.ts.
  • Content stays invisible. .reveal elements are shown by app/plugins/reveal.client.ts; keep the plugin, and don't add .reveal to an element with a :class binding 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 routes in nuxt.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.baseURL in front of files in public/.
  • Social previews have no image. Set SITE_URL to your domain when you build.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install or npx nuxt prepare first.
  • 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.

Search documentation

Search the guides and every template's documentation