Freeframe

Documentation

Pocket

v1.0.0Updated Nov 11, 2026A bright launch site for a mobile app, with phone mockups and a scroll-driven feature tour.

Overview#

Pocket is a complete launch website for a mobile app, built for the fictional budgeting app "Pocket" by Pocket Money, Inc. in Tulsa: layered phone mockups, a sticky feature tour that swaps the app screen as you scroll, a screenshot carousel with a lightbox, pricing with a monthly/yearly switch, filterable release notes, a searchable help center with a contact form, a press kit with copy buttons and a privacy policy, in light and dark themes. Every phone screen is plain HTML and SVG, with no image files. This is the Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4), version 1.0.0, 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/pocket.

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/pocket into demo/

Pages#

Route File Purpose and features
/ app/pages/index.vue Hero with three layered phones, "As featured in" strip, sticky feature tour (#tour-title), count-up statistics, reviews, privacy highlights, download band (#download) with store buttons and a QR code
/features app/pages/features.vue Jump links and one section per feature (smart budgets, bill radar, pockets, insights, shared budgets, security), each with a phone
/screenshots app/pages/screenshots.vue Carousel of the seven screens (app/data/screenshots.ts) with a lightbox
/pricing app/pages/pricing.vue Free, Plus and Family plans with a Monthly / Yearly switch, comparison table and FAQ (app/data/pricing.ts)
/changelog app/pages/changelog.vue Release notes timeline with type and platform filters (app/data/releases.ts)
/support app/pages/support.vue Help center search, popular topics, articles (app/data/help.ts) and the contact form (#contact)
/press app/pages/press.vue Boilerplate, leadership, key facts, logos, brand colors, all with copy buttons (app/data/press.ts)
/privacy app/pages/privacy.vue Privacy policy with an "On this page" list that marks the section in view (app/data/privacy.ts)
404 app/error.vue Links back home and to the help center (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>, keyed so a link to the page you're on starts it afresh
  error.vue                 the 404 page
  router.options.ts         query strings written like the HTML version
  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, newsletter signup and the copyright year
    PocketMark.vue          the app icon (logo mark)
    PhoneMockup.vue         a phone frame around one app screen
    AppScreen.vue           one app screen from app/data/screens.ts
    FaqItem.vue             one accordion question (pricing FAQ, help center)
    CountUp.vue             a number that counts up when its section is revealed
    BackToTop.vue           the back-to-top button
  composables/
    useFormValidation.ts    inline validation and the demo "send" for the forms
    usePageSeo.ts           title, description, canonical and Open Graph tags
    useCurrentPage.ts       aria-current for menu links
  data/
    screens.ts              the seven app screens (markup) and what each one shows
    nav.ts                  header, phone menu and footer links
    screenshots.ts          carousel and lightbox order, titles and captions
    pricing.ts              plans, prices and the pricing FAQ
    releases.ts             release notes
    help.ts                 help center articles
    press.ts                leadership, key facts and brand colors
    privacy.ts              the privacy policy's table of contents
  plugins/
    reveal.client.ts        scroll reveals
    same-page.client.ts     a link to the page you're on reloads it at the top, like the HTML version
    stable-build.server.ts  reproducible builds (drops the build timestamp)
  utils/
    copyText.ts             copy to the clipboard, with a fallback
    reference.ts            confirmation references like PO-7K2Q
    safeStorage.ts          localStorage with an in-memory fallback
  assets/css/main.css       Tailwind entry: fonts, @theme tokens, components, phone mockup, reveals, back to top
  assets/fonts/             Plus Jakarta Sans and Sora (woff2) with their OFL licenses
public/img/                 favicon, logos and the social image

Customizing#

Brand and colors#

The design tokens are in the @theme block of app/assets/css/main.css:

Token Used for
--color-pine-50 … --color-pine-950 Brand green: primary buttons, links, focus outline, highlighted plan
--color-sun-300 … --color-sun-500 Yellow accent; primary buttons and links in dark mode
--color-coral-400, --color-coral-500 Error messages and "Fixed" badges
--color-cream-50 … --color-cream-200 Light page backgrounds
--color-ink-800 … --color-ink-950 Text and dark-mode backgrounds

Change the green by replacing the pine scale (keep the 50–950 steps). pnpm dev picks up the change at once:

css
@theme {
  --color-pine-500: #7c3aed;
  --color-pine-600: #6d28d9;
  --color-pine-700: #5b21b6;
}

The app screens in app/data/screens.ts use inline styles with hex colors, so each screen is self-contained; search the file for #0f7457 (the brand green) and other hex values to recolor them. The browser UI color (theme-color) is set in app.head.meta in nuxt.config.ts.

Typography#

Two variable fonts are self-hosted from app/assets/fonts/ and declared with @font-face at the top of main.css: Sora for headings (--font-display, used by every h1–h3 and the font-display class) and Plus Jakarta Sans for text (--font-sans). They are the same files Google Fonts serves the HTML version, so text sets at the same width. To swap a font, add its woff2 file, change the @font-face rule and the token:

css
@theme {
  --font-display: "Your Font", "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
}

Content#

Static copy is markup in the page files. Everything a feature works with is typed data in app/data/:

  • RELEASES (releases.ts): each release has a version, date, platform ("both", "ios" or "android"), title and changes with a type ("new", "improved" or "fixed"). Add the newest at the top:

    ts
    {
      version: "v4.7.0",
      date: "Oct 16, 2026",
      platform: "ios",
      title: "Home screen widgets",
      changes: [{ type: "new", text: "Safe-to-spend and bills widgets for the iPhone home screen." }],
    },
  • HELP_GROUPS (help.ts): groups of articles, each with an id (its answer panel's id), question and answer. The search covers the question and answer.

  • PLANS, BILLING_NOTES and PRICING_FAQ (pricing.ts): prices are dollars per month as written; annual is the monthly price when billed yearly (null for Free). The comparison table is markup in pricing.vue.

  • SHOTS (screenshots.ts): the carousel order, each with a screen name, title and caption.

  • LEADERSHIP, PRESS_FACTS, BRAND_COLORS and PRESS_EMAIL (press.ts): the copy buttons copy exactly the value or hex shown.

  • PRIVACY_TOC (privacy.ts): one entry per <section id="…" data-toc-section> in privacy.vue.

  • NAV, MOBILE_NAV and FOOTER_COLUMNS (nav.ts): the menus and footer links.

The fictional company details (press@pocket.example, Tulsa) appear in the footer, press kit and privacy policy.

Images and artwork#

  • Phones: <PhoneMockup screen="budgets" /> draws a phone with one of the seven screens from app/data/screens.ts (overview, budgets, goals, insights, bills, family, lock). Screen readers hear "Pocket app: " plus its entry in SCREEN_LABELS; decorative hides duplicates from them and size="sm" draws the smaller phone. Edit a screen's markup in SCREENS, or replace it with a real screenshot (portrait, about 9:19.5, for example 1170×2532) saved in public/img/, changing AppScreen.vue:

    html
    <div class="phone-screen"><img :src="`${$config.app.baseURL}img/${name}.png`" alt="" class="size-full object-cover"></div>
  • Store buttons: the "Download on iPhone / Get it for Android" buttons (.store-btn, data-store="ios|android") are generic, not official badge artwork. Point them at your App Store and Google Play pages, or use the official badges following Apple's and Google's guidelines.

  • Logos and favicon: public/img/pocket-logo.svg, pocket-logo-dark.svg and favicon.svg are plain SVG, offered as downloads on the press page. The mark in the header and footer is PocketMark.vue.

  • Social image: public/img/og-image.png (1200×630) is exported from og-image.svg; edit the SVG and export a new PNG.

The header (SiteHeader.vue) and the phone menu read NAV and MOBILE_NAV from app/data/nav.ts; the footer (SiteFooter.vue) reads FOOTER_COLUMNS. To add a page, create app/pages/<name>.vue with a <main id="main"> root and a usePageSeo() call, add its route to routes in nuxt.config.ts, and add it to nav.ts. The current page's menu link gets aria-current="page" from useCurrentPage().

Dark mode#

The moon/sun button in the header toggles the dark class on <html> and saves "dark" or "light" as pocket-theme in localStorage. A small script in app.head.script (themeScript in nuxt.config.ts) applies the saved choice, or the system setting, before the page paints, so there is no flash. Dark styles use Tailwind's dark: variant, defined in main.css as @custom-variant dark (&:where(.dark, .dark *));.

  • Always start light: change the script to apply dark only when the saved value is "dark".
  • Remove dark mode: delete the toggle button in SiteHeader.vue and the dark part of the script.

Features#

Header and phone menu#

The header is sticky and gains a soft shadow (is-scrolled) once the page scrolls more than 8px. Below 1024px the menu button opens #mobile-menu; its label switches between "Open menu" and "Close menu", and Escape, following a link or widening the window closes it (Escape also returns focus to the button). A link to the page you're on reloads that page at the top, as in the HTML version (app/plugins/same-page.client.ts).

Phone mockups and feature tour#

The hero layers three phones (the side ones float gently, and stand still with reduced motion). The home page's "Four habits, built in" tour has a sticky phone on wide screens: an IntersectionObserver in index.vue watches the four [data-tour-step] articles and shows the screen of the step in the middle of the window, fading between them (.tour-screen.is-active). The phone's aria-label follows the step's heading. On phones and tablets each step shows its own small phone instead. Add a step by adding an article with data-tour-step="<screen>" and the screen name to TOUR.

Count-up statistics#

The four numbers in "Small habits, real results" are <CountUp :value="340" /> (optional decimals and suffix). The generated HTML shows the final value; when the section is revealed the number counts up from zero in 1.3 seconds. Reduced motion shows the final value straight away.

The carousel is a horizontally scrolling list with scroll snapping. The arrow buttons and, when the list has focus, the Left and Right arrow keys move one screen at a time; the buttons are disabled at either end. A polite status line says which screens are showing ("Showing screens 1–4 of 7"). Selecting a screen opens it in a modal lightbox (role="dialog", aria-modal): focus moves to Close and stays inside the dialog, the arrow buttons and arrow keys step through the screens (wrapping around), and Escape, Close or a click on the backdrop closes it and returns focus to the screen you picked. The page doesn't scroll behind it.

Pricing switch#

The Monthly and Yearly buttons (aria-pressed) switch the Plus and Family prices and the notes under them between monthly and annual in PLANS, and a visually hidden live region announces "Showing yearly prices".

FAQ accordion#

FaqItem.vue is used by the pricing FAQ and the help center. Each question is a button with aria-expanded and aria-controls; its answer panel opens with a height and fade transition and is inert while closed, so keyboard and screen reader users skip hidden answers.

Release note filters#

On /changelog the type buttons (All changes, New, Improved, Fixed) and the platform select filter the changes; a release with no matching changes is hidden. A live region announces the count ("16 changes in 6 releases"), and "No changes match those filters." shows when nothing is left.

The search box on /support filters the articles as you type, matching the question and answer text without regard to case. Groups with no matches are hidden, the number of matches is announced ("2 articles match “round-ups”"), and "No articles found" shows when nothing matches. The popular topics jump to the groups.

Press kit copy buttons#

"Copy text" copies the boilerplate paragraph, the small buttons copy each key fact, the color tiles copy their hex value and "Copy press email" copies the address. They use the Clipboard API on https pages and a fallback elsewhere (app/utils/copyText.ts). A toast at the bottom of the window (role="status") confirms the copy and fades after 2.2 seconds, or says to copy by hand if the browser refuses.

Privacy policy contents#

The "On this page" list marks the section being read with aria-current="true" (bold, with a colored bar). An IntersectionObserver in privacy.vue watches the [data-toc-section] sections.

Scroll reveal#

Elements with the reveal class fade and slide in once their top passes 92% of the window height, together with everything above them, so jumping to the end or following an anchor never leaves content hidden (app/plugins/reveal.client.ts). reveal-delay-1 to -3 stagger them. They are only hidden when JavaScript runs (.js .reveal), and reduced motion shows everything at once. Don't put reveal on an element whose :class binding changes: Vue would remove is-visible when it updates the class.

Back to top button#

BackToTop.vue appears after you scroll about one screen down, returns to the top (instantly with reduced motion) and moves keyboard focus to the main content. It lifts above the press kit's toast and hides while it would cover the help center's sticky contact form (data-back-to-top-hide). To remove it, delete the component, its line in app/layouts/default.vue and the .back-to-top styles in main.css.

Forms#

Both 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="c-email" type="email" class="input" required aria-describedby="c-email-error" :aria-invalid="f.invalid['c-email']">
  <p id="c-email-error" class="field-error" :hidden="!f.errors['c-email']">{{ f.errors["c-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 selects when they change; a filled-in field is also checked when you leave it. A valid form dims and disables its button for half a second, then hides itself and shows (and focuses) its success message.

Form Where Fields and rules
Newsletter Footer of every page Email (valid)
contact-form /support Name, email (valid), topic, phone (iPhone or Android), message (20+ characters)

The messages ("This field is required.", "Please enter a valid email address.", "Please use at least 20 characters.") are in MESSAGES and errorFor() in the composable. The contact confirmation shows a reference such as PO-7K2Q, made in the browser by makeRef() (app/utils/reference.ts); "Send another message" empties the form and puts focus in its first field.

The forms don't send anything until you connect them. send() in useFormValidation.ts only shows the success message. To deliver messages through a form service such as Formspree, give the fields name attributes and 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("PO");
  sent.value = true;
  nextTick(() => success.value?.focus());
}
html
<form id="contact-form" ref="form" action="https://formspree.io/f/your-id" method="post" novalidate …>
  <input id="c-name" name="name" class="input" required …>

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). For the newsletter, use your mailing list provider's signup endpoint the same way.

Saved data (localStorage)#

Key Value Used for
pocket-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. Filters, the billing switch 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://pocket.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. For an app, consider adding Apple's apple-itunes-app meta tag (Smart App Banner) in app.head.meta.

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, carousel (arrow keys), lightbox (focus trap, Escape, arrows), accordions, filters and copy buttons.
  • ARIA where it helps: aria-expanded, aria-pressed, aria-current, role="dialog" with aria-modal, live regions for the carousel, filters, search, billing and toast, and aria-invalid with error messages linked through aria-describedby.
  • The phone mockups are role="img" with a description of the screen; duplicates are aria-hidden.
  • Reduced motion: reveals, count-ups, floating phones, the tour fade, 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=/pocket 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.
  • The copy buttons say "Copy failed". Some browsers only allow clipboard access on https pages or after a click; test the deployed site, or select and copy the text by hand.
  • 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/.
  • 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. Plus Jakarta Sans and Sora are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Pocket, Pocket Money, Inc., its team, reviewers and press quotes are fictional. The privacy policy is placeholder text, not legal advice.

Search documentation

Search the guides and every template's documentation