Overview#
Summit is a vibrant website for a conference or event: a live countdown, speakers with pop-up profiles, a three-day schedule with tracks and a personal "My schedule", ticket selection with a team discount, the venue and sponsors. The demo event is Summit 2027, a fictional design, engineering and AI conference in Copenhagen (14–16 April 2027) with 12 speakers and 35 sessions. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.2. It builds to static HTML files with no UI framework and can be hosted anywhere. See it live at freeframe.dev/templates/summit.
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-04-15T10:45 to any page's URL to preview it
as if the event were live (see Live schedule state).
| 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/summit 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 |
Hero with live countdown, topics strip, highlights, featured speakers, count-up stats, schedule preview, sponsor wall, testimonial, venue preview, newsletter |
/speakers |
src/pages/speakers.astro |
Track filter and pop-up speaker profiles |
/speaker |
src/pages/speaker.astro |
One speaker (/speaker?id=hana-kim): bio, links, sessions, "More speakers" |
/schedule |
src/pages/schedule.astro |
Day tabs, track filter, starred sessions in "My schedule", "On now" / "Up next" during the event |
/tickets |
src/pages/tickets.astro |
Four passes with early-bird prices, quantity steppers, order summary with team discount, comparison, refund FAQ, demo checkout |
/venue |
src/pages/venue.astro |
Venue details, illustrated map, travel options, partner hotels, accessibility |
/sponsors |
src/pages/sponsors.astro |
Sponsor tiers and packages, sponsorship enquiry form |
| 404 | src/pages/404.astro |
Not-found page (most hosts show 404.html automatically) |
Project structure#
summit/
├─ 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, tickets button, full-screen mobile menu
│ │ └─ SiteFooter.astro footer links and copyright
│ ├─ data/
│ │ └─ summit.ts tracks, days, speakers, sessions, ticket prices, early-bird date, team discount
│ ├─ lib/
│ │ └─ site.ts site name, theme colour and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts countdown, speakers, schedule and stars, tickets, forms, reveals, menu
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (colours, fonts), track tags and component classes
├─ public/img/ speakers/<id>.svg portraits, favicon, 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/summit.ts) into one small module.
Customising#
Brand and colours#
The palette and fonts are tokens in the @theme block at the top of src/styles/global.css:
| Token | Used for |
|---|---|
--color-night, --color-night-2, --color-panel |
Dark backgrounds and cards |
--color-edge |
Borders |
--color-ink, --color-haze |
Text and secondary text |
--color-violet, --color-magenta, --color-flame |
The gradient used for headlines, buttons and highlights |
--color-lime |
Focus rings, "On now" badges, selected items |
--color-sky |
"Up next" badges and accents |
Change the three gradient colours to restyle the whole event; pnpm dev picks it up instantly:
@theme {
--color-violet: #2563eb;
--color-magenta: #06b6d4;
--color-flame: #22c55e;
}Track colours (.tag-design, .tag-engineering, .tag-ai, ...) are component classes in
global.css. If you change --color-night, also change site.themeColor in src/lib/site.ts, which
fills the theme-color meta tag.
Typography#
Headings use Space Grotesk (--font-display) and text uses Inter (--font-sans), loaded from
Google Fonts by a <link> in src/layouts/Layout.astro. To change them, replace that link and the two
tokens. To self-host, put the font files in public/fonts/, add @font-face rules to global.css and
remove the <link> and the two preconnect tags.
Content#
Page text lives in each src/pages/*.astro file; the title and description are the title and
description props on <Layout>. Event data lives in src/data/summit.ts, which is typed, so
pnpm check catches a misspelt track or a missing field:
| Export | Contents |
|---|---|
tracks |
Track ids and labels: design, engineering, ai, leadership, general |
days |
{ id: "wed", date: "2027-04-14", label: "Wed 14 Apr", title: "Workshop day" } |
speakers |
{ id, name, role, company, city, track, bio, twitter, site } (Speaker) |
sessions |
{ id, day, start, end, title, speakers: [ids], track, room, level, kind } (Session) |
earlyBirdEnds |
When early-bird pricing ends: "2026-12-31T23:59:59+01:00" |
keepDemoCurrent |
true in the demo: once the edition is over, every date moves on a year (see Demo dates). Set it to false when you enter your real dates |
teamDiscount |
{ minTickets: 5, percent: 15 } |
tickets |
{ id, name, price, early } per ticket (Ticket) |
What reads which source:
| Content | Comes from |
|---|---|
Speaker cards on /speakers and / |
The page markup |
Speaker pop-up, /speaker?id=…, "More speakers" |
speakers and sessions |
Default /speaker (no ?id=) |
The page markup, written for the first speaker in speakers |
Schedule sessions on /schedule |
The page markup (<li data-session="…"> items) |
| Valid starred sessions, a speaker's talks | sessions |
| Ticket prices and early-bird prices | tickets (price and early per ticket id) |
| Early-bird end date and team discount | earlyBirdEnds, teamDiscount |
| Countdown target | data-countdown on the countdown in index.astro |
Adding a session means adding it to sessions and adding a schedule item to the right day panel in
src/pages/schedule.astro, with the same id and timing attributes:
<li class="session grid gap-4 p-5 sm:grid-cols-[8.5rem_1fr_auto] sm:p-6"
data-session="s36" data-track="ai" data-day="thu"
data-start="2027-04-15T16:00" data-end="2027-04-15T16:40">
<!-- copy the inner markup of an existing session: time, title, speakers, room, star button -->
</li>{ id: "s36", day: "thu", start: "16:00", end: "16:40", title: "Agents in the wild", speakers: ["sam-wu"], track: "ai", room: "Hall B", level: "Intermediate", kind: "talk" },Copy the inner markup from a neighbouring session so it keeps its star button (data-star="s36") and
.live-badge. data-start / data-end are local event times without a time zone.
Adding a speaker means an entry in speakers, a square portrait at public/img/speakers/<id>.svg,
and a card in speakers.astro (a list item with data-track and a button with data-speaker="<id>").
Demo dates#
The demo is written for 14–16 April 2027. While keepDemoCurrent in src/data/summit.ts is true and that
edition is over (the event-local date is after the last of days), src/scripts/main.ts moves every date to the
same week of the next year, by whole weeks so weekdays never change (Wed 14 April 2027 becomes Wed 12 April 2028),
so the countdown, ticket prices, schedule and "live" state always point at an upcoming conference. The year of the
edition follows. It updates:
days(dates and labels),earlyBirdEnds, the countdown target and everydata-startanddata-end;data-editiontext:dates("14–16 April 2027"),year("2027"),year-1,year+1,yy("’27") andyy-plain("27", in the hotel code);data-date="2027-04-01"text, written asdmy("1 April 2027", the default),dm("1 March") ordmon("31 Dec") withdata-date-format;data-day-label="wed"(the schedule's day tabs), the page title andaria-labels that contain the year.
Before the edition is over nothing is changed, so a preview such as ?now=2027-04-15T10:45 still shows the live
schedule. Set keepDemoCurrent to false once you enter your real dates. Elements with data-early-only (the
"Early-bird tickets on sale" pill and the first sentence of the tickets intro) are removed once early-bird pricing has
ended, whatever the setting.
Time zone#
Event times are written in Copenhagen summer time. eventTime() in src/scripts/main.ts appends
+02:00 to every time, and the schedule's "today" check uses the same offset. For an event elsewhere,
change both (for example to -04:00 and -4 * 3600 * 1000 for New York in summer), plus the note at the
top of src/data/summit.ts.
Images and artwork#
Speaker portraits are illustrated SVGs in public/img/speakers/<speaker-id>.svg (square). Replace them
with square photos using the same file names, or change speakerImg() in src/scripts/main.ts (and the
<img> tags in the pages) if you use .jpg. Write image paths with url() in .astro files so they
work under a base path:
<img src={url("/img/speakers/hana-kim.svg")} alt="" width="400" height="400" />Other art is inline SVG in the pages. The favicon is public/img/favicon.svg; the social image is
public/img/og-image.png (1200×630), exported from public/img/og-image.svg.
Navigation, header and footer#
src/components/SiteHeader.astrois the header and full-screen mobile menu. A link getsaria-current="page"(and its highlighted style) when the page'scurrentprop matches it, for examplecurrent="schedule";speaker.astropassescurrent="speakers".src/components/SiteFooter.astrois the footer.
To add a page, create src/pages/workshops.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Workshops — Summit 2027" description="Hands-on workshops on 14 April." 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 the link to the desktop and mobile menus in SiteHeader.astro (with the same
aria-current={(current === "workshops") ? "page" : undefined} pattern) and to the footer. Always write
internal links as href={url("/workshops")}.
Features#
All interactive code is in src/scripts/main.ts, one commented block per feature. Each block looks for
its elements by data-* attribute and does nothing if they're missing. HTML strings built in the script
(speaker pop-ups, "My schedule", the order summary) use Tailwind classes too; Tailwind scans src/, so
classes you add there are generated automatically.
Countdown#
The element with data-countdown="2027-04-14T09:00:00+02:00" counts down days, hours, minutes and
seconds into its [data-unit="days"], "hours", "minutes" and "seconds" children every second. It
has role="timer" and stops at zero.
Speakers filter and pop-up profiles#
On /speakers, buttons with data-filter (all or a track id) show speakers whose list item has a
matching data-track and update [data-count-label]. Clicking a speaker opens a native <dialog>
([data-speaker-modal]) built from speakers: portrait, role, bio, every session they appear in and a
"Full profile" link to /speaker?id=<id>. Esc, the close button or clicking outside closes it, and
focus returns to the speaker you opened.
Speaker pages#
/speaker is built for the first speaker in speakers. With ?id=<speaker-id> (any letter case:
?id=Mei-Tanaka works) the script swaps in that speaker's portrait, bio, links and session cards in the
same layout, with the same reveal animation, and updates the page title. Unknown ids (and names like
constructor) keep the first speaker. "More speakers" ([data-more-speakers]) lists four others.
Schedule, tracks and My schedule#
/schedule has a tablist of days ([data-day-tab], including a "My schedule" tab with id mine) with
Left/Right, Home and End keys. The current tab, chosen by click or key, is kept in the URL hash (/schedule#fri), and
during the event the current day (in event time) opens automatically.
- Track buttons (
data-track-filter) show one track; breaks and socials (general) stay visible so each day still reads as a timeline. An empty state ([data-filter-empty]) appears when a day has nothing in that track. - Star buttons (
data-star,aria-pressed) add sessions to My schedule, grouped by day, with a count in[data-star-count]and a toast. Their label follows the state ("Add “…” to my schedule", or "Remove “…” from my schedule" once starred, also for stars restored from the browser). Stars are saved in the browser (see Saved data).
Live schedule state#
Every minute the schedule marks the session that is on now (.is-now, "● On now") and the next one
within three hours (.is-next, "Up next"), and shows the live note ([data-live-note]). Preview it any
time with ?now=: /schedule?now=2027-04-15T10:45. The same parameter also drives the countdown and
early-bird pricing, which is handy for testing.
Tickets and order summary#
Each ticket card on /tickets (data-ticket="<id>") has −/+ steppers (data-step, 0–20 each). Until
earlyBirdEnds the early price is used and the full price is shown struck through; afterwards the
early-bird labels are removed. The summary lists each line, the subtotal, the team discount (15% from five
tickets, from teamDiscount), a hint showing how many more tickets unlock it, and the total.
Continue to checkout opens the checkout form.
Prices come from tickets in src/data/summit.ts, matched to each card by its data-ticket id:
export const tickets: Ticket[] = [
{ id: "workshop", name: "Workshop day", price: 340, early: 290 },
{ id: "conference", name: "Conference pass", price: 690, early: 490 },
// ...
];The script writes the current price (data-price-now) and the full price (data-price-was) into each
card, so the order totals always match the data. When early equals price the struck-through price and
early-bird label are removed. The prices typed into tickets.astro are only shown if JavaScript doesn't
run; update them, and the comparison table, to match. A card whose id isn't in tickets is hidden.
Scroll reveals and count-up stats#
Elements with reveal fade in when scrolled into view; numbers with data-count-to (and an optional
data-suffix) count up once when visible. The gradient blobs drift slowly. All motion stops with
prefers-reduced-motion, and content stays visible without JavaScript.
Mobile menu#
The menu button (data-menu-toggle) opens the full-screen #mobile-menu, locks page scrolling, makes the
header solid while open, moves focus to the first link, updates aria-expanded and its label, and closes
with Esc (focus returns to the button) or when the window reaches the lg breakpoint.
Back to top button#
src/scripts/back-to-top.ts adds a .back-to-top button that appears after you scroll down about one
screen. It scrolls back up (instantly with reduced motion), moves keyboard focus to #main and lifts
above toasts (such as "Added to my schedule"). Mark your own fixed UI with data-back-to-top-avoid (lift
above it) or data-back-to-top-hide (hide while covering it). To remove it, delete the file, its import
in Layout.astro and the .back-to-top rules at the end of global.css.
Forms#
Form (data-form) |
Page | Fields |
|---|---|---|
checkout |
/tickets |
first, last, email, company, country, payment (demo choice), terms |
sponsor |
/sponsors |
name, email, company, tier, message |
newsletter |
/ |
email |
Forms marked data-validate check required fields, email format, minimum lengths and required
checkboxes. Errors appear next to each field (data-error-for), fields get aria-invalid, and the first
problem is focused. On success the form's [data-success] element is shown and a summit:submitted
event fires; the ticket page uses it to show a confirmation with the order total that says plainly no
order was placed and no payment was taken.
The demo forms don't send anything. In the "forms" block of src/scripts/main.ts, find the comment
"To send for real", make the submit 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 here
$opt("[data-success]", form)?.classList.remove("hidden");
form.reset();
document.dispatchEvent(new CustomEvent("summit:submitted", { detail: { form: form.dataset.form } }));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/sponsor.ts with
export const prerender = false, then post to it.
For real ticket sales, don't collect payment details in this form. Use a ticketing or payment provider (Tito, Eventbrite, Stripe Checkout...): replace the checkout section with their embed, or send the selected quantities to their checkout from the Continue to checkout button.
Saved data (localStorage)#
| Key | Shape | Purpose |
|---|---|---|
summit-2027-stars |
Array of session ids, e.g. ["s2", "s14"] |
Sessions starred for My schedule |
On load the stored value is validated: anything that isn't an array of known, unique session ids (from
sessions) is dropped and storage is repaired; unreadable JSON counts as no stars. If storage is
blocked, starring still works for the current page. For a new edition, change STAR_KEY in
src/scripts/main.ts (for example summit-2028-stars) so old stars don't carry over. Reset with
localStorage.removeItem("summit-2027-stars") in the browser console.
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://summit.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm buildCanonical URLs have no
.html(https://example.com/schedule). -
/speakerupdates its title for the speaker in?id=in the browser. If speaker pages matter for search, generate one static page per speaker with a dynamic route such assrc/pages/speakers/[id].astrousinggetStaticPaths()overspeakers. -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
Add Event structured data (name, dates, location, offers) as JSON-LD in
index.astro, and a sitemap with@astrojs/sitemap.
Accessibility#
Built in:
- Skip link, labelled landmarks, one
<h1>per page and visible lime focus rings. - Day tabs follow the tabs pattern; filters and stars use
aria-pressed. - Speaker profiles use a native
<dialog>with focus return; the countdown hasrole="timer". - Track tags use text labels, not colour alone.
- Toasts are announced with
aria-live. - Motion (reveals, drifting gradients, counters, smooth scrolling) respects
prefers-reduced-motion.
When editing, keep gradient text large and on dark backgrounds for contrast, label new form fields, and
give portraits empty alt text only where the speaker's name is already next to them.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as schedule.html rather than
schedule/index.html (build.format: "file" in astro.config.mjs), and links point to /schedule
without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /schedule from
schedule.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/scheduleservesschedule.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/scheduletoschedule.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=/summit pnpm build. It becomes Astro's base; every link written with url() and every
link the script builds (speaker pages and portraits) 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#
- A new session has no star or never shows "On now". Copy the full inner markup of an existing
session (star button and
.live-badge), and give itdata-start/data-end. - A starred session disappears after reload. Its id must also exist in
sessionsinsrc/data/summit.ts. - A speaker pop-up doesn't open. The button's
data-speakermust match anidinspeakers. - A ticket card disappeared. Its
data-ticketid must match anidintickets. - Times are an hour or more off. Change the
+02:00offset ineventTime()(and the "today" offset next to it) to your event's offset. /scheduleshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Forms "send" but nothing arrives. Connect them as described in Forms.
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. The code, the SVG speaker portraits and all other included artwork are free for personal and
commercial use; the download includes the license text in LICENSE.txt. Space Grotesk and Inter are
licensed under the SIL Open Font License. Summit 2027, its speakers and sponsors are fictional.