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).
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/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 imageCustomizing#
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:
@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:
@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 aversion,date,platform("both","ios"or"android"),titleandchangeswith atype("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 anid(its answer panel's id),questionandanswer. The search covers the question and answer. -
PLANS,BILLING_NOTESandPRICING_FAQ(pricing.ts): prices are dollars per month as written;annualis the monthly price when billed yearly (nullfor Free). The comparison table is markup inpricing.vue. -
SHOTS(screenshots.ts): the carousel order, each with ascreenname,titleandcaption. -
LEADERSHIP,PRESS_FACTS,BRAND_COLORSandPRESS_EMAIL(press.ts): the copy buttons copy exactly thevalueorhexshown. -
PRIVACY_TOC(privacy.ts): one entry per<section id="…" data-toc-section>inprivacy.vue. -
NAV,MOBILE_NAVandFOOTER_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 fromapp/data/screens.ts(overview,budgets,goals,insights,bills,family,lock). Screen readers hear "Pocket app: " plus its entry inSCREEN_LABELS;decorativehides duplicates from them andsize="sm"draws the smaller phone. Edit a screen's markup inSCREENS, or replace it with a real screenshot (portrait, about 9:19.5, for example 1170×2532) saved inpublic/img/, changingAppScreen.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.svgandfavicon.svgare plain SVG, offered as downloads on the press page. The mark in the header and footer isPocketMark.vue. -
Social image:
public/img/og-image.png(1200×630) is exported fromog-image.svg; edit the SVG and export a new PNG.
Navigation, header and footer#
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
darkonly when the saved value is"dark". - Remove dark mode: delete the toggle button in
SiteHeader.vueand thedarkpart 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.
Screenshot carousel and lightbox#
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.
Help center search#
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:
<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():
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());
}<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 andtwitter:card. -
Canonical,
og:urlandog:imageare absolute:SITE_URL(defaulthttps://pocket.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. For an app, consider adding Apple'sapple-itunes-appmeta tag (Smart App Banner) inapp.head.meta.
Accessibility#
Built in:
- A "Skip to content" link to
#mainand 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"witharia-modal, live regions for the carousel, filters, search, billing and toast, andaria-invalidwith error messages linked througharia-describedby. - The phone mockups are
role="img"with a description of the screen; duplicates arearia-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#
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=/pocket 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. - 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
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/. 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. 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.