Freeframe

Documentation

Pocket

v1.0.4Updated Oct 17, 2026A bright launch site for a mobile app, with phone mockups and a scroll-driven feature tour.

Overview#

Pocket is a launch website for a mobile app, built for Pocket, a fictional budgeting app. It has layered phone mockups, a scroll-driven feature tour, a screenshot carousel with a lightbox, pricing with a monthly/yearly toggle, release notes, a searchable help centre, a press kit and a privacy policy. Every phone screen is a React component made of plain markup and SVG, with no image files. This is the Next.js 16 version (App Router, React 19, TypeScript, Tailwind CSS v4, static export), 1.0.1.

Live demo: freeframe.dev/templates/pocket

Quick start#

Requirements: Node.js 24 and pnpm (npm works too).

sh
pnpm install
pnpm dev        # http://localhost:3000
Script What it does
pnpm dev Development server with hot reload
pnpm build Static export to out/
pnpm start Serve out/ locally (npx serve@latest out)
pnpm lint ESLint
pnpm typecheck next typegen then tsc --noEmit (TypeScript 7)
pnpm build:demo Build for the freeframe.dev demo (base path /demos/nextjs/pocket) into demo/

Pages#

Route File Purpose and notable features
/ src/app/page.tsx Hero with three layered phones, press strip, feature tour (<Tour />), count-up stats, reviews, security section, download block (#download)
/features src/app/features/page.tsx Feature deep-dives, each with a phone (#smart-budgets, #bill-radar, #pockets, #insights, #shared-budgets, #security), plus smaller features
/screenshots src/app/screenshots/page.tsx <ScreenshotGallery />: carousel with buttons, swipe and arrow keys, and a lightbox
/pricing src/app/pricing/page.tsx <BillingToggle />, <PlanCards />, comparison table, pricing FAQ
/changelog src/app/changelog/page.tsx <ReleaseNotes />: timeline with type and platform filters
/support src/app/support/page.tsx <HelpCentre />: live search over help articles, topic shortcuts, contact form (#contact)
/press src/app/press/page.tsx Boilerplate, key facts, logos, colours and leadership, with copy buttons
/privacy src/app/privacy/page.tsx Privacy policy with <PrivacyToc />, a table of contents that follows the scroll
404 src/app/not-found.tsx Not-found page (exported as 404.html)

Project structure#

pocket/
├── next.config.ts            static export, basePath from DEMO_BASE_PATH
├── package.json              scripts and dependencies
├── eslint.config.mjs         ESLint flat config
├── postcss.config.mjs        Tailwind CSS v4 PostCSS plugin
├── public/
│   ├── favicon.svg           app icon (also offered as a download on the press page)
│   ├── og-image.png          social sharing image, 1200×630
│   ├── pocket-logo.svg       logo for light backgrounds (press kit download)
│   └── pocket-logo-dark.svg  logo for dark backgrounds (press kit download)
└── src/
    ├── app/
    │   ├── layout.tsx        fonts, theme script, skip link, header, footer, reveals, back-to-top, toast
    │   ├── globals.css       Tailwind import, @theme tokens and component classes (.btn, .card, .phone…)
    │   ├── icon.svg          favicon used by Next.js
    │   ├── page.tsx          home
    │   ├── not-found.tsx     404
    │   └── <route>/page.tsx  one folder per page (features, screenshots, pricing, changelog, support, press, privacy)
    ├── components/
    │   ├── site-header.tsx   sticky header, desktop nav, mobile menu, theme toggle
    │   ├── site-footer.tsx   footer links and newsletter form
    │   ├── phone.tsx         <Phone> mockup that renders one app screen
    │   ├── screens.tsx       the seven app screens and their accessible labels
    │   ├── tour.tsx          sticky feature tour
    │   ├── screenshot-gallery.tsx  carousel and lightbox
    │   ├── pricing.tsx       BillingToggle and PlanCards (shared monthly/yearly state)
    │   ├── faq.tsx           accordion
    │   ├── release-notes.tsx release timeline and filters
    │   ├── help-centre.tsx   help search, topics and contact form
    │   ├── forms.tsx         ContactForm, NewsletterForm and the validation hook
    │   ├── copy-button.tsx   copy to clipboard
    │   ├── toast.tsx         showToast() and the live-region toast
    │   ├── privacy-toc.tsx   privacy table of contents
    │   ├── reveal-observer.tsx  scroll reveals and count-up numbers
    │   ├── back-to-top.tsx   back-to-top button
    │   ├── logo.tsx          LogoMark
    │   ├── icon.tsx          <Icon name="…">
    │   └── icon-paths.ts     SVG paths for every icon
    ├── data/content.ts       tour, shots, plans, pricingFaq, releases, help, nav
    ├── fonts/                Plus Jakarta Sans and Sora (woff2) with their OFL licences
    └── lib/site.ts           site name, URL, description, asset() and pageMetadata()

Customising#

Brand and colours#

Design tokens live in the @theme block of src/app/globals.css:

Tokens Used for
--color-pine-50 … --color-pine-950 Brand green: buttons, links, highlights, the featured plan
--color-sun-300 … --color-sun-500 Yellow accent: dark-mode buttons, badges, selection
--color-coral-400, --color-coral-500 Error text, "Fixed" badges
--color-cream-50 … --color-cream-200 Light page backgrounds
--color-ink-800 … --color-ink-950 Text and dark-mode backgrounds

Change the brand by replacing the pine scale (keep the 50–950 steps so contrast still works):

css
@theme {
  --color-pine-500: #2563eb;
  --color-pine-600: #1d4ed8;
  --color-pine-700: #1e40af;
}

Also update themeColor in the viewport export of src/app/layout.tsx and the swatches on the press page. The phone screens in src/components/screens.tsx use inline styles with hex colours (for example #ffd23f); search and replace those if you change the palette.

Typography#

Plus Jakarta Sans (body, --font-sans) and Sora (headings, --font-display) are self-hosted with next/font/local in src/app/layout.tsx from src/fonts/PlusJakartaSans-latin.woff2 and src/fonts/Sora-latin.woff2. They set the CSS variables --font-jakarta and --font-sora, which @theme uses.

To swap a font, use next/font/google (or another local file) with the same variable name:

tsx
import { Inter } from "next/font/google";
const jakarta = Inter({ subsets: ["latin"], variable: "--font-jakarta", display: "swap" });

Content#

Site-wide settings are in src/lib/site.ts:

ts
export const site = {
  name: "Pocket",
  url: process.env.NEXT_PUBLIC_SITE_URL ?? "https://pocket.example",
  description: "Pocket is the calm money app that budgets, tracks bills and fills your savings goals for you.",
};

Page copy is in each page.tsx. The repeated content is in src/data/content.ts:

Export Shape Used by
tour { screen, icon, title, body, points[] } Feature tour on the home page
shots { screen, name, caption } Screenshot carousel and lightbox
plans { name, monthly, annual, description, features[], featured } Plan cards (prices are strings; "0" means free)
pricingFaq { q, a } Pricing FAQ (<Faq items="pricing" />)
releases { version, date, platform, title, changes: { type, text }[] } Release notes
help { title, items: { q, a }[] } Help centre groups and articles
nav { href, label } Header navigation

screen must be a key of screens (overview, budgets, goals, insights, bills, family, lock), icon a key of iconPaths, platform one of both, ios or android, and type one of new, improved or fixed. Add a release at the top of releases:

ts
{
  version: "4.7.0",
  date: "Nov 4, 2026",
  platform: "ios",
  title: "Home screen widgets",
  changes: [{ type: "new", text: "Safe-to-spend and bill widgets for iOS." }],
},

The comparison table on the pricing page and the press facts are written directly in their page.tsx files.

Images and artwork#

  • Phones: <Phone screen="budgets" /> renders screens.budgets inside the .phone frame. Edit a screen in src/components/screens.tsx and its description in screenLabels, or render a real screenshot (portrait, about 9:19.5, for example 1170×2532) in its place:
tsx
budgets: () => <img src={asset("/screens/budgets.png")} alt="" className="size-full object-cover" />,

(Import asset from @/lib/site. The Phone already supplies the accessible label.)

  • Store buttons: the download buttons (.store-btn) 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.
  • Logo: LogoMark in src/components/logo.tsx; the press kit downloads come from public/pocket-logo.svg, public/pocket-logo-dark.svg and public/favicon.svg.
  • Favicon: src/app/icon.svg.
  • Social image: replace public/og-image.png with your own 1200×630 PNG.
  • Header: src/components/site-header.tsx maps over nav from src/data/content.ts. The mobile menu uses mobileNav, which is nav plus the press kit link. Add an item to nav to show it in both.
  • Footer: src/components/site-footer.tsx has its links written out, plus <NewsletterForm />.
  • New page: add src/app/<route>/page.tsx that exports metadata = pageMetadata(title, description), then link it from nav and the footer.

Dark mode#

themeScript in src/app/layout.tsx runs before the page paints. It reads the localStorage key pocket-theme ("dark" or "light") and otherwise follows the system setting, adding the dark class to <html>. Tailwind's dark: variant follows that class (@custom-variant dark in globals.css). ThemeToggle in site-header.tsx flips the class and saves the choice.

  • Default to light: remove ||(t!=='light'&&window.matchMedia('(prefers-color-scheme: dark)').matches) from themeScript.
  • Remove dark mode: delete themeScript and its <script> tag, and the <ThemeToggle /> in the header.

Features#

Feature tour#

src/components/tour.tsx. On large screens a sticky phone shows the screen for the step in the middle of the viewport: an IntersectionObserver with rootMargin: "-45% 0px -45% 0px" watches each [data-tour-step], and the matching .tour-screen gets is-active (a cross-fade in globals.css). On small screens each step shows its own small phone instead. Steps come from tour; the phone's aria-label follows the active step. With reduced motion the fade transition is turned off.

src/components/screenshot-gallery.tsx renders shots as a scroll-snapping list. Previous/next buttons scroll by one slide (disabled at either end), the track supports swipe and Left/Right arrow keys, and a live region reads "Showing screens 1–4 of 7". Scrolling is instant with reduced motion.

Clicking a screenshot opens the lightbox (role="dialog", aria-modal): a large phone, the name and caption, previous/next buttons and Close. Left/Right arrows step through screens (wrapping round), Tab is trapped inside, Escape or a click on the backdrop closes it, and focus returns to the screenshot that opened it. Page scrolling is locked while it's open.

Pricing toggle#

BillingToggle and PlanCards in src/components/pricing.tsx share a small store read with useSyncExternalStore. The Monthly/Yearly buttons use aria-pressed, and a hidden live region announces the change. Each card shows monthly or annual from plans and the note "per month, billed yearly" / "per month, billed monthly". The choice isn't saved and resets to monthly on reload.

Accordions#

src/components/faq.tsx: <Faq items={list} /> or <Faq items="pricing" />. Each question is a button inside an h3 with aria-expanded; several can be open at once.

Release note filters#

src/components/release-notes.tsx. Type buttons (All changes, New, Improved, Fixed, aria-pressed) and a platform select filter the changes. Releases marked both show for every platform; a release with no matching changes is hidden, and a live region gives the count. Badge labels and colours are in TYPE, platform names in PLATFORM.

src/components/help-centre.tsx. The search box filters every help article by question and answer text as you type; groups with no matches are hidden, a live region shows the count, and an empty state appears when nothing matches. The topic shortcuts (TOPICS) link to the group headings #hg-0, #hg-1… by position in help, so update them if you reorder or add groups.

Press kit copy buttons#

<CopyButton value="…"> copies a fixed string; <CopyButton target="boilerplate"> copies the text of the element with that id. It uses the Clipboard API, with a fallback for older browsers, and confirms with showToast() from src/components/toast.tsx (a role="status" live region that hides after 2.2 seconds).

Privacy table of contents#

src/components/privacy-toc.tsx. SECTIONS lists [id, label] pairs; each matching <section id="…" data-toc-section> in src/app/privacy/page.tsx is observed, and the link for the section in view gets aria-current="true". Add a section to the page and to SECTIONS together.

Scroll reveals and counters#

src/components/reveal-observer.tsx adds is-visible to .reveal elements once their top passes 92% of the viewport height (checked on every scroll, so jumping with End or an anchor never leaves content hidden), and counts up any [data-count] inside them:

tsx
<span data-count="1.2" data-decimals="1" data-suffix="M">1.2M</span>

It re-runs on every route change. With reduced motion everything shows at once with final numbers.

Back-to-top button#

src/components/back-to-top.tsx fades in after about one screen, scrolls to the top (instantly with reduced motion) and moves focus to the main content. It lifts above anything matching [data-toast], [data-back-to-top-avoid], and hides while it would cover an element with data-back-to-top-hide (the sticky contact form on /support).

Forms#

Both forms are in src/components/forms.tsx and share useValidation(). They use the browser's constraint validation (required, type="email", minLength) with noValidate, show messages under fields (aria-invalid, aria-describedby) after the first submit or when leaving a filled field, and move focus to the first invalid field. On success they show a message and move focus to it.

Form Where Fields and rules
ContactForm /support (#contact) c-name required, c-email required email, c-topic required select, device radio (iPhone/Android), c-message required, at least 20 characters
NewsletterForm Footer, every page newsletter-email required email

The demo forms don't send anything. Fields have no name attributes yet, so add them first (for example name="email"), then send the data in the onValid callback. With Formspree, in ContactForm:

tsx
const v = useValidation(async () => {
  setSending(true);
  const res = await fetch("https://formspree.io/f/your-form-id", {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form.current!),
  });
  setSending(false);
  if (res.ok) {
    setSent(true);
    requestAnimationFrame(() => success.current?.focus());
  }
});

Replace the block under // Demo only: nothing is sent. with this, and update the success text ("This is a demo template…"). Do the same in NewsletterForm, using a ref on its <form>, or call your mailing list provider's API.

Server option: a static export can't run server code. If you remove output: "export" (see Building and deploying), add a Route Handler such as src/app/api/contact/route.ts:

ts
export async function POST(request: Request) {
  const data = await request.formData();
  // send data.get("email"), data.get("message")… to your email or CRM service
  return Response.json({ ok: true });
}

and fetch("/api/contact", { method: "POST", body: new FormData(form) }) from onValid. A Server Action works too.

Saved data (localStorage)#

Key Values Read by
pocket-theme "dark" or "light" themeScript in layout.tsx (anything else falls back to the system setting)

It's the only stored value; reads and writes are wrapped in try/catch so blocked storage doesn't break the page. Remove it in the browser's developer tools (Application → Local Storage) to return to the system theme.

SEO and sharing#

  • Every page exports metadata = pageMetadata(title, description) from src/lib/site.ts, which sets the title, description, Open Graph (siteName, og-image.png) and a summary_large_image Twitter card. The layout adds metadataBase and the default title.
  • Set NEXT_PUBLIC_SITE_URL when building so image URLs become absolute (otherwise they point at https://pocket.example):
sh
NEXT_PUBLIC_SITE_URL=https://your-domain.com pnpm build
  • Light and dark themeColor are set in the viewport export of layout.tsx.
  • For a sitemap and robots file, add src/app/sitemap.ts and src/app/robots.ts (both work with a static export when they export dynamic = "force-static"). If you publish in the app stores, consider adding Apple's Smart App Banner through metadata.itunes on the home page.

Accessibility#

Built in:

  • Skip link to #main, landmarks and one h1 per page.
  • Visible focus outlines (:focus-visible) and AA contrast in light and dark mode.
  • Mobile menu with aria-expanded and aria-controls; Escape closes it and returns focus to the menu button, and it closes after navigating.
  • <Phone> is exposed as a single image (role="img" with the description from screenLabels), or hidden from assistive technology with label="0" when it's a decorative duplicate, so screen readers don't read every number in a fake screen.
  • Accordions with aria-expanded, aria-pressed toggle buttons, a lightbox with a focus trap and focus return, and polite live regions for the carousel, filters, search, billing and toasts.
  • Reduced motion turns off reveals, count-ups, tour transitions and smooth scrolling.

When editing, give every new screen a meaningful screenLabels entry, keep one h1 per page and label every form field.

Building and deploying#

sh
pnpm build      # static site in out/
pnpm start      # preview it locally

next.config.ts sets output: "export" (plain HTML, CSS and JS in out/) and images: { unoptimized: true }.

  • Vercel: import the repository; no settings needed. Add NEXT_PUBLIC_SITE_URL as an environment variable.
  • Netlify, Cloudflare Pages or any static host: build command pnpm build, publish directory out.
  • Sub-folder hosting: build with DEMO_BASE_PATH=/your/path pnpm build. It sets basePath and NEXT_PUBLIC_BASE_PATH, which asset() adds to files from public/.

Need server features (Route Handlers, Server Actions)? Remove output: "export" and images: { unoptimized: true } from next.config.ts and deploy to Vercel or a Node.js host (next start).

TypeScript setup: package.json installs @typescript/native (TypeScript 7, which provides tsc for pnpm typecheck) and typescript as an alias of @typescript/typescript6, the TypeScript 6 API that typescript-eslint and parts of the Next.js toolchain still need. eslint.config.mjs sets settings.react.version because eslint-plugin-react can't detect it under ESLint 10. When typescript-eslint supports TypeScript 7, replace both packages with a single typescript@7.

Troubleshooting#

  • A type error says a screen name doesn't exist. screen values in content.ts and on <Phone> must be keys of screens in screens.tsx; add the screen and its screenLabels entry together.
  • The copy buttons show "Copy failed". Some browsers block clipboard access outside https:// and localhost.
  • A topic shortcut on the help page jumps to the wrong group. TOPICS link to hg-<index>; update them after reordering help.
  • Press kit downloads or the social image 404 in a sub-folder. Use asset("/file") for files in public/ and build with DEMO_BASE_PATH.
  • A hydration warning mentions the dark class. The theme script adds it before React loads; <html> has suppressHydrationWarning for this. Keep it.
  • pnpm typecheck can't find route types. Run it through the script (next typegen runs first), not tsc alone.
  • Shared links show no image. Set NEXT_PUBLIC_SITE_URL to your domain when building.

License#

MIT, like every Freeframe template: free for personal and commercial projects, no attribution required. Artwork and logos are included. Plus Jakarta Sans and Sora are under the SIL Open Font License (src/fonts/OFL-*.txt). See LICENSE.txt in the download. The privacy policy is placeholder text, not legal advice.

Search documentation

Search the guides and every template's documentation