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 Bellevue, WA (April 14–16, 2027) with 12 speakers and 35 sessions. Speakers, sessions, days and prices live in typed data files. This is the Nuxt version (1.0.0): Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site.
Live demo: freeframe.dev/templates/summit
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm).
pnpm install
pnpm dev # http://localhost:3000Add ?now=2027-04-15T10:45 to any page's URL to see it as if the event were live (see
Live schedule state).
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm generate |
Static site in .output/public/ |
pnpm preview |
Serve the last build locally |
pnpm build |
Node.js server build in .output/ (pages still prerendered) |
pnpm typecheck |
nuxt prepare, then vue-tsc |
pnpm lint |
ESLint (the flat config from @nuxt/eslint) |
pnpm build:demo |
Build for /demos/nuxt/summit into demo/ (the freeframe.dev demo; you can delete it) |
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with live countdown, topics marquee, highlights, featured speakers, count-up stats, "A day at Summit", partner wall, testimonial, venue preview, newsletter sign-up |
/speakers |
app/pages/speakers.vue |
Track filter and pop-up speaker profiles (SpeakerGrid.vue) |
/speaker?id=<id> |
app/pages/speaker.vue |
One speaker: portrait, bio, links, session cards, "More speakers" |
/schedule |
app/pages/schedule.vue |
Day tabs, track filter, stars and "My schedule", "On now" / "Up next" during the event (ScheduleBoard.vue) |
/tickets |
app/pages/tickets.vue |
Four passes with early-bird prices, steppers, order summary with team discount, checkout (TicketShop.vue), comparison table, FAQ |
/venue |
app/pages/venue.vue |
Venue facts, illustrated map, travel, partner hotels, accessibility |
/sponsors |
app/pages/sponsors.vue |
Sponsor tiers, packages, sponsorship inquiry form |
| 404 | app/error.vue |
Not-found page, generated as 404.html |
Project structure#
summit/
├─ app/
│ ├─ app.vue <NuxtLayout><NuxtPage /></NuxtLayout>
│ ├─ error.vue the 404 page
│ ├─ router.options.ts writes query strings like the HTML version (?id=mei-tanaka)
│ ├─ layouts/default.vue skip link, header, page, footer, toast, back-to-top button; sets the demo-date shift
│ ├─ pages/ one .vue file per page (see above)
│ ├─ components/
│ │ ├─ SiteHeader.vue logo, main menu, tickets button, full-screen phone menu
│ │ ├─ SiteFooter.vue footer links, contact and copyright year
│ │ ├─ PageHero.vue the title band of the inner pages
│ │ ├─ EventCountdown.vue days, hours, minutes and seconds to the doors opening
│ │ ├─ CountUp.vue numbers that count up when scrolled into view
│ │ ├─ SpeakerGrid.vue track filter, speaker tiles and the speaker <dialog>
│ │ ├─ SpeakerTile.vue a speaker's portrait, track, name and role (link or button)
│ │ ├─ SessionCard.vue one session: time, room, tags, title, speakers, star, live badge
│ │ ├─ ScheduleBoard.vue day tabs, track filter, stars, My schedule, live highlights
│ │ ├─ TicketShop.vue ticket cards, order summary, checkout and confirmation
│ │ ├─ NewsletterForm.vue home page sign-up
│ │ ├─ SponsorForm.vue sponsorship inquiry
│ │ ├─ FieldError.vue a field's inline error message
│ │ ├─ TrackTag.vue, SponsorLogo.vue, AppIcon.vue, DatedText.vue
│ │ ├─ VenueArt.vue, VenueMap.vue the venue illustration and map (inline SVG)
│ │ └─ BackToTop.vue floating "Back to top" button
│ ├─ composables/
│ │ ├─ useEdition.ts useEdition(), useEventNow(), useEarlyBird(): dates, years and the clock
│ │ ├─ useFormValidation.ts inline validation for the three forms
│ │ ├─ useCurrentPage.ts aria-current for menu links
│ │ └─ usePageSeo.ts title, description, canonical and Open Graph tags
│ ├─ data/
│ │ ├─ summit.ts tracks, days, speakers, sessions, ticket prices, early-bird date, team discount
│ │ ├─ tickets.ts ticket cards, comparison table, FAQ, checkout countries
│ │ ├─ sponsors.ts sponsor tiers, home page partners, sponsorship packages
│ │ ├─ venue.ts venue facts, travel, hotels, accessibility list
│ │ ├─ home.ts marquee topics, featured speakers, home agenda, countdown target
│ │ ├─ nav.ts header, phone menu and footer links
│ │ └─ icons.ts the line icons
│ ├─ utils/ event times, demo dates, safeStorage, toast, dollars() and makeRef()
│ ├─ plugins/ scroll reveals, the URL hash, reproducible builds
│ └─ assets/
│ ├─ css/main.css Tailwind theme (colors, fonts) and component classes
│ └─ fonts/ Inter and Space Grotesk (woff2) with their OFL license texts
├─ public/img/ speakers/<id>.svg portraits, favicon, og-image.png (and its SVG source)
├─ nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.mdCustomizing#
Brand and colors#
The palette and fonts are tokens in the @theme block of app/assets/css/main.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 |
Change the three gradient colors to restyle the whole event; pnpm dev picks it up instantly:
@theme {
--color-violet: #2563eb;
--color-magenta: #06b6d4;
--color-flame: #22c55e;
}Track colors (.tag-design, .tag-engineering, .tag-ai, ...) are component classes further down in
main.css. If you change --color-night, also change the theme-color meta tag in nuxt.config.ts.
Typography#
Headings use Space Grotesk (--font-display) and text uses Inter (--font-sans). Both are self-hosted:
the woff2 files are in app/assets/fonts/ and declared with @font-face at the top of main.css. To use other
fonts, put their files in app/assets/fonts/, change the @font-face rules and the two tokens.
Content#
Page text is in each app/pages/*.vue file. Event data lives in app/data/summit.ts, which is typed, so
pnpm typecheck 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, Apr 14", title: "Workshop day" } (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) |
EARLY_BIRD_ENDS |
When early-bird pricing ends: "2026-12-31T23:59:59-08:00" |
KEEP_DEMO_CURRENT |
true in the demo: once the edition is over, every date moves on a year (see Demo dates) |
TEAM_DISCOUNT |
{ minTickets: 5, percent: 15 } |
TICKET_PRICES |
{ id, name, price, early } per ticket (TicketPrice) |
Everything is rendered from these: the speaker tiles and dialog, the speaker pages, every schedule (the full
schedule, a speaker's sessions and the home page agenda in AGENDA in app/data/home.ts), and the ticket
prices. Adding a session is one entry in SESSIONS; it appears on its day, with a star, on its speakers'
pages and in their dialogs:
{ 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" },start and end are 24-hour local event times and show on a 12-hour clock ("4:00 – 4:40 PM"). kind is
talk, keynote, workshop, panel (each with its tag), break or social (no star, dashed card). Keep
session ids stable: starred sessions are saved by id.
Adding a speaker is an entry in SPEAKERS plus a square portrait at public/img/speakers/<id>.svg. The home
page shows the speakers listed in FEATURED_SPEAKERS in app/data/home.ts.
Ticket cards (name, description, features, "Most popular") are in TICKETS in app/data/tickets.ts, with the
comparison table (COMPARE), the FAQ (TICKETS_FAQ) and the checkout's COUNTRIES. Sponsors and packages are in
app/data/sponsors.ts; hotels, travel and the accessibility list in app/data/venue.ts.
Demo dates#
The demo is written for April 14–16, 2027. While KEEP_DEMO_CURRENT in app/data/summit.ts is true and that
edition is over (the event-local date is after the last of DAYS), every date moves to the same week of the next
year, by whole weeks so weekdays never change (Wed, April 14, 2027 becomes Wed, April 12, 2028), so the countdown,
ticket prices, schedule and "live" state always point at an upcoming conference. The edition year follows.
The shift is worked out in app/utils/edition.ts and set by app/layouts/default.vue once the page has mounted
(the generated HTML always shows the written dates). Components read it with useEdition(), which gives the
edition's year, dates ("April 14–16, 2027"), yy ("’27"), yyPlain ("27"), label(day), date(iso, format),
iso() for event times, text() for text containing the year, and earlyBirdEnds:
<p>© <span data-edition="year">{{ ed.year }}</span> Summit Conference LLC.</p>
<span data-date="2027-03-01" data-date-format="dm">{{ ed.date("2027-03-01", "dm") }}</span>Formats are dmy ("April 1, 2027", the default), dm ("March 1") and dmon ("Dec 31"). In TICKETS_FAQ, write a
date as [2027-02-01] and DatedText.vue shows it the same way. Before the edition is over nothing changes, so a
preview such as ?now=2027-04-15T10:45 still shows the live schedule. Set KEEP_DEMO_CURRENT to false once you
enter your real dates. The "Early-bird tickets on sale" pill and the first sentence of the tickets intro disappear
once early-bird pricing has ended (useEarlyBird()), whatever the setting.
Time zone#
Event times are Pacific daylight time (UTC-7 in April). eventTime() in app/utils/event.ts appends
EVENT_OFFSET (-07:00) to every time, and eventDate() uses the same offset for the schedule's "today". For an
event elsewhere, change both (for example -04:00 and -4 * 3600 * 1000 for New York in summer), plus the note at
the top of app/data/summit.ts and the schedule intro.
Images and artwork#
Speaker portraits are illustrated SVGs in public/img/speakers/<speaker-id>.svg (square). Replace them with
square photos using the same names, or change the .svg in SpeakerTile.vue, SessionCard.vue,
SpeakerGrid.vue and speaker.vue if you use .jpg. Files in public/ are written with the base path:
<img :src="`${app.baseURL}img/speakers/${speaker.id}.svg`" alt="" width="400" height="400">The venue illustration (VenueArt.vue) and map (VenueMap.vue) are inline SVG; sponsor logos are placeholder
wordmarks (SponsorLogo.vue, a shape from LogoShape plus the name). 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#
Menu links are in app/data/nav.ts: NAV (header), MOBILE_NAV (the phone menu, which adds Home and Tickets),
FOOTER_EVENT and FOOTER_INFO. SiteHeader.vue marks the current section with aria-current="page" through
useCurrentPage() (a speaker's page counts as Speakers). To add a page, create app/pages/workshops.vue:
<script setup lang="ts">
usePageSeo({ title: "Workshops — Summit 2027", description: "Hands-on workshops on April 14." });
</script>
<template>
<main id="main">
<PageHero eyebrow="Workshops">Hands-on <span class="gradient-text">workshops</span><template #intro>Six small groups.</template></PageHero>
</main>
</template>Then add "/workshops" to routes in nuxt.config.ts (every page is listed there to be prerendered) and the link
to NAV. Each page has its own <main id="main">, the skip link's target. Internal links are <NuxtLink>s; links
that must never be marked as the current page pass :aria-current="undefined".
Features#
Every feature reads its content from app/data/ and keeps the HTML version's data-* attributes, so CSS and
tests can find the same elements.
Countdown#
EventCountdown.vue (on the home page, with DOORS_OPEN from app/data/home.ts) shows days, hours, minutes and
seconds until the doors open, ticking every second and stopping at zero. It has role="timer". The generated HTML
shows zeros until the browser fills it in.
Speakers filter and pop-up profiles#
On /speakers, the track buttons (aria-pressed) show the speakers of one track and update the count, which is
announced politely. Clicking a speaker opens a native <dialog> with their portrait, role, bio, every session they
appear in and a Full profile link. Esc, the close button or a click outside the box closes it, and focus
returns to the speaker you opened.
Speaker pages#
/speaker?id=<speaker-id> (any letter case: ?id=Mei-Tanaka works) shows that speaker, with the same reveal
animation, and puts their name in the page title. The generated speaker.html is written for the first speaker in
SPEAKERS; the id is read once the page has loaded, and a missing or unknown id (or one like constructor) keeps
the first speaker. "More speakers" lists four others. Following a link to another speaker shows them from the top
of the page.
Schedule, tracks and My schedule#
/schedule has a tablist of days plus My schedule, with Left/Right, Home and End keys. The chosen
day is kept in the URL hash (/schedule#fri), so a reload or a shared link opens it; during the event the current
day (in event time) opens automatically.
- Track buttons show one track; breaks and socials (the
generaltrack) stay visible so each day still reads as a timeline. A message appears when a day has nothing in that track. - Star buttons (
aria-pressed) add sessions to My schedule, grouped by day, with a count on the tab and a toast. Their label says what a press does ("Add “…” to my schedule", or "Remove “…” from my schedule" once starred). 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 now" note. Preview it any time with ?now=:
/schedule?now=2027-04-15T10:45. The same parameter drives the countdown, early-bird pricing and the demo dates
(eventNow() in app/utils/event.ts).
Tickets and order summary#
Each ticket card on /tickets has −/+ steppers (0–20 each). Until EARLY_BIRD_ENDS the early price is used and the
full price is shown struck through with an "Early bird" badge; afterwards they go. The summary lists each line, the
subtotal, the team discount (15% from five tickets, from TEAM_DISCOUNT), a hint showing how many more tickets
unlock it, and the total. Continue to checkout opens the checkout form below. Prices come from TICKET_PRICES,
matched to the cards in TICKETS by id:
export const TICKET_PRICES: TicketPrice[] = [
{id:"workshop",name:"Workshop day",price:349,early:299},
{id:"conference",name:"Conference pass",price:799,early:599},
// ...
];When early equals price the struck-through price and badge are left out. A card without a price is not shown.
Update the comparison table (COMPARE) when you change what a pass includes.
Scroll reveals and count-up stats#
Elements with reveal fade in when scrolled into view (app/plugins/reveal.client.ts), together with everything
above them. CountUp.vue counts its number up once when visible. The gradient blobs drift and the topics marquee
scrolls (it pauses on hover). All motion stops with prefers-reduced-motion, and content stays visible without
JavaScript (they are only hidden once a script in nuxt.config.ts has added the js class to <html>, before the page paints).
Mobile menu#
Below 1024px the menu button opens the full-screen #mobile-menu: it locks page scrolling, makes the header solid,
moves focus to the first link, updates aria-expanded and its label, and closes with Esc (focus returns to the
button), on a link, or when the window reaches the lg breakpoint.
Back to top button#
BackToTop.vue (added to the end of <body> by the layout) appears after you scroll down about one screen. It
scrolls back up (instantly with reduced motion), moves keyboard focus to #main and lifts above the toast. 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 <BackToTop /> from the layout and the .back-to-top rules at the end of main.css.
Forms#
Form (data-form) |
Component | Fields |
|---|---|---|
newsletter |
NewsletterForm.vue (home) |
email |
checkout |
TicketShop.vue (tickets) |
first, last, email, company (optional), country, payment (card or invoice), terms |
sponsor |
SponsorForm.vue (sponsors) |
company, name, email, tier, message (at least 20 characters) |
useFormValidation() checks required fields, email format, minimum lengths and required checkboxes with the
browser's own rules. Errors appear under each field (<FieldError>, id="<form>-<field>-error"), fields get
aria-invalid and aria-describedby, and the first problem is focused. A filled-in field is checked when you leave
it, and an error updates as you type. On success the newsletter and sponsor forms show their message (the sponsor
one with a reference like SU-7K2Q from makeRef()), and the ticket page shows an "Order placed" confirmation with
the reference and order total.
The forms don't send anything until you connect them. Each form's submit function has a "To send for real"
comment. For example, in SponsorForm.vue, make submit async and post the data to a form service such as
Formspree before showing the message:
async function submit(e: Event) {
const form = e.target as HTMLFormElement;
if (!v.validate(form)) return;
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
reference.value = makeRef("SU");
done.value = true;
form.reset();
}To run your own server code instead, add a server route
such as server/api/sponsor.post.ts, deploy with pnpm build (a Node.js server) and post to /api/sponsor.
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 in TicketShop.vue with their embed, or send the
quantities (qty) to their checkout from toCheckout(). The confirmation texts (deck within two business days, a
payment link by email) are examples; change them to match what really happens.
Saved data (localStorage)#
| Key | Shape | Purpose |
|---|---|---|
summit-2027-stars |
Array of session ids, e.g. ["s2", "s14"] |
Sessions starred for My schedule |
ScheduleBoard.vue reads it once the page has loaded, through safeStorage (app/utils/safeStorage.ts). Anything
that isn't an array of known, unique session ids 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
ScheduleBoard.vue (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 calls
usePageSeo({ title, description }), which sets<title>, the meta description,og:title,og:description,og:type,og:site_name,og:image, asummary_large_imageTwitter card and a canonical link. The year in the title follows the demo dates. -
The canonical URL,
og:urland the absoluteog:imageURL start withruntimeConfig.public.siteUrlinnuxt.config.ts, which readsSITE_URL(defaulthttps://summit.example). Set it when you build:shSITE_URL=https://example.com pnpm generate -
/speakerputs the speaker from?id=in the title in the browser. If speaker pages matter for search, add a page per speaker (app/pages/speakers/[id].vue) and list each route inroutesinnuxt.config.ts. -
The 404 page passes
noindex:<meta name="robots" content="noindex">and no canonical URL. -
Consider Event structured data (name, dates, location, offers) as JSON-LD with
useHead()on the home page, and a sitemap.
Accessibility#
Built in:
- Skip link, labeled landmarks, one
<h1>per page and visible lime focus rings. - Day tabs follow the tabs pattern (roving
tabindex, arrow keys); filters and stars usearia-pressed. - Speaker profiles use a native
<dialog>with focus return; the countdown hasrole="timer". - Track tags use text labels, not color alone; decorative icons are hidden from screen readers.
- The toast and the speaker count are announced with
aria-live; form errors are linked witharia-describedby. - Motion (reveals, drifting gradients, the marquee, 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 generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (schedule.html,
linked as /schedule), plus 404.html from app/error.vue. Next to each page is a folder with its
_payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify,
Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /schedule to schedule.html on their own need
that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/summit pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still
prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint
load; replace it with typescript@7 once they support it.
Troubleshooting#
- A starred session disappears after reload. Its id must still exist in
SESSIONS; unknown ids are dropped. - A new page shows the 404 page after
pnpm generate. Add its route toroutesinnuxt.config.ts. - A ticket card disappeared. Its
idinTICKETSmust match anidinTICKET_PRICES. - Times are an hour or more off. Change
EVENT_OFFSETand the offset ineventDate()inapp/utils/event.tsto your event's offset. - The dates moved to next year.
KEEP_DEMO_CURRENTistrueand the edition inDAYSis over; set it tofalse(or enter your next dates). /scheduleshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles; add that rewrite (for example Vercel's"cleanUrls": true).- Forms "send" but nothing arrives. Connect them as described in Forms.
pnpm lintfails right after cloning. It needs.nuxt/, whichpnpm installcreates (nuxt prepare).
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and
at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Space Grotesk and
Inter are licensed under the SIL Open Font License. Summit 2027, its speakers and sponsors are fictional.