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).
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):
@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:
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:
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:
{
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" />rendersscreens.budgetsinside the.phoneframe. Edit a screen insrc/components/screens.tsxand its description inscreenLabels, or render a real screenshot (portrait, about 9:19.5, for example 1170×2532) in its place:
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:
LogoMarkinsrc/components/logo.tsx; the press kit downloads come frompublic/pocket-logo.svg,public/pocket-logo-dark.svgandpublic/favicon.svg. - Favicon:
src/app/icon.svg. - Social image: replace
public/og-image.pngwith your own 1200×630 PNG.
Navigation, header and footer#
- Header:
src/components/site-header.tsxmaps overnavfromsrc/data/content.ts. The mobile menu usesmobileNav, which isnavplus the press kit link. Add an item tonavto show it in both. - Footer:
src/components/site-footer.tsxhas its links written out, plus<NewsletterForm />. - New page: add
src/app/<route>/page.tsxthat exportsmetadata = pageMetadata(title, description), then link it fromnavand 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)fromthemeScript. - Remove dark mode: delete
themeScriptand 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.
Screenshot carousel and lightbox#
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.
Help centre search#
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:
<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:
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:
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)fromsrc/lib/site.ts, which sets the title, description, Open Graph (siteName,og-image.png) and asummary_large_imageTwitter card. The layout addsmetadataBaseand the default title. - Set
NEXT_PUBLIC_SITE_URLwhen building so image URLs become absolute (otherwise they point athttps://pocket.example):
NEXT_PUBLIC_SITE_URL=https://your-domain.com pnpm build- Light and dark
themeColorare set in theviewportexport oflayout.tsx. - For a sitemap and robots file, add
src/app/sitemap.tsandsrc/app/robots.ts(both work with a static export when they exportdynamic = "force-static"). If you publish in the app stores, consider adding Apple's Smart App Banner throughmetadata.ituneson the home page.
Accessibility#
Built in:
- Skip link to
#main, landmarks and oneh1per page. - Visible focus outlines (
:focus-visible) and AA contrast in light and dark mode. - Mobile menu with
aria-expandedandaria-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 fromscreenLabels), or hidden from assistive technology withlabel="0"when it's a decorative duplicate, so screen readers don't read every number in a fake screen.- Accordions with
aria-expanded,aria-pressedtoggle 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#
pnpm build # static site in out/
pnpm start # preview it locallynext.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_URLas an environment variable. - Netlify, Cloudflare Pages or any static host: build command
pnpm build, publish directoryout. - Sub-folder hosting: build with
DEMO_BASE_PATH=/your/path pnpm build. It setsbasePathandNEXT_PUBLIC_BASE_PATH, whichasset()adds to files frompublic/.
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.
screenvalues incontent.tsand on<Phone>must be keys ofscreensinscreens.tsx; add the screen and itsscreenLabelsentry together. - The copy buttons show "Copy failed". Some browsers block clipboard access outside
https://andlocalhost. - A topic shortcut on the help page jumps to the wrong group.
TOPICSlink tohg-<index>; update them after reorderinghelp. - Press kit downloads or the social image 404 in a sub-folder. Use
asset("/file")for files inpublic/and build withDEMO_BASE_PATH. - A hydration warning mentions the
darkclass. The theme script adds it before React loads;<html>hassuppressHydrationWarningfor this. Keep it. pnpm typecheckcan't find route types. Run it through the script (next typegenruns first), nottscalone.- Shared links show no image. Set
NEXT_PUBLIC_SITE_URLto 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.