Freeframe

Documentation

Tempo

v1.0.3Updated Oct 17, 2026A bold, poster-printed website for a band or musician, with a real track player, tour dates, a merch store and a press kit.

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

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

Edit 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.md

src/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:

css
@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, the poster utility, .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:

ts
{ 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:

ts
{ 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:

ts
{ 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() in src/scripts/main.ts from each release's art object: bg (paper colour), a and b (two inks), ink (title colour) and motif (weather, glass, river, signal, moons, bus, sun or grid). Any element with data-cover="<release id>" gets the cover painted into it; add data-cover-label="" when the cover is decorative. To use real artwork, put square images (at least 1200×1200) in public/img/ and return an <img> from cover() 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.svg 1200×800, member-*.svg 400×500), used in about.astro and press.astro. Swap them for photos with the same aspect ratios and keep the alt text accurate.
  • Social image: public/img/og-image.svg is 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")} …>.

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:

  1. Create src/pages/<name>.astro and 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>
  2. Add { href: "/videos", label: "Videos", key: "videos" } to mainNav. The link whose key matches the page's current gets aria-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:

ts
{ 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:

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

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

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

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

og: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), one h1 per 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-pressed and 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-expanded and Esc.
  • Forms have real labels, inline errors linked with aria-describedby and success boxes that receive focus.
  • prefers-reduced-motion turns 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:

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

Tailwind 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 in public/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.keepDemoDatesFresh on while you build the site.
  • Copy buttons say "Select the text and copy it". The clipboard API needs https:// or localhost; pnpm dev and pnpm preview run on localhost.
  • Links 404 after deploying to a sub-folder. Build with DEMO_BASE_PATH set to the folder, and write new links with url() rather than "/…".
  • /tour downloads a file or shows a directory listing. Your host doesn't map extensionless URLs to .html; turn on clean URLs (Vercel cleanUrls) or switch build.format to "directory" in astro.config.mjs.
  • The cart shows an item I deleted from PRODUCTS. It doesn't: readCart() drops it. If the count looks wrong, clear tempo-cart in localStorage.
  • The social preview shows no image. Set SITE_URL to your real domain so og:image is 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.

Search documentation

Search the guides and every template's documentation