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. It is built with HTML5, Tailwind CSS v4 and vanilla JavaScript, and needs no build step. This guide covers version 1.0.0.
Live demo: freeframe.dev/templates/solstice
Quick start#
-
Unzip the download and open
index.htmlin a browser. -
Or serve the folder with any static server:
shcd solstice python3 -m http.server 8000 # then open http://localhost:8000
Add ?now=2027-06-25T21:30 to any page's URL to preview it at a given festival time (see
Previewing a date). Node.js 24 is only needed to rebuild the CSS (see
Building and deploying).
Pages#
| File | Purpose | Notable sections |
|---|---|---|
index.html |
Home | 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.html |
Line-up | Day, stage and genre filters (kept in the URL), "My plan only", artist cards with generated artwork, artist pop-up |
schedule.html |
Timetable | Day tabs, grid and list views, "My plan only", My plan panel with clashes, .ics export and clear, opening times |
tickets.html |
Tickets | Day, weekend and add-on rows with steppers, order summary with fees and group discount, three-step demo checkout, good-to-know cards |
food.html |
Food & drink | Food/sweet/drink and dietary filters, search, trader cards, food village map with numbered pins |
info.html |
Plan your visit | Site map with layer toggles and a text version, getting there, accessibility, families, what to bring, searchable FAQ |
contact.html |
Get involved | Contact form that routes volunteer, trader, press and visitor enquiries, direct contacts |
404.html |
Not found | Error page with links back |
Project structure#
solstice/
├── index.html, lineup.html, schedule.html, tickets.html, food.html, info.html, contact.html, 404.html
├── assets/
│ ├── css/style.css compiled Tailwind CSS (generated, do not edit by hand)
│ ├── js/data.js festival content: days, stages, genres, artists, traders, tickets, add-ons
│ ├── js/main.js every interactive feature, forms, reveals and the back-to-top button
│ └── img/
│ ├── hero-sun.svg striped sunset sun with slowly turning rays
│ ├── hero-hills.svg layered hills with the main stage, trees, tents and a ferris wheel
│ ├── bunting.svg flag bunting along the top of the home and visitor pages
│ ├── favicon.svg sun logo mark
│ ├── og-image.svg social image source
│ └── og-image.png social image (1200×630) used in og:image
├── src/input.css Tailwind source: design tokens (@theme) and component classes
├── package.json scripts to rebuild the CSS
├── 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/input.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 and rebuild:
@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 assets/img/ and the
generated artwork in main.js use hex colours directly; search for the old hex value to change them too.
The "poster" look comes from a few component classes in src/input.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 in the <head> of every page:
| 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 each page 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 assets/js/data.js as window.SOLSTICE:
| Key | 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 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.html?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 (index.html, "Three days. Forty acts.") is plain HTML 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.html show the same names and prices; prices on every page are refreshed from data.js
when the page loads (elements with data-price-of="<ticket id>" and data-full-of="<ticket id>"), so
data.js is the source of truth. If you add a ticket type, copy one of the rows in tickets.html and
change its data-qty, ids and labels.
Images and artwork#
There are no photos. All artwork is SVG:
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()inmain.jsfrom each artist'sartsetting. To use photos instead, replaceartSvg(a)in the card and pop-up templates with an<img>; keep a square (1:1) image for cards. - The site map (
info.html) and the food village map (food.html) are inline SVG. Food pins are drawn bymain.jsfromvendors[].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 each page's header and footer.
Navigation, header and footer#
The header and footer are repeated in every HTML file. To add a page, copy an inner page such as
contact.html, then add a link to the desktop <nav aria-label="Main">, the mobile menu
(#mobile-menu) and the footer in every page. Mark the current page with aria-current="page" on its
links. The "My plan" pill ([data-plan-link]) and every [data-plan-count] element show how many sets
are saved.
Features#
Countdown#
index.html has <div data-countdown="2027-06-25T14:00:00+01:00"> with four [data-unit] cells
(days, hours, minutes, seconds). Change the attribute 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 in data.js 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.js. 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.html?day=sat&stage=meadow&genre=indie can be shared or
bookmarked. lineup.html?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 and
uses aria-pressed. The plan is saved in localStorage (see Saved data),
shared by all pages and synced between open tabs. clashes() in main.js marks two planned sets as
clashing when they are on the same day and their times overlap.
Timetable#
schedule.html shows one day at a time with keyboard-friendly tabs (Left/Right, Home/End). The day is
kept in the URL (schedule.html?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.html?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 src/input.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 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 from the solstice:valid
listener in the "tickets page" section of main.js.
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.html?diet=vegan,gluten-free preselects dietary filters.
Site map layers#
Each layer of the info.html 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.
FAQ search#
Typing in [data-faq-search] filters the [data-faq] questions on info.html, 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.html 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.html?topic=trader preselects a topic. Team names and addresses are in ROUTES in
the "contact" section of main.js.
Scroll reveals, tape and back to top#
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. A back-to-top
button 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 keepDemoCurrent in data.js is true and that edition is over
(after the last day's curfew), the script 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:
daysdates,gatesOpen,earlyBirdEnds, the countdown target and years and dates in the artist bios;data-editiontext:dates("25–27 June 2027"),dates-noyear,dates-long("Friday 25 – Sunday 27 June 2027"),dates-long-noyear,yearandyear+1;data-date="2027-06-01"text ("1 June 2027";data-date-format="dm"leaves out the year, anddata-date-endmakes a range such as "19–24 June");data-day-label="fri"("Friday 25 June"), the page title,aria-labels anddata-errormessages.
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.html?now=2027-03-01T10:00 to see full prices after the early-bird ends, or
schedule.html?now=2027-06-26T15:00 to open on Saturday.
Forms#
Every form with data-validate gets inline validation from the "forms" section of main.js: 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") |
index.html |
Email, at least one alert type |
Checkout (data-form="checkout") |
tickets.html |
Name, email, UK postcode pattern, age and terms checkboxes |
Contact (data-form="contact") |
contact.html |
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, set
the form's action and send the data in the submit handler, where the comment marks the spot:
// in form.addEventListener('submit', ...) in assets/js/main.js
fetch('https://formspree.io/f/your-id', {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' }
}).then(function (res) {
if (!res.ok) throw new Error('Send failed');
// show the success message here
});Update the success texts in the HTML (and the data-success-detail text set in the contact section of
main.js) 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–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').
SEO and sharing#
Each page has its own <title>, meta description, Open Graph tags and theme-color in its <head>.
og:image is assets/img/og-image.png; most social sites need an absolute URL, so change it to your
domain on every page:
<meta property="og:image" content="https://www.yourfestival.com/assets/img/og-image.png">Add a sitemap.xml listing the pages and a robots.txt when you deploy. For richer search results you
can add schema.org/MusicEvent or Festival structured data (name, dates, location, offers) to
index.html.
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#
The compiled CSS is included, so you only need to rebuild after changing src/input.css or adding
Tailwind classes that aren't used anywhere yet. With Node.js 24:
npm install
npm run build # or: npm run watchTailwind scans the HTML and JavaScript files for class names, so classes used only in main.js strings
work as long as they appear literally in the file.
To publish, upload the folder (without node_modules/) to any static host: Netlify Drop, GitHub Pages,
Cloudflare Pages, Vercel or your own server. No server-side code is needed.
Troubleshooting#
- New Tailwind classes have no effect. Run
npm run build; only classes found in the files are generated. - The line-up or timetable is empty.
data.jsmust load beforemain.js, and must not have a syntax error (check the browser console, a missing comma is the usual cause). - 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.
- The social preview shows no image. Use an absolute
og:imageURL and the PNG, not the SVG.
License#
MIT. See LICENSE.txt in the download (or the repository license). All SVG artwork in this template is
included under the same license.