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 Next.js version: Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site. 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. The player lives in the root layout, so whatever is playing keeps playing while you move between pages. 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:3000pnpm build exports the static site to out/, and pnpm start serves that folder so you can check the
build. pnpm lint and pnpm typecheck run ESLint and TypeScript.
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
src/app/page.tsx |
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/app/music/page.tsx |
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/app/tour/page.tsx |
Region filter and country select, show list with status chips, calendar downloads, past shows, "notify me" form (#notify), FAQ. /tour?region=europe&country=Germany, /tour?notify=paris#notify |
/merch |
src/app/merch/page.tsx |
Category filter (/merch?cat=apparel), product cards with colour and size options (#p-<product id>), shipping and sizing |
/about |
src/app/about/page.tsx |
Group illustration, story and stats, band members, timeline, press quotes |
/press |
src/app/press/page.tsx |
Fact sheet, short/medium/long bio tabs with copy, downloads (#downloads), tech rider, press quotes, contacts (#contacts) |
/contact |
src/app/contact/page.tsx |
Topic-routed form (/contact?topic=booking), direct contacts, postal address |
| 404 | src/app/not-found.tsx |
"Not on the record" page with links back into the site |
The announcement bar, header, footer, player bar, cart drawer, toast, scroll reveals and back-to-top button
are in the root layout (src/app/layout.tsx), so they are on every page.
Project structure#
tempo/
src/app/layout.tsx root layout: fonts, metadata, header, footer, player bar, cart drawer, toast
src/app/page.tsx home page (one folder per route: music/, tour/, merch/, about/, press/, contact/)
src/app/not-found.tsx 404 page
src/app/globals.css Tailwind CSS: @theme design tokens and component classes
src/app/fonts.ts the three fonts, self-hosted with next/font/local
src/app/icon.svg favicon
src/app/opengraph-image.png social image (1200×630)
src/components/ header, footer, art, player, track lists, discography, tour, merch, cart, forms, press kit
src/components/player/ PlayerBar.tsx (bar and shortcuts) and Tracks.tsx (track lists, play buttons)
src/data/band.ts BAND settings, NAV, PLATFORMS and CONTACT_TOPICS
src/data/releases.ts RELEASES (with their tracks) and TYPES
src/data/tour.ts REGIONS, TOUR, PAST_SHOWS and ticketUrl()
src/data/products.ts PRODUCTS and MERCH_CATS
src/data/press.ts FACTS, BIOS, RIDER and CONTACTS for the press kit
src/lib/player.ts the player engine (one shared <audio> element)
src/lib/synth.ts track previews synthesised in the browser
src/lib/cart.ts the bag (localStorage) and the drawer state
src/lib/dates.ts today, the countdown clock, upcoming and past shows
src/lib/forms.ts form validation and success states
src/lib/download.ts file downloads, clipboard and .ics calendar files
src/fonts/ woff2 font files and their license (OFL.txt)
public/ band and member illustrations, og-image.svg
next.config.ts static export, base path, build day
README.md, DOCS.md, CHANGELOG.mdCustomising#
Brand and colours#
All design tokens live in the @theme block at the top of src/app/globals.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:
@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/components/Icons.tsx, the hero vinyl in src/app/page.tsx, the video poster, the 404 record and the
artwork in src/components/Art.tsx) use hex values directly; search for #ff4d94 to update them too.
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 self-hosted with next/font/local in src/app/fonts.ts, from the same woff2 files the HTML
version loads from Google Fonts (latin and latin-ext subsets, in src/fonts/):
- Big Shoulders Display (700–900) for the condensed poster headlines (
--font-display, theposterutility,.h-hero,.h-page,.h-section). - Familjen Grotesk (400–700, upright and italic) for body text (
--font-sans). - Space Mono (400 and 700) for labels, dates and prices (
--font-mono,.meta,.eyebrow,.chip).
Each face is registered under its plain family name, so the --font-* tokens in globals.css and the SVG
artwork refer to them by name. To swap a font, put its woff2 files in src/fonts/, change the src,
weight and font-family declaration in fonts.ts, and update the matching --font-* token (and any
fontFamily in the SVG components).
Content#
Page copy is written in each route's page.tsx. Everything that is listed, filtered or played lives in
typed data files in src/data/:
| Constant | File | What it holds |
|---|---|---|
BAND |
band.ts |
Name, site URL, email, currency and locale for prices and dates, freeShippingFrom, shipping, keepDemoDatesFresh |
NAV, PLATFORMS, CONTACT_TOPICS |
band.ts |
Menu items, streaming links, contact form topics with who each one goes to and the reply time |
RELEASES, TYPES |
releases.ts |
Every release, newest first: id, type (album, ep, single), title, date, label, cat, art, merch, blurb, credits, tracks |
REGIONS, TOUR, PAST_SHOWS |
tour.ts |
Tour regions and shows: id, date, time (doors), city, country, region, venue, status, onSale, note, support, optional tickets |
PRODUCTS, MERCH_CATS |
products.ts |
Merch: id, name, cat, kind, price, badge, desc, optional release, colours, sizes, soldOut |
FACTS, BIOS, RIDER, CONTACTS |
press.ts |
The press kit's fact sheet, bios (*text* is set in italics), tech rider lines and contact cards |
Add a release by putting a new entry at the start of the DATA list in src/data/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 is the "new release" the home page counts down to (the hero copy in src/app/page.tsx
names Weather for Two; update it with the release). 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).
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, above the older PAST_SHOWS. Region and
country counts are worked out from the data.
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. demoShift(), releaseDate(), tourOn() and
tourYears() in src/lib/dates.ts work it out for a day; a show marked status: "soon" goes on sale on its
onSale date. The text written in the pages follows the same dates through src/components/DemoText.tsx: the
banner (<ReleaseNote>: "Weather for Two is out 13 November", then "is out now"), <ReleaseCta> (the "Pre-save"
buttons, "Hear the first single" and "Pre-order the LP", which read "Listen", "Listen to the album", "Listen now"
and "Buy the LP" once the album is out), <TourYears>, <ReleaseYear>, <DemoText> (the press kit and the about
timeline) and <DemoTitle> (the page titles). Like everything that depends on today, they render for BUILD_DAY
in the static HTML and switch in the browser. Products with the "Pre-order" badge show "Out now" once the album is
out.
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 crossed out and disabled.
Images and artwork#
- Album covers are drawn by
<Cover>insrc/components/Art.tsxfrom each release'sart:bg(paper colour),aandb(two inks),ink(title colour) andmotif(weather,glass,river,signal,moons,bus,sunorgrid). Passlabel=""when the cover is decorative. To use real artwork, return an<img>of your square artwork (at least 1200×1200) from<Cover>. - 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/(band.svg1200×800,member-*.svg400×500), shown withasset()fromsrc/lib/asset.tsso they work under a base path. Swap them for photos with the same aspect ratios and keep thealttext accurate. - Social image:
public/og-image.svgis the source;src/app/opengraph-image.png(1200×630) is what every page uses. Export a new PNG after editing the SVG. - Favicon:
src/app/icon.svg.
Navigation, header and footer#
The header and mobile menu are src/components/SiteHeader.tsx (menu items from NAV in
src/data/band.ts; the mobile menu adds Home), the footer is src/components/SiteFooter.tsx, and the
"New album" bar above the header is in src/app/layout.tsx. To add a page, create
src/app/<route>/page.tsx with its own metadata (see SEO and sharing), then add it to
NAV and the footer. The current page gets aria-current="page" automatically.
Features#
The player#
src/lib/player.ts holds one shared <audio> element outside the React tree, so playback carries on while
Next.js swaps pages. A queue is the playable tracks of one release. Components read the state with
usePlayer() (current track, playing, muted) and usePlayerTime() (position and length), and call
Player.toggle(), next(), prev(), seek(), toggleMute() and close().
<TrackList releaseId>(src/components/player/Tracks.tsx) draws a release's track list. Each number turns into play/pause; the current row getsaria-current="true", a "Now playing" label with an equaliser, and unreleased tracks are disabled with "Out <date>".<PlayReleaseButton releaseId trackId? label>plays a release from its first playable track (or the given track) and becomes "Pause" while it plays.playFrom()inplayer.tsis what both call.<PlayerBar>(src/components/player/PlayerBar.tsx) slides up once something is loaded, with previous, play/pause, next, a seek slider, mute and close, and announces the new track in a polite live region.
Previews without audio files: src/lib/synth.ts synthesises a 30-second piece for each track (a pad,
bass, arpeggio and drums in a key, tempo and chord progression picked from the track id), encodes it as a
WAV file and plays it with the <audio> element. To play your real audio, give the track a src (files in
public/ are served from /):
{ title: "Glasshouse", len: "3:41", early: true, src: "/audio/glasshouse-preview.mp3" },Remove the "30-second preview" labels in Tracks.tsx and the "Preview" chip in PlayerBar.tsx 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); the handler is in
PlayerBar.tsx.
New release countdown and pre-save#
src/components/Release.tsx reads the first release in RELEASES. Before its date <ReleaseState> shows
the "soon" content (the <Countdown>, <PresaveButton> and "Play Glasshouse"); from release day it shows
the "out" content instead. The switch happens by itself when the countdown reaches zero. The pre-save
button 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 onClick.
Everything that depends on today is worked out by useToday() and useNowSeconds() in
src/lib/dates.ts. The static HTML is rendered for the day the site was built (NEXT_PUBLIC_BUILD_DAY,
set in next.config.ts) and the browser then switches to its own date, so the page hydrates cleanly and is
still right on any later day.
Video poster#
<VideoPoster> (src/components/VideoPoster.tsx) shows an SVG poster and loads nothing until someone
presses play. Pass your embed address and the button swaps the poster for an iframe:
<VideoPoster embed="https://www.youtube-nocookie.com/embed/VIDEO_ID" title="Glasshouse (official video)" releaseId="glasshouse" />With embed empty (the demo, in src/app/page.tsx), pressing play plays the preview of releaseId and
animates the poster's bars.
Discography#
<Discography> (src/components/Discography.tsx) renders the release grid and detail from RELEASES. The
filter buttons update the grid, the live count and ?type=; any release link shows that release in the
detail section, moves focus to its title and updates ?release=. Without ?release= the newest album that
is out is shown. "Copy link" copies a link straight to the release (#release).
Tour dates and calendar files#
<TourDates> (src/components/TourDates.tsx) lists upcoming shows. The region buttons and country select
filter the list and keep ?region= and ?country= in the address; an empty state offers a reset. Each row
is a <ShowRow> (src/components/Shows.tsx); its "Calendar" button downloads an .ics file built by
icsFor() in src/lib/download.ts with the doors time, venue, support act and ticket link. The home page's
<NextShows> shows the next four with the same row. /tour?notify=<show id> fills in the tour-alert form's
city and country.
Merch and cart#
<MerchGrid> (src/components/MerchGrid.tsx) renders a card per product. 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 bag lives in src/lib/cart.ts (cart.add(), step(), remove(), clear(),
read with useCart()), and the header shows its count.
<CartDrawer> (src/components/CartDrawer.tsx) 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 placeOrder() in CartDrawer.tsx with a
redirect to your store, for example a Shopify cart permalink or a Stripe Payment Link:
function placeOrder() {
window.location.href = "https://your-shop.example/cart/" + lines.map((l) => `${l.id}:${l.qty}`).join(",");
}Press kit#
<BioTabs> (src/components/PressKit.tsx) follows 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 follows the tab. <DownloadButton kind> generates files in the browser: cover saves the new
album's cover as a 2000×2000 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. <CopyButton> copies 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, opening a page closes it). Elements with .reveal fade
in as they scroll into view (src/components/RevealObserver.tsx; revealing one also reveals everything
above it); with reduced motion they are visible straight away and the ticker, equalisers and vinyl stop
moving. <BackToTop> appears after one screen, lifts itself above the player bar and toasts
(data-back-to-top-avoid), and moves focus to the main content; delete it from layout.tsx and the
.back-to-top styles to drop it.
Forms#
| Form | Component | Page | Validates |
|---|---|---|---|
#join-form (Static Club) |
<JoinForm> |
Home | Region, email, consent checkbox |
#notify-form (tour alerts) |
<NotifyForm> |
Tour | Email, town or city, country, travel distance |
#contact-form |
<ContactForm> |
Contact | Topic, name, email, message (30+ characters) and the topic's own fields |
The components are in src/components/Forms.tsx and use useValidatedForm() from src/lib/forms.ts.
Fields stay uncontrolled and are checked with the browser's own constraint validation (required,
type="email", minLength, min, pattern) on submit and as they change; errors appear under each field
(linked with aria-describedby) and focus moves to the first problem. Field names in messages come from
data-name; checkbox and radio messages from data-required-msg, date and pattern messages from
data-min-msg and data-pattern-msg. On success the form is swapped for its success box, which takes
focus.
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. Topic fields are only required while their
topic is selected; dates marked data-future can't be in the past.
The forms don't send anything until you connect them. Submissions end in onValid() in
src/lib/forms.ts. To use Formspree, post the form there before showing the
success box:
async function onValid(el: HTMLFormElement) {
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(el),
headers: { Accept: "application/json" },
});
if (!res.ok) return toast("Sorry, that didn’t send. Please email us instead.");
// ...then the existing lines: focusAfter.current = "success"; setSent(...)
}(toast is in src/lib/toast.ts.) Use a different endpoint per form by checking el.id. Every field has a
name, so FormData carries all values. For the fan-club and tour-alert forms you can post to your mailing
list provider's form endpoint instead. Then reword the success copy, which says the demo sends nothing.
If you drop the static export (see Building and deploying) you can post to your
own Route Handler, for example src/app/api/contact/route.ts:
export async function POST(req: Request) {
const data = await req.formData();
// send an email, save to a database...
return Response.json({ ok: true, topic: data.get("topic") });
}Saved data (localStorage)#
| Key | Shape | Notes |
|---|---|---|
tempo-cart |
[{ "id": "glasshouse-tee", "colour": "Pink", "size": "L", "qty": 2 }] |
Read by readCart() in src/lib/cart.ts, 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 (src/components/Release.tsx) |
Values are read with readJSON() from src/lib/storage.ts, which falls back to defaults on missing or
broken data, and only in the browser after the page has loaded, so the static HTML never depends on them.
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 bag.
SEO and sharing#
Each page exports its own metadata (title, description, Open Graph) with pageMeta() from
src/lib/metadata.ts; the layout adds the site name, Twitter card, theme colour and metadataBase, and
src/app/opengraph-image.png is the social image. Set your domain with SITE_URL when building so the image
URL is absolute:
SITE_URL=https://www.marigoldstatic.com pnpm buildConsider adding src/app/sitemap.ts and src/app/robots.ts, and MusicGroup and MusicEvent JSON-LD
built from BAND and TOUR for richer search results. The 404 page is noindex.
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, and the player's buttons list their shortcuts inaria-keyshortcuts. - 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> and an error element in their
[data-field] box, keep alt text on portraits, and check the contrast of any new colour pairs.
Building and deploying#
pnpm build writes a static site to out/ (output: "export" in next.config.ts). Upload out/ to any
static host: Vercel, Netlify, Cloudflare Pages, GitHub Pages or your own server. On Vercel you can also
import the project directly; it detects Next.js. Audio files can live in public/audio/.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building (the live demo uses
/demos/nextjs/tempo):
DEMO_BASE_PATH=/band pnpm buildPages are exported as music.html, tour.html and so on; most hosts serve them at /music. Rebuild when
your release date passes so the static HTML shows the "out now" state before JavaScript runs (the browser
switches on its own either way). To use server features (Route Handlers, Server Actions, per-request
rendering), remove output: "export" and images.unoptimized from next.config.ts and deploy to a host
that runs Next.js.
Troubleshooting#
- 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 path (files inpublic/are served from/, plus the base path if you set one) and that the file is a format the browser plays (MP3, AAC or Ogg). Errors show a toast. - 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. useSearchParams() should be wrapped in a suspense boundarywhen building. The components that read the URL (<DiscographyFromUrl>,<TourDatesFromUrl>,<NotifyFormFromUrl>,<MerchGridFromUrl>,<ContactFormFromUrl>) must stay inside<Suspense>in their page./music?release=...works locally but 404s on my host. The host must servemusic.htmlfor/music. Most do; on others link to/music.html?release=...or add a rewrite.- The bag shows an item I deleted from
PRODUCTS. It doesn't:readCart()drops it. If the count looks wrong, cleartempo-cartin localStorage. - 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, including client work. The full text is in LICENSE.txt in the
download. All artwork (the SVG covers, merch mock-ups, portraits, icons and social image) is part of the
template and covered by the same license; the fonts are under the SIL Open Font License
(src/fonts/OFL.txt).