Freeframe

Documentation

Solstice

v1.0.2Updated Oct 17, 2026A sunny, poster-style festival website with a line-up, timetable planner and ticket shop.

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

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

With 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.json

Pages 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):

css
@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:

ts
{ 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"] },
  • id must be unique; it is used in URLs (/lineup?artist=paper-moons), the plan and the calendar.
  • tier is 1 for headliners (red "Headliner" badge, listed first), 2 for special guests, 3 for the rest.
  • art is [motif, background, foreground]. Motifs: sun, waves, rings, stripes, burst, hills.
  • day, stage and genre are typed (DayId, StageId, GenreId), so pnpm check flags 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:

ts
{ 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) and hero-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() in src/scripts/main.ts from each artist's art setting. To use photos instead, replace artSvg(a) in the card and pop-up templates with an <img> (prefix its src with BASE); 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 by main.ts from vendors[].pin.
  • og-image.svg is the editable source of the social image; export it to og-image.png at 1200×630 after editing (most social sites don't display SVG).
  • favicon.svg is the logo mark; the same mark is inlined in the header and footer components.
  • src/components/SiteHeader.astro is the header and full-screen mobile menu. Its links come from the nav array (desktop) and mobileNav (which adds Home and Tickets). A link gets aria-current="page" when the page's current prop matches its key, for example current="lineup".
  • src/components/SiteFooter.astro is 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:

html
---
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.ics with 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 has groupSize (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.

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:

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

To 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 title and description to <Layout>. The layout turns them into <title>, the meta description, og:title, og:description and a summary_large_image Twitter card.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://solstice.example.com). Set it to your domain when you build:

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

    Canonical 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/sitemap and, for richer search results, schema.org/MusicEvent or Festival structured data (name, dates, location, offers) as JSON-LD in index.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 with aria-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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /lineup serves lineup.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /lineup to lineup.html, change build.format to "directory" in astro.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.ts stops the build, so run pnpm check.
  • A set shows in the wrong place. Check day, stage (must match a stage id) and HH:MM times 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 utcOffset to your festival's offset during the event (+01:00 for British Summer Time, +02:00 for central Europe in summer).
  • Saved plans contain removed artists. They are dropped automatically the next time a page loads.
  • /lineup shows a 404 on my host. The host doesn't map extensionless URLs to .html files. Set build.format: "directory" in astro.config.mjs and rebuild.
  • pnpm install skips a build script. pnpm-workspace.yaml lists 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.

Search documentation

Search the guides and every template's documentation