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).
pnpm install
pnpm dev # http://localhost:4321Edit any file in src/ and the page reloads. To build the static site:
pnpm build # writes dist/
pnpm preview # serves dist/ at http://localhost:4321Pages#
| 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.mdsrc/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:
@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
displayutility and the.h-hero,.h-pageand.h-sectionclasses, and inside the SVG cover art. - Schibsted Grotesk for body text (
--font-sans). - DM Mono for labels, timestamps and episode numbers (
--font-mono, the.metaclass).
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:
{ 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:
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)insrc/scripts/art.ts: the season colour fromSEASONS, 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 fromcover()instead, with square images of at least 600×600 inpublic/(build thesrcwithurl()fromsrc/lib/site.ts). - Waveforms are generated by
bars()andwaveSVG()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.svgandpublic/img/host-eli.svg(5:6). Replace them with photos of the same ratio, for example 800×960, and keep thealttext. - Social image:
public/img/og-image.png(1200×630) is rendered fromog-image.svg. Edit the SVG, then export it to PNG at 1200×630 with any browser or design tool. The favicon ispublic/img/favicon.svg.
Navigation, header and footer#
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.talso accepts12:34or1: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 | |
#sub-form |
Subscribe | |
#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:
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:
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:
SITE_URL=https://yourshow.com pnpm buildog: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, labelledasideand 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 usearia-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:
DEMO_BASE_PATH=/podcast SITE_URL=https://example.com pnpm buildAudio 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/…meanspublic/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
httpsorlocalhost; the template falls back to selecting the link.pnpm devandpnpm previewrun onlocalhost. /episodesis a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Turn on "clean URLs" (VercelcleanUrls), 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 insrc/. - Times or chapters are wrong after replacing audio. Update
durationand the chaptertvalues inDETAILS, all in seconds. - Canonical and social URLs point at echo.example.com. Set
SITE_URLwhen you build, or changesiteinastro.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.