Overview#
Solstice is a sunny, poster-style website for an outdoor food and music festival: a live countdown, a filterable line-up with artist pop-ups, a multi-stage timetable where visitors build their own plan (with clash warnings and a calendar export), a ticket shop with early-bird prices and a group discount, a food village with dietary filters and a map, and a visitor guide with an interactive site map and a searchable FAQ. The demo event is Solstice 2027, a fictional three-day festival at Ashton Court Estate, Bristol (Friday 25 – Sunday 27 June 2027) with 40 acts on four stages and 16 food and drink traders. This is the Next.js version (1.0.0): Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site. An HTML + Tailwind version of the template is also available.
Live demo: freeframe.dev/templates/solstice
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm) or npm.
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm build |
Static site in out/ |
pnpm start |
Serve out/ locally (npx serve out) |
pnpm lint |
ESLint (Next.js core-web-vitals and TypeScript rules) |
pnpm typecheck |
Generate route types (next typegen), then type-check with tsc (TypeScript 7) |
pnpm build:demo |
Build for /demos/nextjs/solstice into demo/ (the freeframe.dev demo; you can delete it) |
Add ?now=2027-06-25T21:30 to any URL to preview the site at a given festival time (see
Previewing a date).
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
src/app/page.tsx |
Poster hero with countdown and early-bird sticker, scrolling tape, poster-style line-up by day, "What's on" tiles, ticket preview, numbers, FAQ teaser, visitor guide card, line-up alerts sign-up |
/lineup |
src/app/lineup/page.tsx |
Day, stage and genre filters (kept in the URL), "My plan only", artist cards with generated artwork, artist pop-up |
/schedule |
src/app/schedule/page.tsx |
Day tabs, grid and list views, "My plan only", My plan panel with clashes, .ics export and clear, opening times |
/tickets |
src/app/tickets/page.tsx |
Day, weekend and add-on rows with steppers, order summary with fees and group discount, three-step demo checkout, good-to-know cards |
/food |
src/app/food/page.tsx |
Food/sweet/drink and dietary filters, search, trader cards, food village map with numbered pins |
/info |
src/app/info/page.tsx |
Site map with layer toggles and a text version, getting there, accessibility, families, what to bring, searchable FAQ |
/contact |
src/app/contact/page.tsx |
Contact form that routes volunteer, trader, press and visitor enquiries, direct contacts |
| 404 | src/app/not-found.tsx |
Error page with links back |
Project structure#
solstice/
├── src/
│ ├── app/
│ │ ├── layout.tsx fonts, default metadata, header, footer, toast, reveals, back to top
│ │ ├── globals.css Tailwind source: design tokens (@theme) and component classes
│ │ ├── icon.svg favicon (the sun logo mark)
│ │ ├── page.tsx home page
│ │ ├── not-found.tsx 404 page
│ │ └── lineup/, schedule/, tickets/, food/, info/, contact/ one page.tsx per route
│ ├── components/
│ │ ├── SiteHeader.tsx header, "My plan" pill and mobile menu
│ │ ├── SiteFooter.tsx footer
│ │ ├── LineUp.tsx line-up filters, artist cards and the artist pop-up
│ │ ├── Timetable.tsx timetable tabs, grid and list views, My plan panel and .ics export
│ │ ├── Tickets.tsx ticket rows, order summary and the demo checkout
│ │ ├── FoodVillage.tsx food filters, trader cards and the village map
│ │ ├── MapLayers.tsx site map layer toggles (/info)
│ │ ├── FaqSearch.tsx searchable FAQ (/info)
│ │ ├── ContactForm.tsx contact form with topic routing
│ │ ├── AlertsForm.tsx line-up alerts sign-up (home)
│ │ ├── Countdown.tsx, CountUp.tsx, EarlyBird.tsx countdown, count-up numbers, early-bird prices
│ │ ├── ArtistArt.tsx generated poster artwork for each artist
│ │ ├── PlanButton.tsx, PlanCount.tsx "add to my plan" buttons and the plan count
│ │ └── Icon.tsx, Logo.tsx, FieldError.tsx, Toast.tsx, RevealObserver.tsx, BackToTop.tsx
│ ├── data/
│ │ ├── festival.ts days, stages, genres, artists, diets, traders, tickets, add-ons and ticket rules
│ │ ├── faq.ts the FAQ on /info
│ │ └── nav.ts menu items
│ ├── lib/
│ │ ├── festival.ts festival time, prices, set times and clash detection
│ │ ├── plan.ts "My plan" in localStorage
│ │ ├── cart.ts the ticket basket in localStorage
│ │ ├── validation.ts inline form validation
│ │ ├── client.ts useFestivalNow(), useEarlyBird(), toast()
│ │ └── asset.ts, metadata.ts
│ └── fonts/ self-hosted fonts and their licences
├── public/img/ hero-sun.svg, hero-hills.svg, bunting.svg, og-image.svg, og-image.png
├── next.config.ts static export and basePath
├── package.json, pnpm-workspace.yaml, tsconfig.json, eslint.config.mjs, postcss.config.mjs
├── template.json template metadata used by freeframe.dev
├── README.md, CHANGELOG.md
└── DOCS.md this guideCustomising#
Brand and colours#
All colours are tokens in the @theme block of src/app/globals.css. Tailwind turns each one into utilities
(bg-sun, text-tomato, border-ink...), and the component classes use them through CSS variables.
| Token | Default | Used for |
|---|---|---|
--color-cream |
#fff4de |
Page background |
--color-paper |
#fffbf2 |
Cards, fields, buttons |
--color-ink |
#24122e |
Text, outlines, hard shadows, footer |
--color-muted |
#654757 |
Secondary text |
--color-sun / --color-sun-soft |
#ffc53d / #ffe08a |
Main accent, planned sets, hover states |
--color-tangerine |
#ff8a2b |
Orchard stage, accents |
--color-tomato |
#c73a1e |
Primary buttons, clash warnings, eyebrows |
--color-sea, --color-sky, --color-leaf, --color-peach |
Section colours, stages, dietary tags |
To change the main accent, edit the token:
@theme {
--color-tomato: #b4235a; /* raspberry buttons and warnings instead of tomato red */
}Keep white text on --color-tomato at 4.5:1 contrast or more. The SVG files in public/img/, the inline maps
and the generated artwork (src/components/ArtistArt.tsx) use hex colours directly; search for the old hex
value to change them too.
The "poster" look comes from a few component classes in globals.css: .poster (thick ink outline and a hard
offset shadow), .poster-sm, .poster-hover, .btn with .btn-primary, .btn-sun, .btn-ink and
.btn-ghost, .chip, .tag, .display (condensed uppercase headings) and .wordmark.
Typography#
Three fonts are self-hosted with next/font/local in src/app/layout.tsx (the same Latin files Google Fonts
serves, SIL Open Font License in src/fonts/*-OFL.txt):
| Font | Variable → token | Used for |
|---|---|---|
| Bricolage Grotesque (wdth 75–100, wght 400–800) | --font-bricolage → --font-display |
Headings via .display and .h-section, numbers |
| DM Sans (wght 400–800) | --font-dm-sans → --font-sans |
Body text |
| Shrikhand | --font-shrikhand → --font-script |
The "Solstice" wordmark (.wordmark) |
To swap a font, put the .woff2 file in src/fonts/, point the matching localFont() call at it and keep
the variable name (or update the token in @theme). .display uses font-stretch: 75%; if your new display
font has no width axis, remove that line and the declarations option.
Content#
Festival content lives in src/data/festival.ts:
| Export | What it holds |
|---|---|
KEEP_DEMO_CURRENT |
true in the demo: once the festival is over, every date moves on a year (see Demo dates). Set it to false when you enter your real dates |
GATES_OPEN, UTC_OFFSET |
Opening time (the home countdown) and the festival's UTC offset (+01:00 for BST) |
DAYS |
id, label, short, date, gates, curfew |
STAGES |
id, name, blurb (order = timetable column order) |
GENRES |
Genre id → label |
ARTISTS |
One entry per act and set (see below) |
DIETS, VENDORS |
Dietary labels and the food village traders |
TICKETS, ADDONS |
Ticket types and extras with prices |
EARLY_BIRD_ENDS, GROUP_SIZE, GROUP_DISCOUNT, FEE, MAX_PER_TYPE, CURRENCY |
Ticket rules |
Add an artist by adding an object to ARTISTS. Times are local festival time, 24-hour:
{ id: "paper-moons", name: "Paper Moons", day: "sat", stage: "orchard", start: "19:15", end: "20:00",
genre: "folk", tier: 3, from: "Bath",
bio: "Three-part harmonies and a borrowed double bass.", art: ["waves", "#8fd0ee", "#24122e"] },idmust be unique; it is used in URLs (/lineup?artist=paper-moons), the plan and the calendar.tieris 1 for headliners (red "Headliner" badge, listed first), 2 for special guests, 3 for the rest.artis[motif, background, foreground]. Motifs:sun,waves,rings,stripes,burst,hills.- Sets should finish by midnight; the timetable grid spans from the earliest start to the latest end of each day.
The line-up and timetable are drawn from this data automatically. The poster-style line-up on the home page
(src/app/page.tsx, "Three days. Forty acts.") is written out by hand so you can set the billing exactly:
update it when you change headliners.
Add a trader to VENDORS:
{ id: "pie-minister", name: "Pie Minister", kind: "food", cuisine: "British pies",
dish: "Steak and ale or mushroom pies with mash", price: "£9–£12",
diets: ["vegetarian"], pin: [62, 44] },kind is food, sweet or drink (pin colour and the "Show" filter). diets uses the keys of DIETS.
pin is the position on the food village map in percent (x, y). Trader numbers follow the array order.
Ticket prices are in TICKETS (price and early, in pounds) and ADDONS (price). Every price on the site
comes from there. The rows on /tickets are listed in DAY_ROWS, WEEKEND_ROWS and ADDON_ROWS at the top of
src/components/Tickets.tsx (with the colour and icon of each row's badge): add a row there when you add a
ticket type.
The FAQ on /info is in src/data/faq.ts: groups with questions (q) and answers (a, a list of text and
{ href, text } links). Other copy is in the page files.
Images and artwork#
There are no photos. All artwork is SVG:
public/img/hero-sun.svg(600×600) andhero-hills.svg(1440×300) are reused on the home page, inner page headers and the 404 page. The sun's rays turn slowly; the animation is inside the SVG and stops for visitors who prefer reduced motion.bunting.svgruns along the top of the home and visitor pages.- Artist artwork is generated by
ArtistArt(src/components/ArtistArt.tsx) from each artist'sartsetting. To use photos instead, render an<img>in its place inLineUp.tsx; keep a square (1:1) image for cards. - The site map (
src/app/info/page.tsx) and the food village map (MapArtinFoodVillage.tsx) are inline SVG. Food pins are drawn fromVENDORS[].pin. public/img/og-image.svgis the editable source of the social image; export it toog-image.pngat 1200×630 after editing (most social sites don't display SVG).src/app/icon.svgis the favicon; the same mark isLogoMarkinsrc/components/Logo.tsx.
Navigation, header and footer#
The header is src/components/SiteHeader.tsx and reads its menu from src/data/nav.ts: NAV is the desktop
menu and MOBILE_NAV adds Home and Tickets for the mobile menu. To add a page, create
src/app/<route>/page.tsx, add it to NAV and link it from src/components/SiteFooter.tsx. The current page
is marked with aria-current="page" automatically. The "My plan" pill and every PlanCount show how many sets
are saved.
Features#
Countdown#
<Countdown target={GATES_OPEN} /> (src/components/Countdown.tsx) on the home page shows days, hours,
minutes and seconds until GATES_OPEN. When it reaches zero, the cells are hidden and "The gates are open"
message appears. It has role="timer". The static HTML shows 000 00 00 00; the browser fills in the real
values, so the server and browser render never disagree.
Early-bird prices#
EARLY_BIRD_ENDS decides which price is used everywhere. src/components/EarlyBird.tsx has Price (the
current price of a ticket), FullPrice (the full price, struck through while early-bird prices apply),
EarlyOnly (shown only before the end, such as the hero sticker) and EarlyHide (shown only after). The static
HTML shows early-bird prices; useEarlyBird() in src/lib/client.ts switches them in the browser.
Line-up filters and artist pop-up#
src/components/LineUp.tsx. Day buttons ([data-day-filter]), the stage and genre selects (#filter-stage,
#filter-genre) and "My plan only" filter the cards; the result count is announced through an aria-live
region. Filters are written back to the URL with history.replaceState, so
/lineup?day=sat&stage=meadow&genre=indie can be shared or bookmarked. /lineup?artist=<id> opens that
artist's pop-up on load.
Clicking an artist name opens a native <dialog> with the artwork, stage, set time, bio, a clash warning if
relevant, "Add to my plan" and "See in timetable". Esc, the close button or a click on the backdrop closes it,
and focus returns to the card.
My plan and clash detection#
Every sun button (PlanButton, [data-plan-toggle="<artist id>"]) adds or removes a set from the visitor's
plan, uses aria-pressed and shows a toast. The plan lives in src/lib/plan.ts (usePlan(),
togglePlan(), savePlan()), is saved in localStorage (see Saved data), shared by
all pages and synced between open tabs. clashes() in src/lib/festival.ts marks two planned sets as clashing
when they are on the same day and their times overlap.
Timetable#
src/components/Timetable.tsx shows one day at a time with keyboard-friendly tabs (Left/Right, Home/End). The
day is kept in the URL (/schedule?day=sun); without it, the page opens on the current festival day or Friday.
"Grid" draws the four stages as columns on an hour scale (scrolls sideways on small screens); "List" shows the
day's sets in time order. List is the default below 768px. Planned sets are yellow and clashes get a red striped
background and a "Clash" badge. Links like /schedule?day=fri#set-halcyon-bloom scroll to and highlight one set.
The My plan panel lists saved sets by day with their clashes, and has two buttons:
- Add to calendar (.ics) downloads
solstice-2027-my-plan.icswith one event per set (times in UTC, location and description included). It works with Apple, Google and Outlook calendars. - Clear plan needs two clicks within three seconds, so a stray tap can't wipe the plan.
To change the hour height of the grid, edit --hour on .tt in globals.css.
Tickets and checkout#
src/components/Tickets.tsx. Steppers change quantities from 0 to MAX_PER_TYPE. The order summary lists
every line, then:
- Group discount:
GROUP_DISCOUNT(10%) off the ticket subtotal when an order hasGROUP_SIZE(6) or more tickets. Add-ons are not discounted. A hint shows how many more tickets unlock it. - Booking fee:
FEE(£1.75) per ticket; add-ons have no fee. - Rules (
problem()): a campervan pitch needs a Weekend + camping or Family weekend ticket per van, and a bell tent needs at least one of those tickets. The summary explains the problem and disables "Continue".
The basket is saved on the device (src/lib/cart.ts). "Continue to details" shows step 2, a validated form
(name, email, UK postcode, two required checkboxes); a valid submit shows step 3 with a random demo order
reference and the total, hides the order summary so the confirmation spans the full width
(data-step="done" on [data-checkout-layout]), and empties the basket. Nothing is charged or sent. To sell
real tickets, link the "Continue" button to your ticketing provider, or send the basket to your own checkout
in placeOrder().
Food village filters and map#
src/components/FoodVillage.tsx. "Show" buttons filter by food, sweet or drink; dietary chips can be combined
and traders must match all of them; the search box matches name, cuisine and dish. Matching pins on the map
stay bright, others fade. "Show on map" highlights a trader's pin; clicking a pin (or pressing Enter on it)
highlights the trader's card. /food?diet=vegan,gluten-free preselects dietary filters.
Site map layers#
Each layer of the map on /info is a <g className="map-layer" data-layer-group="..."> (stages, food,
toilets, firstaid, water, access). MapLayers (src/components/MapLayers.tsx) makes the buttons with
the same data-layer toggle it with aria-pressed, and "Show all" turns every layer back on. Each symbol has a
<title> tooltip, and "Read the map as a list" gives the same information as text. To add a symbol, put a new
<g> with a <title> inside the right layer group in src/app/info/page.tsx.
FAQ search#
src/components/FaqSearch.tsx filters the questions from src/data/faq.ts as you type (every word must
match), opens the matches, highlights the words with <mark>, hides empty groups and announces the count.
Clearing the search closes the answers it opened.
Contact routing#
The topic radios in src/components/ContactForm.tsx show the matching [data-topic-panel] (volunteer, trader,
press, general), disable the fields of the others (so they are neither validated nor submitted) and say where
the message goes. /contact?topic=trader preselects a topic. Team names and addresses are in ROUTES at the top
of the file.
Scroll reveals, tape and back to top#
Elements with .reveal fade in as they scroll into view (src/components/RevealObserver.tsx), and everything
above them is revealed too, so fast jumps never leave gaps. The home page tape scrolls continuously and pauses on
hover. A back-to-top button (src/components/BackToTop.tsx) appears after one screen of scrolling; it steps
aside for the sticky order summary (data-back-to-top-hide). All motion is switched off for visitors who prefer
reduced motion.
Demo dates#
The demo is written for 25–27 June 2027. While KEEP_DEMO_CURRENT in src/data/festival.ts is true and that
edition is over (after the last day's curfew), every date moves to the same week of the next year, by whole weeks so
weekdays never change (Friday 25 June 2027 becomes Friday 23 June 2028), so the countdown, early-bird prices,
timetable and calendar export always point at an upcoming festival. The year of the edition follows.
src/lib/edition.ts works out the shift (editionShift(), also used by isEarlyAt() and dayLabel()), and
useEdition() and the components in src/components/Edition.tsx (<EditionYear>, <EditionDates>,
<EditionText>, <DayLabel>, <EditionTitle>) apply it after hydration, so the static build shows the dates as
written and there is no hydration mismatch. <EditionText text="… 1 June 2027 …" /> and useEdition().text() move
dates such as "1 June 2027" or "19–24 June" inside a sentence and swap the edition years. Before the edition is over
nothing changes, so ?now=2027-06-26T21:40 still previews the festival weekend. Set KEEP_DEMO_CURRENT = false
once you enter your real dates.
Previewing a date#
Add ?now=YYYY-MM-DDTHH:MM (festival local time) to any page to pretend it is that moment, for example
/tickets?now=2027-03-01T10:00 to see full prices after the early bird ends, or
/schedule?now=2027-06-26T15:00 to open on Saturday. It is read by festivalNow() in src/lib/festival.ts.
Forms#
Forms use useValidation() from src/lib/validation.ts: errors appear under each field (linked with
aria-describedby), aria-invalid is set, and focus moves to the first problem. A group of checkboxes or radios
is a <fieldset data-group-required="message">; a data-error attribute on a field replaces its default
message.
| Form | File | Validates |
|---|---|---|
| Line-up alerts | src/components/AlertsForm.tsx |
Email, at least one alert type |
| Checkout | src/components/Tickets.tsx |
Name, email, UK postcode pattern, age and terms checkboxes |
| Contact | src/components/ContactForm.tsx |
Topic, name, email, topic fields, message of 20+ characters |
The demo forms don't send anything. To connect a form service such as Formspree, send the data in the
form's onSubmit handler, where the comment marks the spot:
// in onSubmit, after `if (!v.validate(form)) return;`
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error("Send failed");(make the handler async). If you drop the static export, you can post to a Route Handler instead, for example
src/app/api/contact/route.ts with export async function POST(req: Request) { const data = await req.formData(); ... }, or use a Server Action. Update the success texts so they no longer say "Demo only".
Saved data (localStorage)#
| Key | Shape | Validation |
|---|---|---|
solstice-plan |
Array of artist ids, e.g. ["halcyon-bloom","kemi-sunday"] |
Non-strings, duplicates and unknown ids are dropped and storage is repaired |
solstice-cart |
{ "tickets": { "camping": 2 }, "addons": { "parking": 1 } } |
Unknown ids are dropped, quantities are clamped to 0–MAX_PER_TYPE; removed when the basket is empty |
Both are read with useSyncExternalStore, so the server render (and the first browser render) shows an empty
plan and basket and the saved values appear straight after. If storage is unavailable (private mode),
everything still works for the current page. Visitors can reset by clearing the plan on the timetable or the
site data in their browser; in code, call localStorage.removeItem("solstice-plan").
SEO and sharing#
Each page exports its own metadata (title, description, Open Graph) through pageMetadata() in
src/lib/metadata.ts; the defaults and theme-color are in src/app/layout.tsx. Social images need an
absolute URL: set NEXT_PUBLIC_SITE_URL when you build (it becomes metadataBase):
NEXT_PUBLIC_SITE_URL=https://www.yourfestival.com pnpm buildAdd src/app/sitemap.ts and src/app/robots.ts (Next.js generates both at build time). For richer search
results you can add schema.org/MusicEvent or Festival structured data (name, dates, location, offers) to the
home page as a <script type="application/ld+json">.
Accessibility#
- Skip link, landmarks, one
<h1>per page and a logical heading order. - Visible focus outlines and WCAG AA colour contrast.
- Mobile menu with
aria-expanded, closed with Esc. - Filters and toggles use
aria-pressed; day tabs use the tabs pattern; the artist pop-up is a native<dialog>that returns focus; counts and totals are announced witharia-live. - The timetable has a list view, each grid column is labelled with its stage, and the site map has a text version.
- Reduced motion turns off reveals, the tape, the turning sun and hover lifts.
When editing, keep one <h1> per page, give every new field a <label>, and use PlanButton for sun buttons so
their labels stay up to date.
Building and deploying#
pnpm build # static export to out/Upload out/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, your own server) or deploy the project
to Vercel. To serve the site from a sub-folder, build with DEMO_BASE_PATH=/your-folder (used as basePath in
next.config.ts). If you need server features (Route Handlers, Server Actions), remove output: "export" from
next.config.ts and deploy to a Node.js host or Vercel.
Troubleshooting#
- The line-up or timetable shows nothing for a new artist. Check
day,stage(must match a stageid) andHH:MMtimes in 24-hour format;pnpm typecheckcatches unknown days, stages and genres. - Prices show the full price before the early-bird date. Check
EARLY_BIRD_ENDS(ISO date with a time zone) and remove any?now=from the URL. - The calendar file has the wrong times. Set
UTC_OFFSETto your festival's offset during the event (+01:00for British Summer Time,+02:00for central Europe in summer). - Saved plans contain removed artists. They are dropped automatically the next time a page loads.
- The social preview shows no image. Build with
NEXT_PUBLIC_SITE_URLset to your domain. - Assets 404 when hosted in a sub-folder. Build with
DEMO_BASE_PATHset to that folder.
License#
MIT. See LICENSE.txt in the download (or the repository license). All SVG artwork in this template is included
under the same license. The fonts are under the SIL Open Font License (src/fonts/*-OFL.txt).