Overview#
Tempo is a website template for a band or solo musician, built for Marigold Static, a fictional four-piece indie band from the Southside of Glasgow (Isla Carrick, Reuben Oyelaran, Fenna de Wit and Callum Shiach) whose second album, Weather for Two, is about to come out on the fictional Lantern House Records. The site has a home page with a release countdown and pre-save, a discography with a working track player, tour dates, a merch store with a cart, an about page, an electronic press kit (EPK) and a contact form that routes messages by topic. This is the Astro version: Astro 7, TypeScript and Tailwind CSS v4, with no UI framework, building to plain static files. The look is a printed gig poster: warm paper, black ink, fluorescent pink, cobalt and sun-yellow "riso" inks, condensed Big Shoulders Display headlines and SVG artwork throughout. This guide covers version 1.0.0. See it live at freeframe.dev/templates/tempo.
Quick start#
Requires 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. pnpm build writes the finished static site to dist/.
Pages#
| Route | File | Purpose | Notable sections and features |
|---|---|---|---|
/ |
src/pages/index.astro |
Home | New-release hero with countdown, pre-save and "Play Glasshouse" (#presave), platform links, city ticker, the new album's track list, click-to-play video poster, next four shows, merch preview, press quote, Static Club sign-up (#join) |
/music |
src/pages/music.astro |
Discography | Release stats, album/EP/single filter with counts, release grid, release detail (#release) with track list, credits and previous/next links. /music?type=ep&release=night-bus-hymns#release |
/tour |
src/pages/tour.astro |
Tour | Region filter and country select, show list with status badges, calendar downloads, past shows, "notify me" form (#notify), FAQ. /tour?region=europe&country=Germany, /tour?notify=paris#notify |
/merch |
src/pages/merch.astro |
Store | Category filter (/merch?cat=apparel), product cards with colour and size options (#p-<product id>), shipping and sizing |
/about |
src/pages/about.astro |
About | Group illustration, story and stats, band members, timeline, press quotes |
/press |
src/pages/press.astro |
Press kit | Fact sheet, short/medium/long bio tabs with copy, downloads (#downloads), tech rider, press quotes, contacts (#contacts) |
/contact |
src/pages/contact.astro |
Contact | Topic-routed form (/contact?topic=booking), direct contacts, postal address |
/404 |
src/pages/404.astro |
Not found | "Not on the record" page with links back into the site |
The announcement bar, header (with the bag button), footer, player bar, cart drawer and toast come from the layout, so the cart and player work on every page.
Project structure#
tempo/
src/pages/*.astro one file per page (index, music, tour, merch, about, press, contact, 404)
src/layouts/Layout.astro <html>, <head> metadata, fonts, skip link, header, footer, player, cart, scripts
src/components/
SiteHeader.astro announcement bar, header, desktop nav, mobile menu
SiteFooter.astro footer columns and the big wordmark
PlayerBar.astro the docked audio player (#player)
CartDrawer.astro the bag, checkout and demo-order views (#cart)
src/data/band.ts BAND settings, RELEASES, REGIONS, TOUR, PRODUCTS, CONTACT_TOPICS
src/data/nav.ts header, mobile menu and footer links
src/lib/site.ts site name, theme colour and the url() helper
src/lib/icons.ts inline SVG icons shared by the components
src/scripts/main.ts all interactivity: artwork, audio, player, pages, cart, press kit, forms
src/scripts/back-to-top.ts the back-to-top button
src/styles/global.css Tailwind CSS: design tokens (@theme) and component classes
public/img/ favicon, band and member portraits, social image
astro.config.mjs site URL, base path, static output
package.json, pnpm-workspace.yaml, tsconfig.json, eslint.config.mjs
README.md, DOCS.md, CHANGELOG.mdsrc/scripts/main.ts is split into numbered sections: band data, helpers, artwork, audio, player
interfaces, home page, music page, tour page, merch and cart, press kit, forms, and header and reveals.
Search for the section title to find your way around. The layout imports it once, so it runs on every page
and each section only does something when its markup is on the page.
Customising#
Brand and colours#
All design tokens live in the @theme block at the top of src/styles/global.css:
| Token | Default | Used for |
|---|---|---|
--color-paper |
#f4efe6 |
Page background |
--color-paper-2 |
#ebe4d6 |
Alternate sections |
--color-card |
#fbf8f2 |
Cards, inputs, the cart drawer |
--color-ink |
#15130f |
Text, borders, dark sections, hard shadows |
--color-soft / --color-subtle |
#4b463d / #686155 |
Secondary text and captions on paper |
--color-fog / --color-haze |
#bdb5a6 / #9c9485 |
Secondary text and captions on ink |
--color-pink |
#ff4d94 |
Brand fill: primary buttons, chips, highlights (with ink text) |
--color-pink-deep |
#c4155f |
Pink text and links on paper |
--color-cobalt |
#2b3fd6 |
Second ink: the fan-club section, focus rings |
--color-sun |
#ffcf33 |
Third ink: highlights, stickers, success boxes |
To rebrand, change the inks; the dev server picks the change up straight away:
@theme {
--color-pink: #ff7a1a; /* fills, used with ink text */
--color-pink-deep: #b34700; /* the same colour dark enough for text on paper */
}Keep --color-pink-deep at a 4.5:1 contrast with the paper. A few SVGs (the logo mark in src/lib/icons.ts,
the hero vinyl and video poster in index.astro, the 404 record, and the covers and mock-ups drawn in
src/scripts/main.ts) use the hex values directly; search for #ff4d94 to update them too. The browser
theme colour is site.themeColor in src/lib/site.ts.
Dark sections are marked with the on-ink class. Inside them buttons, links, meta text and form fields
switch to their light-on-dark versions automatically.
Typography#
The fonts are loaded from Google Fonts by a <link> in src/layouts/Layout.astro:
- Big Shoulders Display (700–900) for the condensed poster headlines (
--font-display, theposterutility,.h-hero,.h-page,.h-section). - Familjen Grotesk for body text (
--font-sans). - Space Mono for labels, dates and prices (
--font-mono,.meta,.eyebrow,.chip).
To swap a font, change the Google Fonts URL in the layout and the matching --font-* token. The stacks
include system fallbacks, so the site still reads well if the fonts don't load.
Content#
Page text lives in the .astro files in src/pages/. Everything that is listed, filtered or played lives in
src/data/band.ts, typed with the interfaces at the top of each section:
| Export | What it holds |
|---|---|
BAND |
Name, site URL, email, currency and locale for prices and dates, freeShippingFrom, shipping, keepDemoDatesFresh |
RELEASES (Release[]) |
Every release, newest first: id, type (album, ep, single), title, date, label, cat, art, merch, blurb, credits, tracks |
REGIONS, TOUR (Show[]) |
Tour regions and shows: id, date, time (doors), city, country, region, venue, status, onSale, note, support, optional tickets |
PRODUCTS (Product[]), MERCH_CATS |
Merch: id, name, cat, kind, price, badge, desc, optional release, colours, sizes, soldOut |
CONTACT_TOPICS |
Contact form topics, who each one goes to and the reply time |
Add a release by putting a new object at the start of RELEASES:
{ id: 'harbour-ep', type: 'ep', title: 'Harbour', date: '2027-05-07', label: 'Lantern House Records', cat: 'LHR034',
art: { bg: '#c9cff7', a: '#ff4d94', b: '#15130f', ink: '#15130f', motif: 'moons' },
blurb: 'Four songs recorded on a ferry.', credits: 'Produced by Ailsa Braid.',
tracks: [{ title: 'Harbour', len: '3:40', early: true }, { title: 'Tide Tables', len: '4:05' }] },The first release in the list is the "new release" the home page counts down to. A release dated in the
future shows "Out <date>"; only tracks with early: true can be played before release day. Track ids are
generated as <release id>-<track number> (for example weather-for-two-2). The hero copy, the
announcement bar in SiteHeader.astro and the home page's album section are written for Weather for Two,
so update them when you change the new release.
Add a show to TOUR:
{ id: 'oslo', date: '2026-12-13', time: '20:00', city: 'Oslo', country: 'Norway', region: 'europe',
venue: 'Kulturhuset Sal 2', status: 'on-sale', support: 'Twin Ferries', tickets: 'https://tickets.example/oslo' },status is on-sale, low (shows "Last tickets"), sold-out (shows a waitlist link) or soon (shows
"On sale <onSale>" and a reminder link). Without tickets, ticketUrl() builds a placeholder address.
Shows dated before today move to "Past shows" automatically. The region counts on the tour page are filled
in by the script.
Demo dates: BAND.keepDemoDatesFresh moves the whole tour, and the new album (the release with
campaign: true), forward to the same weeks of the next year once every tour date has passed, by whole weeks so
weekdays never change (Friday 13 November 2026 becomes Friday 12 November 2027). The demo then has an album on the
way and upcoming shows again. Set it to false on your real site.
The dates written in the pages follow RELEASES and TOUR (initDemoText() in src/scripts/main.ts): the banner's
[data-release-note] ("Weather for Two is out 13 November", then "is out now"), [data-release-day] (the press kit
and the about timeline, with [data-release-year], and the home page title), [data-tour-years] ("2026–27", also in
the tour page title) and [data-tour-from] ("November 2026"). Once the new album is out, links with
data-release-cta (the "Pre-save" buttons, "Hear the first single", "Pre-order the LP") read their text from
RELEASE_CTA instead (listen, listen-album, listen-now, buy-lp), products with the "Pre-order" badge show
"Out now", and a show marked status: 'soon' goes on sale on its onSale date.
Add a product to PRODUCTS:
{ id: 'beanie', name: 'Static beanie', cat: 'apparel', kind: 'tee', price: 20,
desc: 'Ribbed knit with a woven label.', colours: [{ name: 'Pink', hex: '#ff4d94' }],
sizes: ['One size'] },kind picks the SVG mock-up (lp, cd, cassette, tee, hoodie, poster, tote or pins); lp and
cd use the cover of the release you set. Products with more than one colour get swatches; products with
sizes require a size before they can be added. Sizes listed in soldOut are shown crossed out and
disabled. Category counts on the merch page are filled in from the data.
TypeScript checks your edits: pnpm check reports a misspelt type, status, kind or motif.
Images and artwork#
- Album covers are drawn by
cover()insrc/scripts/main.tsfrom each release'sartobject:bg(paper colour),aandb(two inks),ink(title colour) andmotif(weather,glass,river,signal,moons,bus,sunorgrid). Any element withdata-cover="<release id>"gets the cover painted into it; adddata-cover-label=""when the cover is decorative. To use real artwork, put square images (at least 1200×1200) inpublic/img/and return an<img>fromcover()instead of the SVG. - Merch mock-ups are drawn by
productArt()for the selected colour, in a 4:5 frame. Replace them with 4:5 product photos the same way. - Band and member portraits are SVG files in
public/img/(band.svg1200×800,member-*.svg400×500), used inabout.astroandpress.astro. Swap them for photos with the same aspect ratios and keep thealttext accurate. - Social image:
public/img/og-image.svgis the source,og-image.png(1200×630) is what the layout references. Export a new PNG after editing the SVG. - Favicon:
public/img/favicon.svg.
Reference files in public/ through url() so they work under a base path:
<img src={url("/img/band.svg")} …>.
Navigation, header and footer#
The header, mobile menu and footer links are arrays in src/data/nav.ts (mainNav, mobileNav,
footerColumns), rendered by SiteHeader.astro and SiteFooter.astro. To add a page:
-
Create
src/pages/<name>.astroand wrap its content in the layout:--- import Layout from "../layouts/Layout.astro"; --- <Layout title="Videos — Marigold Static" description="…" current="videos" page="videos"> <section class="container-x py-16">…</section> </Layout> -
Add
{ href: "/videos", label: "Videos", key: "videos" }tomainNav. The link whosekeymatches the page'scurrentgetsaria-current="page"and the pink underline.
Write internal links with url() from src/lib/site.ts and without .html: url("/tour?region=europe"),
url("/#join").
Features#
The player#
Any button with data-track="<track id>" plays that track; data-play-release="<release id>" plays a
release from its first playable track (add data-play-track="<track id>" to start somewhere else). Pressing
a playing button again pauses it. Playing a track queues the rest of its release, so previous and next work.
The player bar (PlayerBar.astro, #player) slides up when something is loaded. It has previous,
play/pause, next, a seek slider, mute and close. Every track list shows the current track with an equaliser
and aria-current, and play buttons switch to pause. The player lives in the page, so playback stops when
you follow a link to another page.
Previews without audio files: each track's preview is a 30-second piece of music synthesised in the
browser by Synth (a pad, bass, arpeggio and drums in a key, tempo and chord progression picked from the
track id), encoded as a WAV file and played by a normal <audio> element. To play your real audio, put the
files in public/audio/ and give the track a src:
{ title: 'Glasshouse', len: '3:41', early: true, src: '/audio/glasshouse-preview.mp3' },If you deploy under a base path, include it in src (or build it with url() when you create the track).
Remove the "30-second preview" labels in trackRow() and the "Preview" chip in PlayerBar.astro if you play
full tracks. The player also sets Media Session metadata, so lock screens and media keys work.
Keyboard: all controls are buttons and the seek slider takes arrow keys. Space or K plays and pauses,
Shift+N and Shift+P skip tracks (ignored while typing or when a button or link has focus). A polite live
region (#player-status) announces the track that starts playing.
New release countdown and pre-save#
initRelease() reads the first release in RELEASES. Before its date the hero shows the countdown
(data-countdown), the pre-save button and "Play Glasshouse"; on release day everything marked
data-release-state="soon" hides and data-release-state="out" shows. The countdown's aria-label says
how many days are left. The pre-save button (data-presave) toggles and remembers its state in
localStorage (tempo-presave); point it at your pre-save service by replacing it with a link, or call that
service in its click handler.
Video poster#
The video section (data-video in index.astro) shows an SVG poster and loads nothing until someone
presses play. Put your embed address in data-video-embed and the button swaps the poster for an iframe:
<div data-video data-video-embed="https://www.youtube-nocookie.com/embed/VIDEO_ID" data-video-title="Glasshouse (official video)" …>With data-video-embed empty (the demo), pressing play plays the preview of the release named in
data-video-release, animates the poster and shows a note explaining the demo.
Discography#
The music page renders the release grid and detail from RELEASES in initMusic(). The filter buttons
(data-type-filter) update the grid, the live count and the ?type= parameter; any link with
data-open-release shows that release in the detail section, moves focus to its title and updates
?release=. "Copy link" copies a link straight to the release. Unknown ?type= or ?release= values fall
back to everything and the latest album.
Tour dates and calendar files#
The tour page renders upcoming shows from TOUR in initTour(). The region buttons (data-region) and
country select (#tour-country) filter the list and keep ?region= and ?country= in the address; an
empty state offers a reset. "Calendar" buttons (data-ics) download an .ics file built by icsFor() with
the doors time, venue, support act and ticket link; it works in Apple Calendar, Google Calendar and
Outlook. Waitlist and reminder links go to /tour?notify=<show id>#notify, which fills in the notify
form's town and country. The home page shows the next four shows with the same markup.
Merch and cart#
Product cards are rendered from PRODUCTS in initMerch(). Choosing a colour redraws the mock-up; "Add to
bag" checks that a size is chosen, adds the item (merging identical colour and size) and opens the cart
drawer. The drawer (CartDrawer.astro, #cart) is a modal dialog: focus moves into it and is kept there,
Esc or the backdrop closes it and focus returns to the button that opened it. It has quantity steppers
(1–10), remove buttons, the subtotal, a free-shipping meter based on BAND.freeShippingFrom and a shipping
line based on BAND.shipping.
Checkout is a demo step. "Checkout" shows an order summary; "Place demo order" empties the bag and says
clearly that nothing was charged. To sell for real, replace the data-place-order branch in initCart()
with a redirect to your store, for example a Shopify cart permalink built from cart:
if (target.closest('[data-place-order]')) {
window.location.href = 'https://your-shop.example/cart/' + cart.map((l) => l.id + ':' + l.qty).join(',');
}Press kit#
The bio tabs follow the ARIA tabs pattern (arrow keys, Home and End move between Short, Medium and Long).
"Copy this bio" copies the visible bio as plain paragraphs and the word count updates with the tab. The
downloads are generated in the browser: data-download="cover" saves the new album's cover as SVG,
logo saves #logo-art, bio saves all three bios as text, facts saves every [data-fact] row of the
fact sheet, and rider saves #rider-text. The band illustration is a plain download link to
public/img/band.svg. Contact cards have data-copy buttons that copy an email address.
Header, menu, reveals and back to top#
The header gets a border once you scroll. On screens under 1024 px the menu button opens a full-height menu
(aria-expanded, Esc closes it and returns focus). Elements with .reveal fade in as they scroll into view
(revealing one also reveals everything above it); with reduced motion they are visible straight away and
the ticker, equalisers and vinyl stop moving. The back-to-top button (src/scripts/back-to-top.ts) appears
after one screen, lifts itself above the player bar and toasts, and moves focus to the main content. To
drop it, delete the file, its import in Layout.astro and the .back-to-top styles in global.css.
Forms#
| Form | Page | Validates | Success state |
|---|---|---|---|
#join-form (Static Club) |
index.astro |
Region, email, consent checkbox | #join-success |
#notify-form (tour alerts) |
tour.astro |
Email, town or city, country, travel distance | #notify-success |
#contact-form |
contact.astro |
Topic, name, email, message (30+ characters) and the topic's own fields | #contact-success |
Every form with data-validate is checked in the browser on submit and as fields change; errors appear
under each field (linked with aria-describedby) and focus moves to the first problem. Field names in
error messages come from data-name; checkbox and radio messages from data-required-msg.
Contact routing: choosing a topic shows that topic's fields (data-topic-fields) and says who the
message goes to, from CONTACT_TOPICS. Booking is routed by the "Where is the show?" select: North America
goes to the US agent, everything else to the UK and Europe agent. Fields marked data-required are only
required while their topic is selected; dates marked data-future can't be in the past. ?topic= in the
address preselects a topic.
The forms don't send anything until you connect them. Submissions end in onValid() in
src/scripts/main.ts. To use Formspree, post the data there before showing the success box:
function onValid(form: HTMLFormElement): void {
fetch('https://formspree.io/f/your-id', { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } })
.then((r) => { if (!r.ok) throw new Error(); /* then show the success box as before */ })
.catch(() => { toast('Sorry, that didn’t send. Please email us instead.'); });
}For the fan-club and tour-alert forms you can instead post to your mailing list provider's form endpoint. The site is static, so there's no server code to write; if you add an Astro adapter later you can point the forms at an Astro endpoint instead. Update the success copy, which currently says the demo sends nothing.
Saved data (localStorage)#
| Key | Shape | Notes |
|---|---|---|
tempo-cart |
[{ "id": "glasshouse-tee", "colour": "Pink", "size": "L", "qty": 2 }] |
Read by readCart(), which drops unknown products, colours or sizes, sold-out sizes and bad quantities, and caps quantities at 10 |
tempo-presave |
true or false |
The pre-save button's state |
To reset, run localStorage.removeItem('tempo-cart') (or tempo-presave) in the browser console, or clear
the site's data. "Place demo order" also empties the cart.
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://marigoldstatic.com pnpm buildog:image is public/img/og-image.png resolved against that URL. Consider adding @astrojs/sitemap and a
public/robots.txt. For richer search results you can add MusicGroup and MusicEvent JSON-LD to the
layout, built from BAND and TOUR at build time.
Accessibility#
- Skip link, landmarks (
header,nav,main,footer), oneh1per page and a logical heading order. - Visible focus rings (cobalt on paper, yellow on ink), AA contrast for text in both the paper and ink
sections,
aria-current="page"on the current menu item. - Filters are toggle buttons with
aria-pressedand live result counts; the player announces the new track; track buttons say "Play" or "Pause" with the track name; unreleased tracks are disabled buttons described by their release date. - The cart is a modal dialog with focus trapping and Esc; the press bios use the ARIA tabs pattern; the menu
uses
aria-expandedand Esc. - Forms have real labels, inline errors linked with
aria-describedbyand success boxes that receive focus. prefers-reduced-motionturns off reveals, the ticker, equalisers, the spinning vinyl and smooth scrolling.
When editing, keep one h1 per page, give new inputs a <label>, keep alt text on portraits, and check
the contrast of any new colour pairs.
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 tour.html and linked as /tour; 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=/band SITE_URL=https://example.com pnpm buildTailwind scans src/ (including the class names in src/scripts/main.ts) for classes. Classes built up from
strings must appear in full somewhere in a scanned file.
Troubleshooting#
- No sound when I press play. Check the tab isn't muted and the player's mute button isn't on. If you
added a
src, check the file is inpublic/audio/, the path starts with/(plus your base path) and the format plays in browsers (MP3, AAC or Ogg). - The tour list is empty. Every date is in the past. Add new shows, or leave
BAND.keepDemoDatesFreshon while you build the site. - Copy buttons say "Select the text and copy it". The clipboard API needs
https://orlocalhost;pnpm devandpnpm previewrun on localhost. - Links 404 after deploying to a sub-folder. Build with
DEMO_BASE_PATHset to the folder, and write new links withurl()rather than"/…". /tourdownloads a file or shows a directory listing. Your host doesn't map extensionless URLs to.html; turn on clean URLs (VercelcleanUrls) or switchbuild.formatto"directory"inastro.config.mjs.- The cart shows an item I deleted from
PRODUCTS. It doesn't:readCart()drops it. If the count looks wrong, cleartempo-cartin localStorage. - The social preview shows no image. Set
SITE_URLto your real domain soog:imageis absolute. - Fonts look different in the downloaded cover SVG. SVG files use the fonts installed on the viewer's computer; convert the text to outlines in a design tool if you need exact type.
License#
MIT, free for personal and commercial use. The full text is in LICENSE.txt in the download (the
repository's license). All artwork, including the SVG covers, portraits, merch mock-ups and social image, is
included under the same license.