Freeframe

Documentation

Echo

v1.0.2Updated Oct 17, 2026A bold, dark podcast website with a real audio player, chapters, a searchable transcript and an episode archive.

Overview#

Echo is a website template for a podcast, built for Overtone, a fictional independent interview show about how things get made. Its hosts, Nadia Ferreira (an industrial designer) and Eli Brandt (a science journalist), record in a former piano workshop in Philadelphia and have published 62 episodes over four seasons. The site has a home page with the latest episode, an episode archive, episode pages with chapters, show notes and a transcript, hosts, subscribe, advertise and be-a-guest pages, and an audio player that keeps playing while the visitor moves around the site. This is the Next.js version: Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site. The design is dark-first: a blackcurrant night background, wide Syne headlines, mono labels and colourful SVG cover art. This guide covers version 1.0.0. See it live at freeframe.dev/templates/echo.

Quick start#

You need Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev

Open http://localhost:3000. Edits to pages, components and data reload instantly.

Script What it does
pnpm dev Development server
pnpm build Static export to out/
pnpm start Serve out/ locally
pnpm lint ESLint
pnpm typecheck TypeScript

Pages#

Route File Purpose and notable features
/ src/app/page.tsx Hero with the latest episode and its player, "listen on" links, guest ticker, four featured episodes, topics with counts, hosts, listener reviews, newsletter form
/episodes src/app/episodes/page.tsx Archive: search, season and topic filters, sort, filter chips, load more, empty state (/episodes?topic=sound&season=4&q=room&sort=oldest)
/episode src/app/episode/page.tsx One episode: cover, full player with chapter markers, about and pull quote, chapters (#chapters), show notes (#notes), transcript (#transcript), guest card, share box (#share), related episodes. /episode?id=18&t=754 opens episode 18 at 12:34; without id it shows the latest episode
/hosts src/app/hosts/page.tsx Host cards, numbers, timeline, how an episode is made, press quotes (#press), team
/subscribe src/app/subscribe/page.tsx App links (#apple-podcasts, #spotify, #youtube, #pocket-casts, #overcast, #rss), copyable RSS feed, next release date, FAQ, email sign-up
/sponsor src/app/sponsor/page.tsx Audience stats, growth line chart, occupations donut, age and country bars, ad formats, rate card (#rates), enquiry form (#enquire; /sponsor?format=season ticks a format)
/guest src/app/guest/page.tsx What we look for, what happens next, pitch form
404 src/app/not-found.tsx "Dead air" page with links back into the site

Project structure#

echo/
  src/app/
    layout.tsx              <html>, fonts, skip link, header, footer, mini player, toast, back to top
    page.tsx                home page; one folder per route below it (episodes/, episode/, ...)
    not-found.tsx           404 page
    globals.css             Tailwind CSS: design tokens (@theme) and component classes
    fonts.ts                next/font/local setup for the three fonts
    icon.svg                favicon
    opengraph-image.png     social image (1200×630)
  src/components/
    player/PlayerUI.tsx     player building blocks: <PlayerRoot>, <Toggle>, <Wave>, <Rate>, <Volume>...
    player/HeroPlayer.tsx   the home page player (always the newest episode)
    player/MiniPlayer.tsx   the mini player docked to the bottom of every page
    EpisodePage.tsx         the episode page: player, chapters, notes, transcript, guest, share
    Archive.tsx             the /episodes filters and results
    EpisodeCards.tsx        episode cards, archive rows and play buttons
    Art.tsx                 SVG cover art, waveforms and guest avatars
    Charts.tsx              advertise page charts
    Forms.tsx               the four forms; useValidatedForm.ts is their validation hook
    Subscribe.tsx           RSS copy button and next release date
    SiteHeader.tsx, SiteFooter.tsx, ListenOn.tsx, Icons.tsx, Toast.tsx, RevealObserver.tsx,
    BackToTop.tsx, Year.tsx
  src/data/
    episodes.ts             every episode (EPISODES)
    details.ts              chapters, show notes, transcripts and guest bios (DETAILS)
    show.ts                 show name, feed, credits, topics and seasons (SHOW, TOPICS, SEASONS)
    site.ts                 menu, podcast app links, contact addresses (NAV, APPS, CONTACT)
    audience.ts             advertise page chart numbers (AUDIENCE)
  src/lib/
    player.ts               the player engine and saved progress
    episodes.ts             look-ups, generated chapters/notes/transcripts, archive filters
    format.ts, art.ts, validate.ts, toast.ts, metadata.ts, asset.ts, use-class.ts, use-client-value.ts,
    use-document-meta.ts
  src/fonts/                woff2 font files and their license (OFL.txt)
  public/                   host portraits (host-nadia.svg, host-eli.svg), og-image.svg source
  next.config.ts            static export and optional base path
  template.json, README.md, DOCS.md, CHANGELOG.md

Customising#

Brand and colours#

The design tokens live in the @theme block of src/app/globals.css. Every utility such as bg-night, text-muted or text-lilac comes from them.

Token Default Used for
--color-night #0e0c13 Page background
--color-ink-900, --color-ink-800, --color-ink-700 #15121c, #1c1826, #272132 Sections, cards and inputs, hover states
--color-line #342c42 Borders, rules, the unplayed part of waveforms
--color-paper #f4efe6 Headings and body text
--color-muted, --color-subtle #b6adc2, #948aa3 Secondary text and captions
--color-lilac #b9a4ff Main accent: buttons, links, the played part of waveforms, focus rings
--color-tomato, --color-sun, --color-sky, --color-mint "On air" dots, stars and chapter markers, topic colours

To change the main accent, edit the lilac tokens:

css
@theme {
  --color-lilac: #7ee0d0;
  --color-lilac-soft: #b4f0e6;
  --color-lilac-deep: #3fbfa8;
}

A few colours are also written in data and SVG because they are drawn into artwork: the season cover colours in SEASONS (src/data/show.ts), the occupation colours in AUDIENCE.work, the chart strokes in src/components/Charts.tsx, the logo in src/components/Icons.tsx and the colours inside the SVG files in public/. Echo is a dark-only design (color-scheme: dark in globals.css and in the layout's viewport export). There is no light theme or toggle.

Typography#

The fonts are self-hosted with next/font/local in src/app/fonts.ts, from the same woff2 files Google Fonts serves to the HTML version (src/fonts/, SIL Open Font License), so text measures exactly the same:

  • Syne (600–800) for display headings, through the display utility and the .h-hero, .h-page and .h-section classes, and inside the SVG cover art.
  • Schibsted Grotesk (400–800, italic 400–600) for body text (--font-sans).
  • DM Mono (400 and 500) for labels, timestamps and episode numbers (--font-mono, the .meta class).

Each family has a latin and a latin-ext file split by unicode-range, so names such as Mira Kovač render in the same font. Every face keeps its plain family name (the declarations option), so CSS and SVG can say "Syne" directly. To swap a font, put its woff2 files in src/fonts/, change the localFont() calls and the matching stack in @theme (--font-display, --font-sans, --font-mono).

Content#

Static text (hero, hosts, reviews, press, rates, FAQ) is in each page's file, mostly in small arrays at the top (REVIEWS, STORY, PRESS, RATES, FAQ...). Everything about episodes comes from src/data/:

Export File What it holds
SHOW show.ts Show name, the RSS feed address shown on /subscribe, and the credits line added to show notes
TOPICS show.ts Topic ids, labels, colour dot class and a one-line description (filters, home topic list)
SEASONS show.ts Season labels, years and the cover colours for each season
EPISODES episodes.ts One Episode object per episode
DETAILS details.ts Optional chapters, show notes, transcript and guest bio and links per episode id
APPS site.ts Podcast apps: name, your show's address in the app, blurb and icon colour
AUDIENCE audience.ts Numbers for the advertise page charts

An episode looks like this. duration is in seconds and date is YYYY-MM-DD:

ts
{ id: 63, season: 4, title: "The Sound of Snow", guest: "Dr. Mira Holm", role: "Snow physicist", topic: "science", date: "2026-10-21", duration: 3540,
  summary: "Why fresh snow makes a city quiet, and how Mira measures it.",
  quote: "Snow is a very large, very cold acoustic panel.",
  src: "/audio/episode-63.mp3" },

The newest date becomes the latest episode everywhere (latest() in src/lib/episodes.ts): the home hero, the "New episode" links, /episode without an id and the subscribe page's next release date (every other Wednesday after it). The home page's "Start with these four" list is FEATURED in src/app/page.tsx.

To give an episode real chapters, notes and a transcript, add an entry to DETAILS with its id. Times are seconds from the start. Notes are blocks: a string is a paragraph, { h } a heading, { ul } a list and { links } a list of [label, href] pairs (internal links like /episode?id=62 use next/link; https: links open in a new tab):

ts
63: {
  bio: "Mira Holm studies how snow absorbs sound at a research station in Tromsø.",
  links: [["Mira’s lab", "https://example.com/snow-lab"]],
  chapters: [{ t: 0, title: "Cold open" }, { t: 140, title: "Meet Mira" }, { t: 1260, title: "Measuring quiet" }],
  notes: ["Paragraph of show notes.", { h: "Mentioned" }, { links: [["Episode 62: The Quietest Room on Earth", "/episode?id=62"]] }],
  transcript: [[0, "Eli", "From a former piano workshop in Philadelphia, this is Overtone."], [140, "Mira", "Thanks for having me."]],
},

Episodes without a DETAILS entry still get a full page: chaptersFor() spreads a standard running order across the episode, notesFor() builds notes from the summary, chapters and related episodes, and transcriptFor() writes a short excerpt marked as such. Replace these with your real content when you can.

Images and artwork#

There are no photos. All artwork is SVG:

  • Cover art is drawn by <CoverArt ep={ep} /> in src/components/Art.tsx: the season colour from SEASONS, a motif for the topic (waveform for sound, orbits for science, a shape grid for design, a truss for engineering, woven scales for craft) and the episode number. Covers are square. To use your own artwork, return an <img src={...} alt="" /> from CoverArt instead, with square images of at least 600×600 in public/ (wrap the path in asset() from src/lib/asset.ts).
  • Waveforms come from bars() in src/lib/art.ts and <WaveSVG>, seeded by the episode id, so each episode always has the same shape. They are decorative; they don't analyse audio.
  • Guest avatars (<Avatar>) are the guest's initials on the season colour.
  • Host portraits are public/host-nadia.svg and public/host-eli.svg (5:6). Replace them with photos of the same ratio, for example 800×960, and keep the alt text.
  • Social image: src/app/opengraph-image.png (1200×630), rendered from public/og-image.svg. Edit the SVG, export it to PNG at 1200×630 and replace the PNG. The favicon is src/app/icon.svg.

The header (src/components/SiteHeader.tsx) and footer (src/components/SiteFooter.tsx) are rendered once by the layout. The main menu is NAV in src/data/site.ts; the mobile menu adds Home and Subscribe. The current page gets aria-current="page" from the URL (the episode page counts as Episodes). The "New episode" links point at latest(). The "Listen on" links (src/components/ListenOn.tsx) and the footer's app links go to the matching card on /subscribe; the cards link to APPS[].url. Replace the https://example.com/overtone/... addresses with your real show pages.

Features#

The player#

The engine is src/lib/player.ts. It lives outside the React tree as a module-level singleton, so there is only ever one player (and at most one <audio> element) for the whole site, and it keeps running while Next.js swaps pages. Components read its state with usePlayer() (built on useSyncExternalStore) and control it with the Player object: load, play, pause, toggle, seek, skip, cycleRate, setVolume, toggleMute, unload, plus restore() and cue() (below).

The interfaces are built from the parts in src/components/player/PlayerUI.tsx. <PlayerRoot> is one player; usePlayerUI(ep) says which episode it controls:

Player File Controls
Home hero (data-player-ui="latest") player/HeroPlayer.tsx Always the newest episode
Episode page (#episode-player) EpisodePage.tsx The episode in the URL
Mini player (#mini-player) player/MiniPlayer.tsx Whatever is loaded

Inside a <PlayerRoot>, use any of <Toggle>, <Back>, <Fwd>, <Rate>, <Mute>, <Volume>, <Close>, <Wave> (the waveform scrubber: an invisible <input type="range"> over two SVG layers), <Cur>, <Dur> and <ProgressBar>. Pressing play anywhere pauses everything else, and every player and play button (<PlayButton> in EpisodeCards.tsx) stays in sync. Settings: skip length is SKIP (15 seconds) and the speed cycle is RATES (1, 1.25, 1.5, 1.75, 2, 0.75).

Real audio or a simulated clock. If an episode has a src, Player.load() creates an <audio> element for it, and the time, duration, speed, volume and end of the episode come from the real file. Episodes without src play on a clock: a timer advances the position every 250 ms by the elapsed time multiplied by the speed, until it reaches duration. That is how the demo plays all 62 episodes with no audio files. To go live, add src to each episode: a file in public/ (for example /audio/episode-63.mp3; with a base path, include it) or your podcast host's MP3 URL. Keep duration accurate: it is shown before the file has loaded and used for chapters and the waveform.

Keyboard. Inside any player: Space or K play or pause, ←/→ or J/L skip 15 seconds, ↑/↓ change the volume and M mutes. On the waveform slider itself the arrows move 5 seconds, Page Up/Page Down a minute and Home/End jump to the ends. On the episode page, K, J, L and M also work anywhere on the page while you are not typing in a field. The episode player lists the shortcuts under "Shortcuts".

Accessibility. Every player is labelled (the episode and mini players are regions); the play button's label says what will happen ("Play episode 62: The Quietest Room on Earth"); the seek slider announces its position in words ("12 minutes 34 seconds of 57 minutes 56 seconds"); mute is a toggle button with aria-pressed. Animated equaliser bars and the ripple rings around the home cover only move while playing and stay still with reduced motion.

Mini player and saved progress#

<MiniPlayer> is rendered by src/app/layout.tsx, so it stays mounted while pages change: playback carries on without a gap during client-side navigation. It slides up as soon as an episode is loaded, and the page gets extra bottom padding (the has-mini class on <html>) so nothing is hidden behind it. On an episode page it tucks away while that episode's own player is on screen (the page reports this with Player.setPage() from an IntersectionObserver). The close button stops playback and hides it; the position is kept.

The engine saves to localStorage every five seconds while playing, and on pause, seek and when the page is closed (see Saved data). After a full page load or on a later visit, Player.restore() (called once by the mini player) loads the same episode at the same second; if it was playing it carries on (a real audio file may be blocked by the browser's autoplay rules, in which case it waits, paused, for the visitor to press play). Episode cards and archive rows show "23 min left" or "Played" with a progress bar: the text is worked out when the list is drawn (on page load, or when the archive's filters change; useDrawnProgress() in EpisodeCards.tsx) and the bar follows playback.

Episode archive#

<Archive> (src/components/Archive.tsx) filters EPISODES with filterEpisodes() from src/lib/episodes.ts: a search across title, guest, role, summary and topic (matches are highlighted), one season, one topic and a sort (newest, oldest, longest, shortest in SORTS). The state is read from the URL by readFilters() and written back with history.replaceState, so filtered views can be shared and bookmarked; unknown values are ignored. Topic buttons show live counts within the current season and search. Results show 12 at a time (PAGE_SIZE); "Show more" focuses the first new episode. Active filters appear as removable chips, and an empty state offers to clear them. Filter buttons use aria-pressed, and the result count is a polite live region. The static page shows the unfiltered archive; the filters from the URL apply in the browser (<ArchiveFromUrl> inside <Suspense>).

Chapters, transcript and sharing#

/episode renders <EpisodeFromUrl>, which reads id and t from the URL (an unknown id shows "Episode not found"), and <EpisodeView> for the episode. The exported HTML is the newest episode; the browser updates the title and description for the episode in the URL (useDocumentMeta() in src/lib/use-document-meta.ts).

  • Chapters: each is a button that seeks there and plays. The current chapter gets aria-current="true", is named above the waveform, and appears as a dot under the waveform (<Wave chapterMarks>).
  • Transcript: each line is a button that seeks and plays. The current line is highlighted while you listen, and with "Follow along while playing" ticked the transcript box scrolls to keep it in view (it stops following for a few seconds after you scroll it yourself, and scrolls instantly with reduced motion). The search box filters lines and highlights matches.
  • Share: the share box shows the episode link; tick "Start at" to add &t= with the current second, then "Copy link". Opening a link with ?t= cues the episode at that point, paused, with a note saying so (Player.cue(); if another episode is already playing, it keeps playing and the shared position is saved for this one). t also accepts 12:34 or 1:02:03. Where the browser supports it, a "More" button opens the system share sheet.

Charts#

src/components/Charts.tsx draws the growth line chart and the occupation donut as inline SVG, and the age and country charts as CSS bars, from AUDIENCE. They are Server Components, so the charts are in the static HTML. Each SVG chart has a text alternative in aria-label, the bar lists are readable as text, and "Show the numbers" opens a data table for the growth chart. Change the numbers in AUDIENCE and the aria-label text together.

Header, menu, reveals and back to top#

The header turns solid after scrolling. Below 1024 px the menu button opens a full-height menu (aria-expanded, Esc closes it and returns focus to the button; following a link closes it). Elements with .reveal fade in on scroll (src/components/RevealObserver.tsx, which also picks up elements added later), and revealing one also reveals everything above it; with reduced motion they are visible straight away. The back-to-top button (src/components/BackToTop.tsx) lifts itself above the mini player and toasts (anything marked data-back-to-top-avoid). To remove it, delete <BackToTop /> from the layout and the .back-to-top styles in globals.css.

Forms#

All four forms are in src/components/Forms.tsx and use useValidatedForm() (src/components/useValidatedForm.ts) with the rules in src/lib/validate.ts. Fields stay uncontrolled (their values are read from the form), errors appear on blur and on submit, re-check as you type once shown, and the first invalid field is focused. Each error is in the element <field id>-error, linked with aria-describedby. On success the form is replaced by its success box, which takes focus.

Attribute Rule
required Must not be empty (checkboxes must be ticked; message from data-msg)
type="email" Must look like an email address
type="url" Must look like a web address (example.com/work)
minLength Minimum length, with how many characters to go
data-label Words used in the "Enter …" / "Choose …" message
<fieldset data-required-group data-msg="..."> At least one checkbox ticked
Form Page Checks
<NewsletterForm> (#newsletter-form) Home Email
<SubscribeForm> (#sub-form) Subscribe Email
<SponsorForm> (#sponsor-form) Advertise Name, company, email, optional website, at least one format, budget, message (30+ characters)
<GuestForm> (#guest-form) Be a guest Name, email, their name when "Someone else" is chosen, field, topic, optional link, story (120+ characters with a live counter), consent

The demo forms don't send anything until you connect them; the success messages say so. To collect submissions, post the form in onValid() in useValidatedForm.ts before showing the success state, for example to Formspree:

ts
async function onValid(el: HTMLFormElement) {
  const res = await fetch("https://formspree.io/f/your-form-id", {
    method: "POST",
    body: new FormData(el),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return toast("Something went wrong. Please email us.");
  // ...then the existing lines: setSent(...), el.reset()
}

(toast is in src/lib/toast.ts.) Use a different endpoint per form by checking el.id. Every field has a name, so FormData carries all values; keep a name on any field you add. For the newsletter, most email services (Buttondown, Mailchimp, ConvertKit) accept the same fetch to their form endpoint. Then reword the success messages, which currently say that nothing was sent.

If you drop the static export (see Building and deploying) you can post to your own Route Handler instead, for example src/app/api/pitch/route.ts:

ts
export async function POST(req: Request) {
  const data = await req.formData();
  // send an email, save to a database...
  return Response.json({ ok: true, name: data.get("name") });
}

Saved data (localStorage)#

Key Shape Notes
overtone-player { "id": 18, "rate": 1.25, "volume": 0.9, "muted": false, "playing": true } The loaded episode and player settings. Unknown ids and speeds not in RATES are ignored on read, and the volume is clamped to 0–1. Without id, nothing is loaded
overtone-progress { "18": 1720.4, "62": 312 } Seconds listened per episode id. Positions within 2 seconds of the end count as played and restart from the top

The keys are STORE_PLAYER and STORE_PROGRESS in src/lib/player.ts. Values are read with readJSON(), which falls back to defaults on missing or broken data, and written with writeJSON(), which ignores storage errors (private windows, full storage) so playback still works. Storage is only read in the browser, after the page has loaded, so the static HTML never depends on it. To reset, run localStorage.removeItem("overtone-player"); localStorage.removeItem("overtone-progress") in the browser console.

SEO and sharing#

Each page exports its own metadata (title, description, Open Graph) with pageMeta() from src/lib/metadata.ts; the layout adds the site name, Twitter card, theme colour and metadataBase. Set your domain with SITE_URL when building so the social image URL is absolute:

sh
SITE_URL=https://www.yourshow.com pnpm build

The episode page's exported HTML describes the newest episode; the browser updates the title and description for the episode in ?id=. If search visibility for each episode matters, generate one route per episode instead (for example src/app/episodes/[id]/page.tsx with generateStaticParams() returning every EPISODES id, and generateMetadata() for its title). Consider adding src/app/sitemap.ts, src/app/robots.ts, PodcastEpisode structured data (JSON-LD) and alternates: { types: { "application/rss+xml": SHOW.feed } } in the layout's metadata so podcast apps can find your feed. The 404 page is noindex.

Accessibility#

  • Skip link, landmarks (header, nav, main, footer, labelled aside and regions) and one h1 per page; the breadcrumb uses aria-current.
  • Visible focus rings in lilac on every control, including the invisible seek slider (the waveform gets the ring).
  • The player is fully keyboard operable, with labelled buttons, a slider with spoken values and documented shortcuts (aria-keyshortcuts). When the mini player hides with focus inside it, focus moves to the main content.
  • Filters use aria-pressed, the current chapter and transcript line use aria-current, result counts, the toast and form errors are live regions.
  • Reduced motion turns off scroll reveals, the ticker, equaliser and ripple animations, and smooth scrolling.
  • Text colours meet WCAG AA on every surface.

When editing, keep one h1 per page, give new inputs a <label> and an error element with the matching id, and keep alt text on replaced images.

Building and deploying#

pnpm build writes a static site to out/ (output: "export" in next.config.ts). Upload out/ to any static host: Vercel, Netlify, Cloudflare Pages, GitHub Pages or your own server. On Vercel you can also import the project directly; it detects Next.js. Audio files can live in public/audio/ or on your podcast host.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building (the live demo uses /demos/nextjs/echo):

sh
DEMO_BASE_PATH=/podcast pnpm build

Pages are exported as episodes.html, episode.html and so on; most hosts serve them at /episodes. To use server features (Route Handlers, Server Actions, per-request rendering), remove output: "export" and images.unoptimized from next.config.ts and deploy to a host that runs Next.js.

Troubleshooting#

  • Nothing plays when I add a src. Check the path (files in public/ are served from /, plus the base path if you set one), that the file is MP3, AAC or another format the browser plays, and that your audio host allows it (some hosts block hotlinking). Errors show a toast.
  • The player keeps resuming an old episode. That is the saved state. Clear it with the commands in Saved data, or press the mini player's close button.
  • "Copy link" doesn't copy. The clipboard API needs https or localhost; elsewhere the template falls back to selecting the link so it can be copied by hand.
  • useSearchParams() should be wrapped in a suspense boundary when building. Components that read the URL (<ArchiveFromUrl>, <EpisodeFromUrl>, <FormatPreselect>) must stay inside <Suspense> in their page.
  • /episode?id=... works locally but 404s on my host. The host must serve episode.html for /episode. Most do; on others link to /episode.html?id=... or add a rewrite.
  • Times or chapters are wrong after replacing audio. Update duration and the chapter t values in DETAILS, all in seconds.

License#

MIT. Free for personal and commercial use, including client work. The full text is in LICENSE.txt in the download. All artwork (cover art, waveforms, portraits and icons) is part of the template and covered by the same license; the fonts are under the SIL Open Font License (src/fonts/OFL.txt).

Search documentation

Search the guides and every template's documentation