Freeframe

Documentation

Pocket

v1.0.1Updated Oct 9, 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 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).

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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.json

Pages 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:

css
@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 use data-billing-note with data-monthly-note and data-annual-note.
  • Release notes: each release in changelog.astro is an <article data-release data-platform="both"> (both, ios or android), and each change an <li data-change="new"> (new, improved or fixed). Add one by copying an existing release:
html
<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.astro is inside a <section data-help-group> and marked data-faq-item; the search covers the whole text of every item. Copy an existing item, give its button a new aria-controls value and its panel the matching id.
  • 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 in public/img/:
html
---
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 their href at 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.svg and favicon.svg are plain SVG.
  • Social image: public/img/og-image.png (1200×630) comes from og-image.svg; edit the SVG and export a new PNG.

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:

html
---
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.

/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.

/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 Email

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 and aria-invalid is 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 using data-match="<id>" and data-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:

html
<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):

ts
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 title and description to <Layout>, which turns them into <title>, the meta description, og:title, og:description and a summary_large_image Twitter card, plus theme-color for light and dark.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://pocket.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm build

    Canonical 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 a public/robots.txt that 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 h1 per 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-pressed toggles 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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /pricing serves pricing.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /pricing to pricing.html, change build.format to "directory" in astro.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 with url() from src/lib/site.ts rather than plain "/..." strings.
  • /pricing shows a 404 on my host. The host doesn't map extensionless URLs to .html files. Set build.format: "directory" and rebuild.
  • The copy buttons show "Copy failed". Some browsers block clipboard access on plain http://; it works on https:// and localhost.
  • The lightbox shows the wrong screen. The data-lightbox-open indexes must match the order of the <template data-lightbox-item> elements.
  • A new release doesn't respond to the filters. Check that the <article> has data-release and a data-platform, and each <li> has data-change.
  • The tour phone doesn't change. Each data-tour-step value needs a matching data-tour-screen inside data-tour-phone.
  • The theme flashes on load. Keep the <script is:inline> in the layout's <head>; without is:inline, Astro would bundle it and run it too late.
  • Shared links show the wrong domain or no image. Set SITE_URL to 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.

Search documentation

Search the guides and every template's documentation