Overview#
Pocket is a launch website for a mobile app, built for Pocket, a fictional budgeting app from Bristol. It has layered phone mockups, a scroll-driven feature tour, a screenshot carousel with a lightbox, pricing, release notes, a help centre, a press kit and a privacy policy, in light and dark themes. Every phone screen is plain HTML and SVG, with no image files. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.1. It builds to static HTML files with no UI framework and can be hosted anywhere. See it live at freeframe.dev/templates/pocket.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/pocket into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | What's on it |
|---|---|---|
/ |
src/pages/index.astro |
Hero with layered phone mockups and store buttons, press strip, sticky feature tour, count-up stats, app-store style reviews, privacy section, download block (#download), newsletter |
/features |
src/pages/features.astro |
Six feature deep-dives with app screens and a grid of smaller features |
/screenshots |
src/pages/screenshots.astro |
Horizontal carousel (buttons, swipe, arrow keys) with a lightbox |
/pricing |
src/pages/pricing.astro |
Free, Plus and Family plans, monthly/yearly toggle, comparison table, FAQ accordion |
/changelog |
src/pages/changelog.astro |
Release notes ("What's new") with type and platform filters and a live count |
/support |
src/pages/support.astro |
Searchable help centre in groups, contact form in a sticky sidebar, newsletter |
/press |
src/pages/press.astro |
Boilerplate, key facts, logos, colours and leadership, with copy buttons |
/privacy |
src/pages/privacy.astro |
Privacy policy with a table of contents that highlights the section in view |
| 404 | src/pages/404.astro |
Not-found page (most hosts show 404.html automatically) |
Project structure#
pocket/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts, theme script), skip link, header, footer, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro logo, desktop navigation, theme toggle, mobile menu
│ │ └─ SiteFooter.astro footer links and copyright
│ ├─ lib/
│ │ └─ site.ts site name, theme colours and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts all interactivity, one commented block per feature
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (@theme tokens) and component classes
├─ public/img/
│ ├─ favicon.svg app icon, also used as the favicon
│ ├─ pocket-logo.svg logo for light backgrounds (press kit download)
│ ├─ pocket-logo-dark.svg logo for dark backgrounds
│ ├─ og-image.svg editable source of the social image
│ └─ og-image.png 1200×630 social image
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonPages are rendered to HTML at build time. The two files in src/scripts/ are the only JavaScript sent to the
browser, plus a few lines of inline script in the layout that set the theme before the page paints.
Layout.astro imports both scripts, and Astro bundles them into one small module. Each page passes a
page prop to the layout, which becomes <body data-page="…">.
Customising#
Brand and colours#
Tokens are in the @theme block of src/styles/global.css:
| Token | Used for |
|---|---|
--color-pine-50 … --color-pine-950 |
Brand green: primary buttons, links, focus outline, app screens |
--color-sun-300 … --color-sun-500 |
Yellow accent; primary buttons in dark mode |
--color-coral-400, --color-coral-500 |
Warm accent in the app screens |
--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
immediately:
@theme {
--color-pine-500: #7c3aed;
--color-pine-600: #6d28d9;
--color-pine-700: #5b21b6;
}The phone screens use inline styles with hex colours so each screen is self-contained. Search
src/pages/ for #0f7457 (the brand green) and other hex values to recolour them. The browser UI colour
(<meta name="theme-color">) comes from site.themeColor in src/lib/site.ts.
Typography#
Google Fonts are loaded by the <link rel="stylesheet"> in src/layouts/Layout.astro: Sora (headings,
--font-display) and Plus Jakarta Sans (text, --font-sans). All h1–h3 use the display font (set in
@layer base). To swap fonts, change that URL and the two tokens in global.css.
Content#
All copy is markup in the .astro files in src/pages/. The parts that drive features:
- Pricing: each paid price is a
<span data-price-monthly="4.99" data-price-annual="3.99">; notes under prices usedata-billing-notewithdata-monthly-noteanddata-annual-note. - Release notes: each release in
changelog.astrois an<article data-release data-platform="both">(both,iosorandroid), and each change an<li data-change="new">(new,improvedorfixed). Add one by copying an existing release:
<article data-release data-platform="ios">
<!-- version heading and date, copied from another release -->
<ul>
<li data-change="new"><span>New</span><span>Home screen widgets for iOS.</span></li>
</ul>
</article>- Help articles: each FAQ item in
support.astrois inside a<section data-help-group>and markeddata-faq-item; the search covers the whole text of every item. Copy an existing item, give its button a newaria-controlsvalue and its panel the matchingid. - Press facts: each copy button carries its value in
data-copy="…"; update it together with the visible text.
Images and artwork#
- Phones: every phone is
<div class="phone"><div class="phone-screen">…</div></div>. Edit the markup inside.phone-screen, or replace it with a real screenshot (portrait, about 9:19.5, for example 1170×2532) saved inpublic/img/:
---
import { url } from "../lib/site";
---
<div class="phone"><div class="phone-screen">
<img src={url("/img/home-screen.png")} alt="Pocket home screen showing a $1,240 safe-to-spend balance" class="size-full object-cover" />
</div></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 theirhrefat your App Store and Google Play pages, or replace them with 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. - Social image:
public/img/og-image.png(1200×630) comes fromog-image.svg; edit the SVG and export a new PNG.
Navigation, header and footer#
The header is src/components/SiteHeader.astro (desktop navigation, theme toggle and the mobile menu
#mobile-menu) and the footer is src/components/SiteFooter.astro; every page gets them from the layout.
A header link is marked aria-current="page" when the page's current prop matches it, for example
aria-current={current === "pricing" ? "page" : undefined}.
To add a page, create src/pages/careers.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Careers — Pocket" description="Join the team building Pocket." page="careers" current="careers">
<section class="wrap py-20">
<h1 class="font-display text-4xl font-extrabold">Careers</h1>
</section>
</Layout>Then add the link to the desktop navigation and the mobile menu in SiteHeader.astro (with the
aria-current expression above) and to SiteFooter.astro. Always write internal links as
href={url("/careers")} (from src/lib/site.ts) so they keep working under a base path.
Dark mode#
src/layouts/Layout.astro has a small <script is:inline> in <head> that runs before the page paints. It
adds the dark class to <html> when localStorage key pocket-theme is "dark", or when it isn't
"light" and the system prefers dark. Buttons with data-theme-toggle are wired up in src/scripts/main.ts:
they flip the class, update aria-pressed and the label, and save the choice. @custom-variant dark in
global.css makes Tailwind's dark: classes follow that class.
- Default to light: in the inline script, remove
|| (t !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches). - Remove dark mode: delete the inline script's theme lines and the toggle buttons in
SiteHeader.astro.
Features#
All feature code is in src/scripts/main.ts, one commented block per feature.
Feature tour#
On the home page, the phone in the tour (data-tour-phone) is sticky while four steps scroll past
(<article data-tour-step="budgets">, bills, goals, insights). The phone contains one screen per step
(data-tour-screen="…"); when a step reaches the middle of the viewport (an IntersectionObserver), its screen
gets the is-active class and the phone's aria-label changes to name it.
To add a step, add an <article data-tour-step="name"> with an <h3>, and a matching
<div data-tour-screen="name"> inside the phone. On small screens each step shows its own phone instead, and
with reduced motion the screens don't animate.
Screenshot carousel and lightbox#
/screenshots.
- The track (
data-carousel-track) is a horizontally scrolling list with scroll snapping. The previous and next buttons move one screen; with the track focused, the left and right arrow keys do the same. The status line (data-carousel-status, a polite live region) says which screens are visible. Swipe and trackpad scrolling work natively. - Each "Enlarge" button (
data-lightbox-open="0"…) opens the lightbox (role="dialog",aria-modal="true"). Its contents come from the<template data-lightbox-item data-title="…" data-caption="…">elements, one per screen, in the same order as the buttons. - In the lightbox: arrow keys move between screens, Escape or clicking the backdrop closes it, Tab is trapped inside, and focus returns to the button that opened it.
To add a screenshot, add a slide to the track, an "Enlarge" button with the next index, and a matching
<template>.
Pricing toggle#
/pricing. The two data-billing buttons (monthly, annual) use aria-pressed; choosing one swaps every
price and billing note and updates a status message for screen readers (data-billing-status).
Accordions#
FAQ items on the pricing and help pages are buttons marked data-accordion-button with aria-expanded and
aria-controls pointing to their panel. Closed panels are inert, so their links can't be tabbed to.
Release note filters#
/changelog. The type buttons (data-change-filter: all, new, improved, fixed) and the platform select
(data-platform-filter) hide non-matching changes and empty releases, and update the count
(data-change-count). An empty state (data-change-empty) appears when nothing matches.
Help centre search#
/support. Typing in data-help-search filters every data-faq-item by its text, hides empty groups and shows
"3 articles match …" (data-help-count) or the empty state (data-help-empty). The contact form sits in a
sticky sidebar marked data-back-to-top-hide, so the back-to-top button never covers it.
Press kit copy buttons#
/press. Buttons with data-copy="text" copy that text; data-copy-target="boilerplate" copies the text of
the element with that id. A toast (data-toast, role="status") confirms the copy. Without the Clipboard API
(for example on plain http://), a fallback using a hidden textarea is used.
Privacy table of contents#
/privacy. Links marked data-toc-link point at sections marked data-toc-section; the link for the section in
view gets aria-current="true". The policy text is a placeholder: have it reviewed for your product and region.
Scroll reveals and counters#
Elements with the class reveal fade in once their top passes 92% of the viewport height, checked on scroll, so
jumping to the end of a page reveals everything above. Numbers with data-count (plus optional
data-decimals and data-suffix) count up when revealed. With reduced motion everything appears immediately.
Back to top button#
src/scripts/back-to-top.ts adds the button after about one screen of scrolling. It returns to the top
(instantly with reduced motion), moves focus to the skip-link target (#main), lifts above toasts and hides
while an in-page "Back to top" link is on screen. Add data-back-to-top-avoid to bottom-pinned elements and
data-back-to-top-hide to sticky ones it must not cover. To remove it, delete the file, its import in
Layout.astro and the .back-to-top rules in global.css.
Forms#
| Form | Where | Fields |
|---|---|---|
Contact (#contact-form) |
/support |
Name, email, topic (select), phone (iPhone/Android), message (at least 20 characters) |
| Newsletter | /, /support |
Forms with data-validate are checked with the browser's built-in constraints (required, type="email",
minlength) and custom messages:
- Errors appear in the
<p id="<field id>-error">under each field andaria-invalidis set. - After the first submit, fields re-check as you type.
- Focus moves to the first invalid field.
- A field can override its message with
data-message="…", or require a match with another field usingdata-match="<id>"anddata-match-message.
When valid, onValid(form) hides the form and shows the element named in data-success. The contact form's
success message has a "Send another message" button (data-form-reset="contact-form", the id of the form)
that resets the form and shows it again. Nothing is sent.
To connect a form to a service such as Formspree, give it an action and its fields
name attributes in src/pages/support.astro:
<form id="contact-form" action="https://formspree.io/f/your-id" method="post" class="mt-6 space-y-4" data-validate data-success="contact-success" novalidate>
<input id="c-name" name="name" required />then replace onValid in src/scripts/main.ts (it has a "Demo only" comment):
async function onValid(form: HTMLFormElement) {
const success = document.getElementById(form.dataset.success ?? "");
const button = form.querySelector<HTMLButtonElement>('[type="submit"]');
if (button) button.disabled = true;
try {
const res = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } });
if (!res.ok) throw new Error("Request failed");
form.hidden = true;
if (success) {
success.hidden = false;
success.focus();
}
} catch {
if (button) button.disabled = false;
alert("Sorry, your message could not be sent. Please try again.");
}
}To run your own server code instead, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or
@astrojs/vercel) and an API endpoint such as
src/pages/api/contact.ts with export const prerender = false, then post to it.
Saved data (localStorage)#
| Key | Value | Used for |
|---|---|---|
pocket-theme |
"dark" or "light" |
The visitor's colour theme choice |
Any other value is ignored and the system setting is used. If storage is blocked, the toggle still works for
the current page. To reset, run localStorage.removeItem("pocket-theme") in the browser console.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>, which turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card, plustheme-colorfor light and dark. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://pocket.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm buildCanonical URLs have no
.html(https://example.com/pricing). -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
For a sitemap, add
@astrojs/sitemap(pnpm astro add sitemap) and apublic/robots.txtthat points to it. If you publish in the app stores, consider Apple's Smart App Banner (<meta name="apple-itunes-app" content="app-id=…">) in the layout.
Accessibility#
Built in:
- Skip link, landmarks and one
h1per page. - Visible focus outlines (
:focus-visible) and AA contrast in light and dark mode. - Mobile menu with
aria-expanded; Escape closes it and returns focus. - Phone mockups are exposed as single images (
role="img"with a description of the screen) or hidden when purely decorative, so screen reader users aren't read every number inside a fake screen. - Accordions, the lightbox (focus trap, Escape, focus return),
aria-pressedtoggles and live regions for filter counts, carousel status and toasts. - Reduced motion turns off reveals, count-ups, the tour transitions and smooth scrolling.
When editing, give every new phone a meaningful aria-label, keep one h1 per page and label every form
field.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as pricing.html rather than
pricing/index.html (build.format: "file" in astro.config.mjs), and links point to /pricing without a
trailing slash. compressHTML: false keeps whitespace exactly as written, so text that continues on the next
line before an inline element keeps its space.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/pricingservespricing.html. - GitHub Pages: use the official Astro GitHub Action.
For a project site (
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/pricingtopricing.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/pocket pnpm build.
It becomes Astro's base, and every link written with url() includes it.
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides
the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6
API that astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a
single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and asset paths withurl()fromsrc/lib/site.tsrather than plain"/..."strings. /pricingshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"and rebuild.- The copy buttons show "Copy failed". Some browsers block clipboard access on plain
http://; it works onhttps://andlocalhost. - The lightbox shows the wrong screen. The
data-lightbox-openindexes must match the order of the<template data-lightbox-item>elements. - A new release doesn't respond to the filters. Check that the
<article>hasdata-releaseand adata-platform, and each<li>hasdata-change. - The tour phone doesn't change. Each
data-tour-stepvalue needs a matchingdata-tour-screeninsidedata-tour-phone. - The theme flashes on load. Keep the
<script is:inline>in the layout's<head>; withoutis:inline, Astro would bundle it and run it too late. - Shared links show the wrong domain or no image. Set
SITE_URLto your domain at build time.
License#
MIT, like every Freeframe template: free for personal and commercial projects, no attribution required.
Artwork and logos are included. See LICENSE.txt in the download. The privacy policy is placeholder text, not
legal advice.