Freeframe

Documentation

Echo

v1.0.0Updated Oct 12, 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 follows the visitor from page to page. This is the Astro version: Astro 7, TypeScript and Tailwind CSS v4, with no UI framework; the interactive parts are plain TypeScript modules, and the site builds to static HTML files. 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          # http://localhost:4321

Edit any file in src/ and the page reloads. To build the static site:

sh
pnpm build        # writes dist/
pnpm preview      # serves dist/ at http://localhost:4321

Pages#

File Route Purpose and notable features
src/pages/index.astro / Hero with the latest episode and its player, "listen on" links, guest ticker, four featured episodes, topics with counts, hosts, listener reviews, newsletter form
src/pages/episodes.astro /episodes Archive: search, season and topic filters, sort, filter chips, load more, empty state (/episodes?topic=sound&season=4&q=room&sort=oldest)
src/pages/episode.astro /episode 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
src/pages/hosts.astro /hosts Host cards, numbers, timeline, how an episode is made, press quotes (#press), team
src/pages/subscribe.astro /subscribe App links (#apple-podcasts, #spotify, #youtube, #pocket-casts, #overcast, #rss), copyable RSS feed, next release date, FAQ, newsletter form
src/pages/sponsor.astro /sponsor Advertise: 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)
src/pages/guest.astro /guest Be a guest: what we look for, what happens next, pitch form
src/pages/404.astro /404 "Dead air" page with links back into the site

The site is built with build.format: "file", so each page becomes episodes.html and is linked as /episodes, the same URLs as the HTML version.

Project structure#

echo/
  astro.config.mjs            site URL, base path, static output (build.format "file")
  package.json                scripts and dependencies
  tsconfig.json               strict TypeScript (Astro preset)
  eslint.config.mjs           ESLint flat config (typescript-eslint + eslint-plugin-astro)
  public/img/                 favicon.svg, host portraits, og-image.svg and og-image.png
  src/
    layouts/Layout.astro      <html>, <head> metadata, header, footer, mini player, toast, script imports
    components/
      SiteHeader.astro        header, desktop nav and mobile menu
      SiteFooter.astro        footer columns
      MiniPlayer.astro        the player docked to the bottom of every page
      ListenLinks.astro       "listen on" pill links to the subscribe page
    pages/                    one .astro file per page (see Pages)
    data/
      show.ts                 SHOW, TOPICS, SEASONS, EPISODES, DETAILS, AUDIENCE
      nav.ts                  header, mobile and footer links, podcast apps
      reviews.ts              listener reviews, rating, ticker roles
      story.ts                hosts page: numbers, timeline, making-of, press, team
      advertise.ts            advertise page: stats, rates, formats, form choices
      faq.ts                  subscribe page questions
      guest.ts                be-a-guest page: what we look for, next steps
    lib/
      site.ts                 site name, theme colour and the url() helper
      episodes.ts             episode lookups and time/date formatting, shared by pages and scripts
      icons.ts                inline SVG icons used in several places
    scripts/
      main.ts                 player, player interfaces, cards, archive, episode page, charts, forms, chrome
      art.ts                  cover art, waveforms and guest avatars drawn as SVG
      back-to-top.ts          the back-to-top button
    styles/global.css         Tailwind source: design tokens and component classes
  README.md, DOCS.md, CHANGELOG.md

src/scripts/main.ts is split into numbered sections (helpers, player engine, player interfaces, cards and home, archive, episode page, charts, forms, chrome). Search for the section title to find your way around. The layout imports main.ts and back-to-top.ts once, and Astro bundles them into one module.

Customising#

Brand and colours#

The design tokens live in the @theme block of src/styles/global.css. Every utility such as bg-night, text-muted or text-lilac comes from them. Astro compiles the CSS with @tailwindcss/vite; there is no separate CSS build.

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 TypeScript and SVG because they are drawn into artwork: the season cover colours in SEASONS and the occupation colours in AUDIENCE.work (src/data/show.ts), the chart strokes in initCharts() (src/scripts/main.ts), the logo in src/lib/icons.ts and the colours inside public/img/*.svg. The browser theme colour is themeColor in src/lib/site.ts. Echo is a dark-only design (color-scheme: dark in global.css and a color-scheme meta tag in the layout). There is no light theme or toggle.

Typography#

Three Google Fonts are loaded by the <link> in src/layouts/Layout.astro:

  • 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 for body text (--font-sans).
  • DM Mono for labels, timestamps and episode numbers (--font-mono, the .meta class).

To swap a font, change the Google Fonts URL in the layout and the matching stack in @theme (--font-display, --font-sans, --font-mono). Each stack ends with system fallbacks. Cover art names the fonts directly (font-family="Syne, sans-serif" in src/scripts/art.ts).

Content#

Page text (hero, hosts, reviews, press, rates, FAQ) is in the .astro pages and the small data files next to them (src/data/reviews.ts, story.ts, advertise.ts, faq.ts, guest.ts). Everything about episodes comes from src/data/show.ts:

Export What it holds
SHOW Show name, the RSS feed address shown on the subscribe page, and the credits line added to show notes
TOPICS Topic ids, labels, colour dot class and a one-line description (used by filters and the home topic list)
SEASONS Season labels, years and the cover colours for each season
EPISODES One Episode object per episode
DETAILS Optional chapters, show notes, transcript and guest bio and links, by episode id
AUDIENCE Numbers for the advertise page charts

The types (Episode, Details, NoteBlock, TranscriptLine...) are at the top of the same file, so your editor checks new entries. 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: the home hero, the "New episode" links, the footer's "Latest episode", the episode page without an id, and the subscribe page's next release date (two weeks later). The home page's "Start with these four" list is the featured array at the top of src/pages/index.astro.

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, url] pairs. Links that start with / are pages of this site and get the base path automatically:

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() in src/scripts/main.ts 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 generated by cover(ep) in src/scripts/art.ts: 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=""> string from cover() instead, with square images of at least 600×600 in public/ (build the src with url() from src/lib/site.ts).
  • Waveforms are generated by bars() and waveSVG() from a stable hash of the episode id, so each episode always has the same shape. They are decorative; they don't analyse audio.
  • Guest avatars (avatar(ep)) are the guest's initials on the season colour.
  • Host portraits are public/img/host-nadia.svg and public/img/host-eli.svg (5:6). Replace them with photos of the same ratio, for example 800×960, and keep the alt text.
  • Social image: public/img/og-image.png (1200×630) is rendered from og-image.svg. Edit the SVG, then export it to PNG at 1200×630 with any browser or design tool. The favicon is public/img/favicon.svg.

The header and mobile menu are src/components/SiteHeader.astro, the footer SiteFooter.astro; both read their links from src/data/nav.ts (mainNav, mobileNav, footerColumns). A page marks its header link by passing current to the layout:

<Layout title="Hosts & story — Overtone" description="…" page="hosts" current="hosts">

Links with data-latest-link point at the newest episode. The "Listen on" links on the home and episode pages (ListenLinks.astro) and in the footer go to the matching card on the subscribe page; the cards link to your show in each app. Replace the https://example.com/overtone/… addresses in platforms in src/data/nav.ts, and the feed address in SHOW.feed.

Every internal link goes through url() from src/lib/site.ts, which adds the base path when the site is served from a sub-folder. Use it for new links too: url("/episodes?topic=craft").

Features#

The player#

Every element with data-player-ui is a player. bindPlayer() in src/scripts/main.ts wires up whatever controls it contains:

Attribute value Where Controls
data-player-ui="latest" Home hero Always the newest episode
data-player-ui="62" #episode-player on the episode page (set from the URL) That episode
data-player-ui="mini" #mini-player on every page (MiniPlayer.astro) Whatever is loaded

All of them share one engine, Player, so pressing play anywhere pauses everything else, and every player and play button stays in sync. Inside a player, these hooks are used when present: [data-act="toggle"], "back", "fwd", "rate", "mute" and "close" buttons, [data-wave] (the waveform scrubber, built as an invisible <input type="range"> over two SVG layers), [data-vol], [data-cur], [data-dur], [data-bar] and the data-ui-* text hooks. Any button with data-play="<id>" plays or pauses that episode.

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 path that starts with / is a file in public/ (/audio/episode-63.mp3 for public/audio/episode-63.mp3, base path added for you), or use your podcast host's full 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, Left/Right arrows or J/L skip 15 seconds, Up/Down arrows 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#

#mini-player slides up at the bottom of the page as soon as an episode is loaded, and the page gets extra bottom padding (.has-mini) so nothing is hidden behind it. On an episode page it tucks away while that episode's own player is on screen. The close button stops playback and hides it; the position is kept.

The player saves to localStorage every five seconds while playing, and on pause, seek and page change (see Saved data). On the next page it 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.

Episode archive#

initArchive() on /episodes filters EPISODES with filterEpisodes(): 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 written to the URL with history.replaceState, so filtered views can be shared and bookmarked; unknown values in the URL 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.

Chapters, transcript and sharing#

There is one episode page for every episode. initEpisodePage() fills it from the id in the URL (an unknown id shows the #episode-missing template instead), and sets the document title and description.

  • 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 (data-chapter-marks).
  • 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= loads the episode at that point, paused, with a note saying so. t also accepts 12:34 or 1:02:03. Where the browser supports it, a "More" button opens the system share sheet.

Charts#

initCharts() on /sponsor draws the growth line chart and the occupation donut as inline SVG, and the age and country charts as CSS bars, from AUDIENCE. 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 in src/pages/sponsor.astro 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). Elements with .reveal fade in on scroll, and revealing one also reveals everything above it; with reduced motion they are visible straight away. The back-to-top button (src/scripts/back-to-top.ts) lifts itself above the mini player and toasts. To remove it, delete that file, its import in src/layouts/Layout.astro and the .back-to-top styles in src/styles/global.css.

Forms#

Every form with data-validate is checked on submit by initForms(): required fields, email format, web addresses (type="url"), minimum lengths, required checkboxes and checkbox groups (<fieldset data-required-group>). Errors appear under each field (in the element whose id is the field id plus -error), the first invalid field gets focus, and fields re-check as you fix them. On success the form is replaced by its success box (data-success="<id>"), which takes focus.

Form Page Checks
#newsletter-form Home Email
#sub-form Subscribe Email
#sponsor-form Advertise Name, company, email, optional website, at least one format, budget, message (30+ characters)
#guest-form Be a guest Name, email, their name when "Someone else" is chosen (data-toggle-when), 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 connect them, edit onValid() in section 10 of src/scripts/main.ts. With Formspree:

ts
function onValid(form: HTMLFormElement): void {
  fetch("https://formspree.io/f/your-form-id", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  }).then((res) => {
    if (res.ok) showSuccess(form);
    else toast("Something went wrong. Please email us.");
  });
}

Use a different endpoint per form by checking form.id. For the newsletter, most email services (Buttondown, Mailchimp, ConvertKit) accept the same fetch to their form endpoint. Then reword the success messages in the pages, which currently say that nothing was sent. The site is static, so there is no server code to write; if you add an Astro adapter later you could post to an API route instead.

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

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. To reset, run this in the browser console:

js
localStorage.removeItem("overtone-player");
localStorage.removeItem("overtone-progress");

To rename the keys, change STORE_PLAYER and STORE_PROGRESS in src/scripts/main.ts.

SEO and sharing#

Each page passes its title and description to <Layout>, which writes the meta description, a canonical link, Open Graph (og:title, og:description, og:url, og:image) and Twitter card tags, theme-color and the favicon. The 404 page passes noindex, which adds robots: noindex and leaves out the canonical URL.

Absolute URLs come from SITE_URL. Set it to your domain when you build, or change the default in astro.config.mjs:

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

og:image is public/img/og-image.png resolved against that URL. The episode page is one static page; its markup and metadata are the newest episode's, and the title and description are replaced with the selected episode's in the browser. For search engines and link previews that don't run JavaScript, you can generate one page per episode with a dynamic route (src/pages/episodes/[id].astro with getStaticPaths() over EPISODES). Consider adding @astrojs/sitemap, a public/robots.txt, and <link rel="alternate" type="application/rss+xml" href="…/feed.xml"> in the layout's <head> so podcast apps can find your feed.

Accessibility#

  • Skip link, landmarks (header, nav, main, footer, labelled aside and regions) and one <h1> per page.
  • 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).
  • Filters use aria-pressed, the current chapter and transcript line use aria-current, result counts 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#

Script What it does
pnpm dev Development server at http://localhost:4321
pnpm build Static site in dist/
pnpm preview Serves dist/
pnpm check astro check: type-checks .astro files and TypeScript
pnpm typecheck astro sync && tsc --noEmit
pnpm lint ESLint

Upload dist/ to any static host: Netlify, Cloudflare Pages, GitHub Pages, Vercel or your own server. Pages are built as episodes.html and linked as /episodes; those hosts serve the .html file for the extensionless URL (on Vercel, set "cleanUrls": true in vercel.json). 404.html is picked up as the not-found page by most of them.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building (it becomes Astro's base); every link and asset goes through url(), so nothing else changes:

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

Audio files can live in public/audio/ (they are copied to dist/audio/) or on your podcast host.

Troubleshooting#

  • Nothing plays when I add a src. Check the path (/audio/… means public/audio/…), 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; the template falls back to selecting the link. pnpm dev and pnpm preview run on localhost.
  • /episodes is a 404 on my host. The host doesn't map extensionless URLs to .html files. Turn on "clean URLs" (Vercel cleanUrls), or link to /episodes.html.
  • A Tailwind class written in a data file has no effect. Tailwind only sees complete class names. Write the full class (bg-mint, not "bg-" + colour) somewhere in src/.
  • Times or chapters are wrong after replacing audio. Update duration and the chapter t values in DETAILS, all in seconds.
  • Canonical and social URLs point at echo.example.com. Set SITE_URL when you build, or change site in astro.config.mjs.

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.

Search documentation

Search the guides and every template's documentation