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

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

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

Customising#

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:

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

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:

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

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 crossed out and disabled.

Images and artwork#

  • Album covers are drawn by <Cover> in src/components/Art.tsx from each release's art: bg (paper colour), a and b (two inks), ink (title colour) and motif (weather, glass, river, signal, moons, bus, sun or grid). Pass label="" 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.svg 1200×800, member-*.svg 400×500), shown with asset() from src/lib/asset.ts so they work under a base path. Swap them for photos with the same aspect ratios and keep the alt text accurate.
  • Social image: public/og-image.svg is 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.

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 gets aria-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() in player.ts is 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 /):

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

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

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

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

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:

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

Consider 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), 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, and the player's buttons list their shortcuts in aria-keyshortcuts.
  • 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> 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):

sh
DEMO_BASE_PATH=/band pnpm build

Pages 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 in public/ 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.keepDemoDatesFresh on while you build the site.
  • Copy buttons say "Select the text and copy it". The clipboard API needs https:// or localhost.
  • useSearchParams() should be wrapped in a suspense boundary when 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 serve music.html for /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, clear tempo-cart in 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).

Search documentation

Search the guides and every template's documentation