Skip to content
All projects

WebLive

DevHorizon 26: Conference Website

A static, fully accessible conference site built from a design handoff in a single working session, scoring 100 on Lighthouse Accessibility, Best Practices, and SEO on every page.

Role
Solo frontend developer
Type
Web
Stack
  • Next.js 16 (App Router),
  • React 19
  • React Compiler
  • TypeScript
  • Tailwind
  • Biome
horizontal thumbnail

The problem

A conference website serves two very different people. Someone deciding whether to go wants to skim: who's speaking, what the tracks are, when it is. Someone who's already bought a ticket wants to plan: what's on which day, what clashes, and which talks they don't want to miss.

DevHorizon 26 is a fictional three-day conference for frontend engineers, running November 15 to 17, 2026, at Pier 70 in San Francisco. I started from a complete design handoff with desktop, tablet, and mobile mockups for the Home, Schedule, and Speakers pages, a speaker modal, hover states, and a style guide covering color, type, radius, and spacing.

It's an opinionated design. Thirteen colors, two typefaces, hard offset shadows instead of soft ones, and sessions drawn as tear-off tickets with a barcode stub. That's what makes it good, and also what made it tricky to build. Three things stood out.

The first was fidelity. A visual language this specific falls apart the moment someone reaches for a default Tailwind gray or a rounded corner that isn't in the style guide.

The second was behavior the mockups imply but never specify. The frames show filter pills and a star icon, but not what should happen when you share a filtered link, reload the page, or filter down to nothing.

The third was accessibility. Ticket layouts, vertical text, color-coded tracks, and a modal are all places where a pixel-perfect build can still shut out keyboard and screen reader users.

The approach

I built the site with Next.js 16 (App Router), React 19, and Tailwind CSS v4. Every page is prerendered to static HTML, and the interactive parts (schedule filtering, a personal "My schedule" list, and the speaker modal) run entirely on the client. There's no backend.

The guiding idea was simple: the design system should be the only system. I replaced Tailwind's default theme with the style guide's tokens, so the colors, radii, and type presets available in code are exactly the ones in the design file, and nothing else exists. On top of that sits a small set of components (session ticket, speaker card, button link, section heading) that every page is built from.

For planners, the schedule keeps its filters in the URL and remembers starred sessions in the browser. An attendee can filter to Day 2, Performance and Accessibility, send that link to a colleague, star three talks, and find them still starred next time they visit.

What it does

  • A filterable schedule. Filter by day, by one or more tracks, or by "My schedule". Filters live in the query string (/schedule?day=2&track=performance&mine=1), so links from the home page and footer land on the right view, and any filtered view can be shared.
  • My schedule. Every session has a star button that saves it to a personal list. Stars stay in sync between the Schedule page and the speaker modal, and even across open tabs.
  • Session tickets. Each session is a ticket with a color-coded track rail, a time stub with a barcode, and an expandable section for the abstract and room. The rail runs vertically on desktop and becomes a header strip on mobile.
  • A speaker directory. Twenty speaker cards, each opening a modal with the speaker's bio and their session ticket, which you can star right there.
  • Empty states that help. When filters match nothing, the page says why (for example, you haven't starred anything yet) and offers a one-click reset.
  • A living style guide. The /styleguide route renders every color, type preset, radius, and spacing token straight from the real CSS, so the build can be checked against the design file side by side. It's hidden from search engines.
  • Restrained motion. Pages slide in on navigation and the modal eases in and out. Both respect prefers-reduced-motion.

Architecture

The whole thing is a static front end.

mermaid
flowchart LR
  subgraph Build["Build time (next build)"]
    Data["lib/conference.ts<br/>speakers, sessions, tracks, venue"]
    Tokens["app/globals.css<br/>@theme tokens + text presets"]
    Pages["/ · /schedule · /speakers · /styleguide"]
    Data --> Pages
    Tokens --> Pages
  end

  Pages -->|static HTML| CDN["Vercel CDN"]
  CDN --> Browser

  subgraph Browser["Browser"]
    URL["URL query<br/>day, track, mine"]
    Store["localStorage<br/>starred session ids"]
    Board["ScheduleBoard"]
    Star["StarButton"]
    Modal["Speaker dialog"]
    URL <--> Board
    Store <--> Star
    Star --- Board
    Star --- Modal
  end

Server Components render the page shells, and Client Components are kept to the pieces that actually need interaction: the schedule board, the star button, the speaker directory, the ticket's details toggle, and the mobile menu. The React Compiler is enabled.

Styling is Tailwind v4 configured in CSS with @theme, plus custom @utility classes for the style guide's text presets and page gutter. The fonts, Chakra Petch and JetBrains Mono, are self-hosted through next/font.

All content lives in one TypeScript module. Sessions reference speakers by slug and are joined when the module loads, so a speaker's name and company are only ever written once. Filter state lives in the URL, starred sessions live in localStorage, and the site deploys to Vercel from main with every route prerendered.

Key decisions

Locking Tailwind to the style guide

Tailwind v4 lets you wipe a theme namespace with --color-*: initial. I did that for colors and radii, then declared only the style guide's tokens. Now a class like bg-gray-800 or rounded-lg simply produces nothing, so off-palette styling shows up immediately in review instead of quietly creeping in. I also matched the token names to the design file, so radius-12 in Figma is rounded-12 in code. There's no translation step between the two.

Filters in the URL, not component state

The home page's track cards and the footer's track links both needed to open the schedule already filtered. With filters in the query string, that's just a link (/schedule?track=tooling), with no shared store to wire up. As a bonus, attendees get shareable, bookmarkable views and working back and forward buttons for free. I used router.replace instead of push so toggling pills doesn't flood the history, and only non-default values get written, which keeps URLs short.

localStorage for "My schedule"

A personal agenda is useful without an account, and adding sign-in to a conference site would be a lot of cost for a small feature. Keeping starred session IDs in the browser meant the site could stay fully static. I read storage through a useSyncExternalStore hook, so every star button and the "My schedule" filter share one source of truth and update together, even across tabs via the storage event. The tradeoff is that your list doesn't follow you to another device, which was fine for this scope.

A native <dialog> for the speaker modal

The native element already handles focus trapping, closing on Escape, top-layer rendering, and making the background inert, all without a library. I only added what it's missing: closing on a backdrop click, an accessible name through aria-labelledby, and CSS transitions for opening and closing.

A typed data module instead of a CMS

The content is small and fixed for the event: 20 speakers and 20 sessions. A TypeScript module gives type checking on every entry, zero network requests, and full static prerendering. Because everything sits behind a single import, swapping in a CMS later wouldn't touch any components.

Challenges along the way

A static page that depends on the query string

The schedule reads its filters with useSearchParams, which isn't known until request time. But I wanted the page to stay static, with no per-request rendering and no blank flash or layout shift while the client took over.

My fix was to split the board in two: a pure ScheduleBoard that takes a query string, and a thin ScheduleBoardFromUrl wrapper that reads the real search params. The page renders the wrapper inside <Suspense> with a plain <ScheduleBoard /> as the fallback. So the prerendered HTML is the complete, unfiltered Day 1 schedule, and the filtered view swaps in as soon as the client reads the URL. /schedule builds as static content and still respects deep links like ?day=3&track=frontend.

Reading browser storage without hydration mismatches

Starred sessions only exist in the browser, so reading localStorage during render would make the server HTML disagree with the first client render. useSyncExternalStore solves this with a separate server snapshot: both the server and the hydration pass see "nothing starred", and React switches to the stored value right after. I also wrapped every storage read and write in try/catch, since storage can be blocked in private browsing, and a failed star should fail quietly rather than crash the page. The result is no hydration warnings and star state that updates everywhere at once.

A modal that animates out, not just in

A <dialog> normally vanishes the instant it closes, cutting off any exit animation. Locking the page's scroll behind it also tends to make content jump sideways when the scrollbar disappears.

I handled both in CSS. @starting-style defines the entry state, and transition-behavior: allow-discrete on display and overlay keeps the dialog rendered until its exit transition finishes. Scroll is locked with html:has(dialog[open]) { overflow: hidden }, and scrollbar-gutter: stable reserves the scrollbar's space so nothing shifts. Under reduced motion, the modal just fades. No JavaScript animation code, no layout shift.

Keeping a visual design accessible

Several details in the design work well visually but not for assistive technology. The track name is written vertically, "A11y" is an abbreviation, the barcode is pure decoration, and filtering changes the list with no announcement.

So the filter pills are real buttons with aria-pressed, and a visually hidden aria-live region announces how many sessions match and for which day. The "A11y" pill is hidden from screen readers and paired with the full word "Accessibility". Tickets include hidden "Track:" and "to" text, so a time range reads aloud as "11:00 to 12:00". Decorative pieces (the barcode, the // prefixes, the outlined "HORIZON" in the hero) are aria-hidden. Focus styles use a dashed green border that fits the design language instead of the browser default. The interface keeps the look of the mockups while working properly with a keyboard and screen reader.

Results

Every page and the speaker modal are implemented at mobile, tablet, and desktop sizes, including hover and focus states. A production build prerenders all four routes (/, /schedule, /speakers, /styleguide) as static content served from Vercel's CDN.

The codebase is about 2,000 lines of TypeScript and CSS, and the only runtime dependencies are Next.js, React, and React DOM. No UI, state, or animation libraries. It went from an empty Create Next App scaffold to a production deployment in a single working session.

On Lighthouse mobile with simulated slow 4G, every page scores 99 to 100 on Performance and 100 on Accessibility, Best Practices, and SEO, with zero layout shift and LCP around 2 seconds. Desktop scores 100 across the board.

What I learned

What worked, and I'd do again. Resetting the theme to the style guide's tokens turned "does this match the design?" from a judgment call into something the build enforces. Putting shareable state in the URL was one decision that paid off three times: deep links, sharing, and history navigation, with no extra code. And leaning on the platform (<dialog>, @starting-style, :has(), useSyncExternalStore) replaced what would otherwise have been three or four dependencies.

What I'd do next. I'd add automated checks: axe and Lighthouse in CI, plus a few Playwright tests for the filter and star flows, so the accessibility and performance numbers are measured on every change rather than claimed once. If the product needed it, I'd sync "My schedule" across devices through a lightweight account or an emailed link. If organizers had to edit content themselves, I'd move speakers and sessions into a CMS, which the single data module makes easy. And I'd give each speaker their own page, since a modal is great for browsing but a URL per speaker is better for sharing and search.

Discussion0 comments

No comments yet. Start the discussion.