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 Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.0. It builds to static HTML files with no UI framework and can be hosted anywhere. See it live at freeframe.dev/templates/solstice.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev. Add ?now=2027-06-25T21:30 to any page's URL to preview it
at a given festival time (see Previewing a date).
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/solstice into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | What's on it |
|---|---|---|
/ |
src/pages/index.astro |
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/pages/lineup.astro |
Day, stage and genre filters (kept in the URL), "My plan only", artist cards with generated artwork, artist pop-up |
/schedule |
src/pages/schedule.astro |
Day tabs, grid and list views, "My plan only", My plan panel with clashes, .ics export and clear, opening times |
/tickets |
src/pages/tickets.astro |
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/pages/food.astro |
Food/sweet/drink and dietary filters, search, trader cards, food village map with numbered pins |
/info |
src/pages/info.astro |
Site map with layer toggles and a text version, getting there, accessibility, families, what to bring, searchable FAQ |
/contact |
src/pages/contact.astro |
Contact form that routes volunteer, trader, press and visitor enquiries, direct contacts |
| 404 | src/pages/404.astro |
Not-found page (most hosts show 404.html automatically) |
Project structure#
solstice/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), skip link, header, footer, toast, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro logo, desktop navigation, "My plan" pill, tickets button, full-screen mobile menu
│ │ └─ SiteFooter.astro footer links, social links and copyright
│ ├─ data/
│ │ └─ solstice.ts days, stages, genres, artists, traders, tickets, add-ons and ticket rules
│ ├─ lib/
│ │ ├─ site.ts site name, theme colour and the url() helper for links
│ │ └─ icons.ts the "my plan" sun icon shared by the header
│ ├─ scripts/
│ │ ├─ main.ts every interactive feature, forms, reveals and the mobile menu
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (colours, fonts) and component classes
├─ public/img/ hero-sun.svg, hero-hills.svg, bunting.svg, favicon.svg, og-image.png (and its SVG source)
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonPages are rendered to HTML at build time. The two files in src/scripts/ are the only JavaScript sent to
the browser, plus one inline line in the layout that swaps the no-js class on <html> for js before
the page paints. Layout.astro imports both scripts, and Astro bundles them (with the data they import
from src/data/solstice.ts) into one small module.
Customising#
Brand and colours#
All colours are tokens in the @theme block of src/styles/global.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 (pnpm dev picks it up straight away):
@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
SVG in the pages and the generated artwork in src/scripts/main.ts use hex colours directly; search for
the old hex value to change them too. themeColor in src/lib/site.ts sets <meta name="theme-color">.
The "poster" look comes from a few component classes in global.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 Google Fonts are loaded by one <link> in src/layouts/Layout.astro:
| Font | Token | Used for |
|---|---|---|
| Bricolage Grotesque (wdth 75–100, wght 400–800) | --font-display |
Headings via .display and .h-section, numbers |
| DM Sans | --font-sans |
Body text |
| Shrikhand | --font-script |
The "Solstice" wordmark (.wordmark) |
To swap a font, change the Google Fonts <link> in Layout.astro and the matching token in @theme.
.display uses font-stretch: 75%; if your new display font has no width axis, remove that line.
Content#
Festival content lives in src/data/solstice.ts as typed named exports:
| Export | What it holds |
|---|---|
keepDemoCurrent |
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 |
gatesOpen, utcOffset |
Opening time (drives the home page 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 |
earlyBirdEnds, groupSize, groupDiscount, fee, maxPerType, 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.day,stageandgenreare typed (DayId,StageId,GenreId), sopnpm checkflags a typo.- 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 in the browser. The poster-style line-up on the home
page (index.astro, "Three days. Forty acts.") is plain markup so search engines can read it: update it
by hand 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). The ticket rows
in tickets.astro show the same names and prices; prices on every page are refreshed from the data when
the page loads (elements with data-price-of="<ticket id>" and data-full-of="<ticket id>"), so
solstice.ts is the source of truth. If you add a ticket type, copy one of the rows in tickets.astro
and change its data-qty, ids and labels.
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.- Artist artwork is generated by
artSvg()insrc/scripts/main.tsfrom each artist'sartsetting. To use photos instead, replaceartSvg(a)in the card and pop-up templates with an<img>(prefix itssrcwithBASE); keep a square (1:1) image for cards. - The site map (
info.astro) and the food village map (food.astro) are inline SVG. Food pins are drawn bymain.tsfromvendors[].pin. 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).favicon.svgis the logo mark; the same mark is inlined in the header and footer components.
Navigation, header and footer#
src/components/SiteHeader.astrois the header and full-screen mobile menu. Its links come from thenavarray (desktop) andmobileNav(which adds Home and Tickets). A link getsaria-current="page"when the page'scurrentprop matches itskey, for examplecurrent="lineup".src/components/SiteFooter.astrois the footer.- The "My plan" pill (
[data-plan-link]) and every[data-plan-count]element show how many sets are saved.
To add a page, create src/pages/workshops.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Workshops — Solstice 2027" description="Makers' workshops all weekend." current="workshops" page="workshops">
<main id="main">
<section class="container-x pt-36 pb-20">
<h1 class="display text-6xl">Workshops</h1>
</section>
</main>
</Layout>Each page provides its own <main id="main"> (the skip link's target); page sets data-page on
<body>. Then add { key: "workshops", href: "/workshops", label: "Workshops" } to nav in
SiteHeader.astro, and a link to the footer. Always write internal links as href={url("/workshops")}.
Features#
Countdown#
index.astro has <div data-countdown={gatesOpen}> with four [data-unit] cells (days, hours,
minutes, seconds). Change gatesOpen in src/data/solstice.ts to move the target. When it reaches zero,
the cells are hidden and [data-countdown-done] is shown. The countdown has role="timer".
Early-bird prices#
earlyBirdEnds decides which price is used everywhere. Before that moment, early-bird prices show with
the full price struck through and [data-early-only] elements (such as the hero sticker) are shown;
afterwards, full prices show and [data-early-hide] elements appear instead.
Line-up filters and artist pop-up#
The code is in the "line-up page" section of main.ts. Day buttons ([data-day-filter]), the stage and
genre selects (#filter-stage, #filter-genre) and "My plan only" ([data-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> ([data-artist-modal]) 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 ([data-plan-toggle="<artist id>"]) adds or removes a set from the visitor's plan,
uses aria-pressed and shows a toast. The plan is saved in localStorage (see
Saved data), shared by all pages and synced between open tabs. clashes() in
main.ts marks two planned sets as clashing when they are on the same day and their times overlap.
Timetable#
/schedule 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 global.css.
Tickets and checkout#
Steppers ([data-qty] with [data-step="-1"] and [data-step="1"]) change quantities from 0 to
maxPerType. The order summary lists every line, then:
- Group discount:
groupDiscount(10%) off the ticket subtotal when an order hasgroupSize(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: 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. "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 centred confirmation spans the full width (data-step="done"
on [data-checkout-layout], styled with lg:data-[step=done]:grid-cols-1), 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 from the solstice:valid listener in the "tickets page" section
of main.ts.
Food village filters and map#
"Show" buttons ([data-kind]) filter by food, sweet or drink; dietary chips ([data-diet]) 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 /info map is a <g class="map-layer" data-layer-group="..."> (stages, food,
toilets, firstaid, water, access). 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 the
"Read the map as a list" section gives the same information as text. To add a symbol, put a new <g>
with a <title> inside the right layer group in info.astro.
FAQ search#
Typing in [data-faq-search] filters the [data-faq] questions on /info, opens matches, highlights the
words with <mark>, hides empty groups ([data-faq-group]) and announces the count. To add a question,
copy a <details class="faq ..." data-faq> block; keep the data-faq-q and data-faq-a hooks.
Contact routing#
The topic radios on /contact 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 in
the "contact" section of main.ts.
Scroll reveals, tape and mobile menu#
Elements with .reveal fade in as they scroll into view, and everything above them is revealed too, so
fast jumps never leave gaps. The home page tape scrolls continuously and pauses on hover. The mobile menu
([data-menu-toggle], #mobile-menu) uses aria-expanded, moves focus to its first link, closes with
Esc and closes itself when the window grows to desktop width. All motion is switched off for visitors who
prefer reduced motion.
Back to top button#
src/scripts/back-to-top.ts adds a .back-to-top button that appears after one screen of scrolling. It
scrolls back up (instantly with reduced motion), moves keyboard focus to #main, lifts above toasts and
steps aside for the sticky order summary (data-back-to-top-hide). Mark your own fixed UI with
data-back-to-top-avoid or data-back-to-top-hide. To remove it, delete the file, its import in
Layout.astro and the .back-to-top rules at the end of global.css.
Demo dates#
The demo is written for 25–27 June 2027. While keepDemoCurrent in src/data/solstice.ts is true and that edition
is over (after the last day's curfew), src/scripts/main.ts moves every date 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. It
updates the days dates, earlyBirdEnds, the countdown target and the artist bios, and the text of
data-edition (dates "25–27 June 2027", dates-noyear, dates-long "Friday 25 – Sunday 27 June 2027",
dates-long-noyear, year, year+1), data-date="2027-06-01" (data-date-format="dm" leaves out the year,
data-date-end makes a range) and data-day-label="fri", plus the page title, aria-labels and data-error
messages. Before the edition is over nothing is changed, so ?now=2027-06-26T21:40 still previews the festival
weekend. Set keepDemoCurrent to 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, /?now=2027-06-25T14:00
to see the countdown finished, or /schedule?now=2027-06-26T15:00 to open on Saturday.
Forms#
Every form with data-validate gets inline validation from the "forms" section of main.ts: errors
appear under each field (linked with aria-describedby), aria-invalid is set, and focus moves to the
first problem. Groups of checkboxes or radios use <fieldset data-group-required="message">.
| Form | Page | Validates |
|---|---|---|
Line-up alerts (data-form="alerts") |
/ |
Email, at least one alert type |
Checkout (data-form="checkout") |
/tickets |
Name, email, UK postcode pattern, age and terms checkboxes |
Contact (data-form="contact") |
/contact |
Topic, name, email, topic fields, message of 20+ characters |
The demo forms don't send anything. On a valid submit, the form fires a solstice:valid event with
the FormData and shows its [data-success] message. To connect a form service such as Formspree, find
the comment "To send for real" in the submit listener, make the listener async (e) => { ... } and post
the data before showing success:
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message hereTo run your own server code instead, add an Astro adapter (for example @astrojs/node,
@astrojs/netlify or @astrojs/vercel) and an
API endpoint such as src/pages/api/contact.ts with
export const prerender = false, then post to it.
Update the success texts in the pages (and the data-success-detail text set in the contact section of
main.ts) so they no longer say "Demo only". For real ticket sales, don't collect payment details in the
checkout form: use a ticketing or payment provider and send the basket to their checkout.
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–maxPerType; removed when the basket is empty |
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"). For a new edition, change PLAN_KEY and CART_KEY in
main.ts so old plans don't carry over.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>. The layout turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://solstice.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm buildCanonical URLs have no
.html(https://example.com/lineup). -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
Add a sitemap with
@astrojs/sitemapand, for richer search results,schema.org/MusicEventorFestivalstructured data (name, dates, location, offers) as JSON-LD inindex.astro.
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 keep sun buttons as
<button> elements with the data-plan-toggle attribute so their labels stay up to date.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as lineup.html rather than
lineup/index.html (build.format: "file" in astro.config.mjs), and links point to /lineup without
a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /lineup from lineup.html
automatically; Vercel does with cleanUrls (below).
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/lineupserveslineup.html. - GitHub Pages: use the official Astro GitHub Action.
For a project site (
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/lineuptolineup.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/solstice pnpm build. It becomes Astro's base; every link written with url() and every
link the script builds (artist and timetable links) includes it.
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and
provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the
TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7,
replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- The line-up or timetable is empty. Check the browser console; a type or syntax error in
src/data/solstice.tsstops the build, so runpnpm check. - A set shows in the wrong place. Check
day,stage(must match a stageid) andHH:MMtimes in 24-hour format. - Prices show the full price before the early-bird date. Check
earlyBirdEnds(ISO date with a time zone) and remove any?now=from the URL. - The calendar file has the wrong times. Set
utcOffsetto 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.
/lineupshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"inastro.config.mjsand rebuild.pnpm installskips a build script.pnpm-workspace.yamllists the packages allowed to run install scripts (esbuild,sharp,@tailwindcss/oxide); add any new one there.
License#
MIT. See LICENSE.txt in the download (or the repository license). All SVG artwork in this template is
included under the same license. Bricolage Grotesque, DM Sans and Shrikhand are loaded from Google Fonts
under the SIL Open Font License.