Event 1: agenda and my schedule
An event page with its agenda: dates, venue and a countdown, the schedule by day with a filter per track, a star on each session to build your own schedule, a warning for overlaps, and a calendar file download.
// Ballmac UI: Event 1. https://ui.ballmac.com/blocks/event-1
"use client"
import * as React from "react"
import { CalendarArrowDown, CalendarDays, Clock, MapPin, Star, TriangleAlert } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { CoverArt } from "@/components/ballmac/cover-art"
import { cn } from "@/lib/utils"
type Event1Session = {
id: string
/** ISO date of the day it happens. */
day: string
/** "09:30", in the event's time zone. */
start: string
end: string
title: string
track: string
room?: string
speakers?: { name: string; role?: string }[]
description?: string
}
type Event1Props = Omit<React.ComponentProps<"section">, "title"> & {
name?: string
tagline?: string
/** ISO dates of the first and last day. */
dates?: [string, string]
venue?: string
/** Hours from UTC for the venue, so the schedule file has the right times. */
utcOffset?: number
sessions?: Event1Session[]
/** An ISO date for "now", used for the countdown. The block never reads the clock. */
now?: string
locale?: string
/** Sessions saved at first. */
defaultSaved?: string[]
onSavedChange?: (ids: string[]) => void
}
const sample: Event1Session[] = [
{ id: "s1", day: "2026-11-12", start: "09:00", end: "09:45", title: "Opening keynote: software that respects people", track: "Keynote", room: "Main hall", speakers: [{ name: "Mara Ellison", role: "CEO, Acme" }] },
{ id: "s2", day: "2026-11-12", start: "10:00", end: "10:45", title: "Design systems at 400 designers", track: "Design", room: "Room A", speakers: [{ name: "Noor Haddad", role: "Head of Design" }], description: "What broke, what held, and the three rules that survived growth." },
{ id: "s3", day: "2026-11-12", start: "10:15", end: "11:00", title: "Postgres in production: lessons from year five", track: "Engineering", room: "Room B", speakers: [{ name: "Priya Shah", role: "Platform lead" }] },
{ id: "s4", day: "2026-11-12", start: "11:15", end: "12:00", title: "Accessible by default", track: "Design", room: "Room A", speakers: [{ name: "Amara Okoye", role: "Product Designer" }, { name: "Jordan Lee", role: "Systems Lead" }] },
{ id: "s5", day: "2026-11-12", start: "13:30", end: "14:15", title: "Shipping with AI agents, safely", track: "Engineering", room: "Main hall", speakers: [{ name: "Theo Nakamura", role: "CTO" }], description: "Approvals, budgets and the logs you will wish you had." },
{ id: "s6", day: "2026-11-12", start: "14:30", end: "15:15", title: "Pricing pages that earn trust", track: "Business", room: "Room C", speakers: [{ name: "Sam Okafor", role: "Head of Support" }] },
{ id: "s7", day: "2026-11-13", start: "09:30", end: "10:15", title: "Remote teams, real friendships", track: "Business", room: "Main hall", speakers: [{ name: "Ana Souza", role: "Operations" }] },
{ id: "s8", day: "2026-11-13", start: "10:30", end: "11:15", title: "Fast front ends without a framework fight", track: "Engineering", room: "Room B", speakers: [{ name: "Yuki Tanaka", role: "Frontend Engineer" }] },
{ id: "s9", day: "2026-11-13", start: "10:30", end: "11:15", title: "Motion that helps, not distracts", track: "Design", room: "Room A", speakers: [{ name: "Jordan Lee", role: "Systems Lead" }] },
{ id: "s10", day: "2026-11-13", start: "12:00", end: "12:45", title: "Closing panel: what we got wrong", track: "Keynote", room: "Main hall", speakers: [{ name: "Mara Ellison" }, { name: "Theo Nakamura" }, { name: "Noor Haddad" }] },
]
const mins = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3, 5))
const trackTone = ["bg-chart-1/15", "bg-chart-2/15", "bg-chart-3/15", "bg-chart-4/15", "bg-chart-5/15"]
function ics(sessions: Event1Session[], name: string, utcOffset: number) {
const stamp = (day: string, t: string) => {
const ms = Date.parse(`${day}T${t}:00Z`) - utcOffset * 3_600_000
return new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "")
}
const esc = (s: string) => s.replace(/([,;\\])/g, "\\$1").replace(/\n/g, "\\n")
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", `PRODID:-//${esc(name)}//Schedule//EN`, "CALSCALE:GREGORIAN"]
for (const s of sessions) {
lines.push("BEGIN:VEVENT", `UID:${s.id}@${esc(name).replace(/\s+/g, "-").toLowerCase()}`, `DTSTAMP:${stamp(s.day, s.start)}`, `DTSTART:${stamp(s.day, s.start)}`, `DTEND:${stamp(s.day, s.end)}`, `SUMMARY:${esc(s.title)}`, ...(s.room ? [`LOCATION:${esc(s.room)}`] : []), ...(s.description ? [`DESCRIPTION:${esc(s.description)}`] : []), "END:VEVENT")
}
lines.push("END:VCALENDAR")
return lines.join("\r\n")
}
/**
* An event page with its agenda: the dates, venue and a countdown, then the schedule by day with a filter for each track, a star on every session
* to build your own schedule, a warning when two of your sessions overlap, and one press to download your schedule as a calendar file.
*/
function Event1({
name = "Acme Summit 2026",
tagline = "Two days of talks on building software people trust.",
dates = ["2026-11-12", "2026-11-13"],
venue = "Convento do Beato, Lisbon",
utcOffset = 0,
sessions = sample,
now = "2026-10-18T10:00:00Z",
locale = "en",
defaultSaved = [],
onSavedChange,
className,
...props
}: Event1Props) {
const id = React.useId()
const days = [...new Set(sessions.map((s) => s.day))].sort()
const tracks = [...new Set(sessions.map((s) => s.track))]
const [day, setDay] = React.useState(days[0] ?? dates[0])
const [track, setTrack] = React.useState<string | null>(null)
const [saved, setSaved] = React.useState<string[]>(defaultSaved)
const [announce, setAnnounce] = React.useState("")
const dayFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }), [locale])
const shortFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short", timeZone: "UTC" }), [locale])
const rangeFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }), [locale])
const until = Math.ceil((Date.parse(`${dates[0]}T00:00:00Z`) - Date.parse(now)) / 86_400_000)
const shown = sessions.filter((s) => s.day === day && (!track || s.track === track)).sort((a, b) => mins(a.start) - mins(b.start) || a.title.localeCompare(b.title))
const mine = sessions.filter((s) => saved.includes(s.id)).sort((a, b) => a.day.localeCompare(b.day) || mins(a.start) - mins(b.start))
const clash = (s: Event1Session) => mine.find((m) => m.id !== s.id && m.day === s.day && mins(m.start) < mins(s.end) && mins(s.start) < mins(m.end))
const toggle = (s: Event1Session) => {
const on = !saved.includes(s.id)
const next = on ? [...saved, s.id] : saved.filter((x) => x !== s.id)
setSaved(next)
onSavedChange?.(next)
setAnnounce(`${s.title} ${on ? "added to" : "removed from"} your schedule`)
}
const download = () => {
const url = URL.createObjectURL(new Blob([ics(mine, name, utcOffset)], { type: "text/calendar" }))
const a = document.createElement("a")
a.href = url
a.download = `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-schedule.ics`
a.click()
URL.revokeObjectURL(url)
}
return (
<section data-slot="event-1" aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-5xl px-4 py-10 sm:px-6", className)} {...props}>
<div className="relative isolate overflow-hidden rounded-3xl border">
<CoverArt seed={name} variant="mesh" className="absolute inset-0 -z-20 scale-110 saturate-150" />
<div aria-hidden="true" className="from-background/90 via-background/70 to-background/30 absolute inset-0 -z-10 bg-gradient-to-r rtl:bg-gradient-to-l" />
<div className="p-6 sm:p-10">
<p className="bg-background/70 inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium backdrop-blur">
<CalendarDays aria-hidden="true" className="size-4" />
{rangeFmt.format(Date.parse(`${dates[0]}T00:00:00Z`))}
{dates[1] !== dates[0] ? ` to ${rangeFmt.format(Date.parse(`${dates[1]}T00:00:00Z`))}` : ""}
</p>
<h1 id={`${id}-title`} className="mt-5 max-w-2xl text-4xl font-semibold tracking-[-0.045em] text-balance sm:text-6xl">
{name}
</h1>
<p className="text-muted-foreground mt-4 max-w-xl text-lg text-pretty">{tagline}</p>
<div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<p className="flex items-center gap-2">
<MapPin aria-hidden="true" className="size-4" />
{venue}
</p>
{until > 0 ? (
<p className="flex items-center gap-2 font-medium tabular-nums">
<Clock aria-hidden="true" className="size-4" />
{until} {until === 1 ? "day" : "days"} to go
</p>
) : null}
</div>
</div>
</div>
<div className="mt-10 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-w-0">
<div role="tablist" aria-label="Day" className="flex gap-2">
{days.map((d) => (
<button key={d} type="button" role="tab" aria-selected={day === d} id={`${id}-tab-${d}`} aria-controls={`${id}-panel`} tabIndex={day === d ? 0 : -1} onClick={() => setDay(d)} onKeyDown={(e) => {
const i = days.indexOf(d)
const n = e.key === "ArrowRight" ? days[(i + 1) % days.length] : e.key === "ArrowLeft" ? days[(i - 1 + days.length) % days.length] : null
if (n) { e.preventDefault(); setDay(n); document.getElementById(`${id}-tab-${n}`)?.focus() }
}} className={cn("focus-visible:ring-ring/50 rounded-xl border px-4 py-2.5 text-start outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none", day === d ? "bg-foreground text-background border-foreground" : "hover:bg-accent")}>
<span className="block text-xs opacity-70">Day {days.indexOf(d) + 1}</span>
<span className="block text-sm font-semibold">{shortFmt.format(Date.parse(`${d}T00:00:00Z`))}</span>
</button>
))}
</div>
<div role="group" aria-label="Filter by track" className="mt-5 flex flex-wrap gap-2">
{[null, ...tracks].map((t) => (
<button key={t ?? "all"} type="button" aria-pressed={track === t} onClick={() => setTrack(t)} className={cn("focus-visible:ring-ring/50 rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none", track === t ? "bg-foreground text-background border-foreground" : "hover:bg-accent")}>
{t ?? "All tracks"}
</button>
))}
</div>
<div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-tab-${day}`} className="mt-6">
<h2 className="mb-4 text-lg font-semibold tracking-tight">{dayFmt.format(Date.parse(`${day}T00:00:00Z`))}</h2>
{shown.length ? (
<ol className="grid gap-3">
{shown.map((s) => {
const on = saved.includes(s.id)
const c = on ? clash(s) : undefined
return (
<li key={s.id} className={cn("bg-card grid gap-x-5 gap-y-2 rounded-2xl border p-4 transition-colors sm:grid-cols-[6.5rem_minmax(0,1fr)_auto] sm:p-5 motion-reduce:transition-none", on && "border-foreground/40")}>
<p className="text-sm font-semibold tabular-nums">
{s.start}
<span className="text-muted-foreground font-normal"> to {s.end}</span>
</p>
<div className="min-w-0">
<h3 className="font-semibold tracking-tight text-pretty">{s.title}</h3>
{s.speakers?.length ? (
<p className="text-muted-foreground mt-1 text-sm">
{s.speakers.map((p) => p.name).join(", ")}
{s.speakers.length === 1 && s.speakers[0]!.role ? ` · ${s.speakers[0]!.role}` : ""}
</p>
) : null}
{s.description ? <p className="text-muted-foreground mt-2 text-sm text-pretty">{s.description}</p> : null}
<p className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<span className={cn("rounded-full border px-2.5 py-0.5 font-medium", trackTone[tracks.indexOf(s.track) % trackTone.length])}>{s.track}</span>
{s.room ? <span className="text-muted-foreground">{s.room}</span> : null}
</p>
{c ? (
<p role="status" className="text-destructive mt-3 flex items-start gap-1.5 text-sm">
<TriangleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
Overlaps with {c.title} ({c.start} to {c.end})
</p>
) : null}
</div>
<Button variant={on ? "secondary" : "outline"} size="sm" aria-pressed={on} onClick={() => toggle(s)} className="self-start">
<Star aria-hidden="true" className={cn(on && "fill-current")} />
{on ? "Saved" : "Save"} <span className="sr-only">{s.title}</span>
</Button>
</li>
)
})}
</ol>
) : (
<p className="text-muted-foreground rounded-2xl border border-dashed px-6 py-12 text-center text-sm">No sessions on this day for that track. Try All tracks.</p>
)}
</div>
</div>
<aside aria-labelledby={`${id}-mine`} className="bg-card rounded-2xl border p-5 lg:sticky lg:top-6">
<h2 id={`${id}-mine`} className="flex items-center gap-2 font-semibold tracking-tight">
<Star aria-hidden="true" className="size-4" />
My schedule <span className="text-muted-foreground font-normal tabular-nums">· {mine.length}</span>
</h2>
{mine.length ? (
<>
<ul className="mt-4 grid gap-3">
{mine.map((s) => (
<li key={s.id} className="text-sm">
<p className="text-muted-foreground text-xs tabular-nums">
{shortFmt.format(Date.parse(`${s.day}T00:00:00Z`))} · {s.start} to {s.end}
</p>
<p className="font-medium text-pretty">{s.title}</p>
</li>
))}
</ul>
<Button className="mt-5 w-full" onClick={download}>
<CalendarArrowDown aria-hidden="true" />
Add to my calendar
</Button>
<p className="text-muted-foreground mt-2 text-xs">Downloads a calendar file with the right times for {venue.split(",").pop()?.trim()}.</p>
</>
) : (
<p className="text-muted-foreground mt-3 text-sm text-pretty">Press Save on a session to build your own schedule. Overlaps are flagged for you.</p>
)}
</aside>
</div>
<p aria-live="polite" className="sr-only">
{announce}
</p>
</section>
)
}
export { Event1, type Event1Props, type Event1Session }Installation
$ pnpm dlx shadcn@latest add @ballmac/event-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/cover-artCopy the source into your project.
components/ballmac/blocks/event-1/event-1.tsx// Ballmac UI: Event 1. https://ui.ballmac.com/blocks/event-1 "use client" import * as React from "react" import { CalendarArrowDown, CalendarDays, Clock, MapPin, Star, TriangleAlert } from "lucide-react" import { Button } from "@/components/ballmac/button" import { CoverArt } from "@/components/ballmac/cover-art" import { cn } from "@/lib/utils" type Event1Session = { id: string /** ISO date of the day it happens. */ day: string /** "09:30", in the event's time zone. */ start: string end: string title: string track: string room?: string speakers?: { name: string; role?: string }[] description?: string } type Event1Props = Omit<React.ComponentProps<"section">, "title"> & { name?: string tagline?: string /** ISO dates of the first and last day. */ dates?: [string, string] venue?: string /** Hours from UTC for the venue, so the schedule file has the right times. */ utcOffset?: number sessions?: Event1Session[] /** An ISO date for "now", used for the countdown. The block never reads the clock. */ now?: string locale?: string /** Sessions saved at first. */ defaultSaved?: string[] onSavedChange?: (ids: string[]) => void } const sample: Event1Session[] = [ { id: "s1", day: "2026-11-12", start: "09:00", end: "09:45", title: "Opening keynote: software that respects people", track: "Keynote", room: "Main hall", speakers: [{ name: "Mara Ellison", role: "CEO, Acme" }] }, { id: "s2", day: "2026-11-12", start: "10:00", end: "10:45", title: "Design systems at 400 designers", track: "Design", room: "Room A", speakers: [{ name: "Noor Haddad", role: "Head of Design" }], description: "What broke, what held, and the three rules that survived growth." }, { id: "s3", day: "2026-11-12", start: "10:15", end: "11:00", title: "Postgres in production: lessons from year five", track: "Engineering", room: "Room B", speakers: [{ name: "Priya Shah", role: "Platform lead" }] }, { id: "s4", day: "2026-11-12", start: "11:15", end: "12:00", title: "Accessible by default", track: "Design", room: "Room A", speakers: [{ name: "Amara Okoye", role: "Product Designer" }, { name: "Jordan Lee", role: "Systems Lead" }] }, { id: "s5", day: "2026-11-12", start: "13:30", end: "14:15", title: "Shipping with AI agents, safely", track: "Engineering", room: "Main hall", speakers: [{ name: "Theo Nakamura", role: "CTO" }], description: "Approvals, budgets and the logs you will wish you had." }, { id: "s6", day: "2026-11-12", start: "14:30", end: "15:15", title: "Pricing pages that earn trust", track: "Business", room: "Room C", speakers: [{ name: "Sam Okafor", role: "Head of Support" }] }, { id: "s7", day: "2026-11-13", start: "09:30", end: "10:15", title: "Remote teams, real friendships", track: "Business", room: "Main hall", speakers: [{ name: "Ana Souza", role: "Operations" }] }, { id: "s8", day: "2026-11-13", start: "10:30", end: "11:15", title: "Fast front ends without a framework fight", track: "Engineering", room: "Room B", speakers: [{ name: "Yuki Tanaka", role: "Frontend Engineer" }] }, { id: "s9", day: "2026-11-13", start: "10:30", end: "11:15", title: "Motion that helps, not distracts", track: "Design", room: "Room A", speakers: [{ name: "Jordan Lee", role: "Systems Lead" }] }, { id: "s10", day: "2026-11-13", start: "12:00", end: "12:45", title: "Closing panel: what we got wrong", track: "Keynote", room: "Main hall", speakers: [{ name: "Mara Ellison" }, { name: "Theo Nakamura" }, { name: "Noor Haddad" }] }, ] const mins = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3, 5)) const trackTone = ["bg-chart-1/15", "bg-chart-2/15", "bg-chart-3/15", "bg-chart-4/15", "bg-chart-5/15"] function ics(sessions: Event1Session[], name: string, utcOffset: number) { const stamp = (day: string, t: string) => { const ms = Date.parse(`${day}T${t}:00Z`) - utcOffset * 3_600_000 return new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "") } const esc = (s: string) => s.replace(/([,;\\])/g, "\\$1").replace(/\n/g, "\\n") const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", `PRODID:-//${esc(name)}//Schedule//EN`, "CALSCALE:GREGORIAN"] for (const s of sessions) { lines.push("BEGIN:VEVENT", `UID:${s.id}@${esc(name).replace(/\s+/g, "-").toLowerCase()}`, `DTSTAMP:${stamp(s.day, s.start)}`, `DTSTART:${stamp(s.day, s.start)}`, `DTEND:${stamp(s.day, s.end)}`, `SUMMARY:${esc(s.title)}`, ...(s.room ? [`LOCATION:${esc(s.room)}`] : []), ...(s.description ? [`DESCRIPTION:${esc(s.description)}`] : []), "END:VEVENT") } lines.push("END:VCALENDAR") return lines.join("\r\n") } /** * An event page with its agenda: the dates, venue and a countdown, then the schedule by day with a filter for each track, a star on every session * to build your own schedule, a warning when two of your sessions overlap, and one press to download your schedule as a calendar file. */ function Event1({ name = "Acme Summit 2026", tagline = "Two days of talks on building software people trust.", dates = ["2026-11-12", "2026-11-13"], venue = "Convento do Beato, Lisbon", utcOffset = 0, sessions = sample, now = "2026-10-18T10:00:00Z", locale = "en", defaultSaved = [], onSavedChange, className, ...props }: Event1Props) { const id = React.useId() const days = [...new Set(sessions.map((s) => s.day))].sort() const tracks = [...new Set(sessions.map((s) => s.track))] const [day, setDay] = React.useState(days[0] ?? dates[0]) const [track, setTrack] = React.useState<string | null>(null) const [saved, setSaved] = React.useState<string[]>(defaultSaved) const [announce, setAnnounce] = React.useState("") const dayFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }), [locale]) const shortFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short", timeZone: "UTC" }), [locale]) const rangeFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }), [locale]) const until = Math.ceil((Date.parse(`${dates[0]}T00:00:00Z`) - Date.parse(now)) / 86_400_000) const shown = sessions.filter((s) => s.day === day && (!track || s.track === track)).sort((a, b) => mins(a.start) - mins(b.start) || a.title.localeCompare(b.title)) const mine = sessions.filter((s) => saved.includes(s.id)).sort((a, b) => a.day.localeCompare(b.day) || mins(a.start) - mins(b.start)) const clash = (s: Event1Session) => mine.find((m) => m.id !== s.id && m.day === s.day && mins(m.start) < mins(s.end) && mins(s.start) < mins(m.end)) const toggle = (s: Event1Session) => { const on = !saved.includes(s.id) const next = on ? [...saved, s.id] : saved.filter((x) => x !== s.id) setSaved(next) onSavedChange?.(next) setAnnounce(`${s.title} ${on ? "added to" : "removed from"} your schedule`) } const download = () => { const url = URL.createObjectURL(new Blob([ics(mine, name, utcOffset)], { type: "text/calendar" })) const a = document.createElement("a") a.href = url a.download = `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-schedule.ics` a.click() URL.revokeObjectURL(url) } return ( <section data-slot="event-1" aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-5xl px-4 py-10 sm:px-6", className)} {...props}> <div className="relative isolate overflow-hidden rounded-3xl border"> <CoverArt seed={name} variant="mesh" className="absolute inset-0 -z-20 scale-110 saturate-150" /> <div aria-hidden="true" className="from-background/90 via-background/70 to-background/30 absolute inset-0 -z-10 bg-gradient-to-r rtl:bg-gradient-to-l" /> <div className="p-6 sm:p-10"> <p className="bg-background/70 inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium backdrop-blur"> <CalendarDays aria-hidden="true" className="size-4" /> {rangeFmt.format(Date.parse(`${dates[0]}T00:00:00Z`))} {dates[1] !== dates[0] ? ` to ${rangeFmt.format(Date.parse(`${dates[1]}T00:00:00Z`))}` : ""} </p> <h1 id={`${id}-title`} className="mt-5 max-w-2xl text-4xl font-semibold tracking-[-0.045em] text-balance sm:text-6xl"> {name} </h1> <p className="text-muted-foreground mt-4 max-w-xl text-lg text-pretty">{tagline}</p> <div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm"> <p className="flex items-center gap-2"> <MapPin aria-hidden="true" className="size-4" /> {venue} </p> {until > 0 ? ( <p className="flex items-center gap-2 font-medium tabular-nums"> <Clock aria-hidden="true" className="size-4" /> {until} {until === 1 ? "day" : "days"} to go </p> ) : null} </div> </div> </div> <div className="mt-10 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]"> <div className="min-w-0"> <div role="tablist" aria-label="Day" className="flex gap-2"> {days.map((d) => ( <button key={d} type="button" role="tab" aria-selected={day === d} id={`${id}-tab-${d}`} aria-controls={`${id}-panel`} tabIndex={day === d ? 0 : -1} onClick={() => setDay(d)} onKeyDown={(e) => { const i = days.indexOf(d) const n = e.key === "ArrowRight" ? days[(i + 1) % days.length] : e.key === "ArrowLeft" ? days[(i - 1 + days.length) % days.length] : null if (n) { e.preventDefault(); setDay(n); document.getElementById(`${id}-tab-${n}`)?.focus() } }} className={cn("focus-visible:ring-ring/50 rounded-xl border px-4 py-2.5 text-start outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none", day === d ? "bg-foreground text-background border-foreground" : "hover:bg-accent")}> <span className="block text-xs opacity-70">Day {days.indexOf(d) + 1}</span> <span className="block text-sm font-semibold">{shortFmt.format(Date.parse(`${d}T00:00:00Z`))}</span> </button> ))} </div> <div role="group" aria-label="Filter by track" className="mt-5 flex flex-wrap gap-2"> {[null, ...tracks].map((t) => ( <button key={t ?? "all"} type="button" aria-pressed={track === t} onClick={() => setTrack(t)} className={cn("focus-visible:ring-ring/50 rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none", track === t ? "bg-foreground text-background border-foreground" : "hover:bg-accent")}> {t ?? "All tracks"} </button> ))} </div> <div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-tab-${day}`} className="mt-6"> <h2 className="mb-4 text-lg font-semibold tracking-tight">{dayFmt.format(Date.parse(`${day}T00:00:00Z`))}</h2> {shown.length ? ( <ol className="grid gap-3"> {shown.map((s) => { const on = saved.includes(s.id) const c = on ? clash(s) : undefined return ( <li key={s.id} className={cn("bg-card grid gap-x-5 gap-y-2 rounded-2xl border p-4 transition-colors sm:grid-cols-[6.5rem_minmax(0,1fr)_auto] sm:p-5 motion-reduce:transition-none", on && "border-foreground/40")}> <p className="text-sm font-semibold tabular-nums"> {s.start} <span className="text-muted-foreground font-normal"> to {s.end}</span> </p> <div className="min-w-0"> <h3 className="font-semibold tracking-tight text-pretty">{s.title}</h3> {s.speakers?.length ? ( <p className="text-muted-foreground mt-1 text-sm"> {s.speakers.map((p) => p.name).join(", ")} {s.speakers.length === 1 && s.speakers[0]!.role ? ` · ${s.speakers[0]!.role}` : ""} </p> ) : null} {s.description ? <p className="text-muted-foreground mt-2 text-sm text-pretty">{s.description}</p> : null} <p className="mt-3 flex flex-wrap items-center gap-2 text-xs"> <span className={cn("rounded-full border px-2.5 py-0.5 font-medium", trackTone[tracks.indexOf(s.track) % trackTone.length])}>{s.track}</span> {s.room ? <span className="text-muted-foreground">{s.room}</span> : null} </p> {c ? ( <p role="status" className="text-destructive mt-3 flex items-start gap-1.5 text-sm"> <TriangleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> Overlaps with {c.title} ({c.start} to {c.end}) </p> ) : null} </div> <Button variant={on ? "secondary" : "outline"} size="sm" aria-pressed={on} onClick={() => toggle(s)} className="self-start"> <Star aria-hidden="true" className={cn(on && "fill-current")} /> {on ? "Saved" : "Save"} <span className="sr-only">{s.title}</span> </Button> </li> ) })} </ol> ) : ( <p className="text-muted-foreground rounded-2xl border border-dashed px-6 py-12 text-center text-sm">No sessions on this day for that track. Try All tracks.</p> )} </div> </div> <aside aria-labelledby={`${id}-mine`} className="bg-card rounded-2xl border p-5 lg:sticky lg:top-6"> <h2 id={`${id}-mine`} className="flex items-center gap-2 font-semibold tracking-tight"> <Star aria-hidden="true" className="size-4" /> My schedule <span className="text-muted-foreground font-normal tabular-nums">· {mine.length}</span> </h2> {mine.length ? ( <> <ul className="mt-4 grid gap-3"> {mine.map((s) => ( <li key={s.id} className="text-sm"> <p className="text-muted-foreground text-xs tabular-nums"> {shortFmt.format(Date.parse(`${s.day}T00:00:00Z`))} · {s.start} to {s.end} </p> <p className="font-medium text-pretty">{s.title}</p> </li> ))} </ul> <Button className="mt-5 w-full" onClick={download}> <CalendarArrowDown aria-hidden="true" /> Add to my calendar </Button> <p className="text-muted-foreground mt-2 text-xs">Downloads a calendar file with the right times for {venue.split(",").pop()?.trim()}.</p> </> ) : ( <p className="text-muted-foreground mt-3 text-sm text-pretty">Press Save on a session to build your own schedule. Overlaps are flagged for you.</p> )} </aside> </div> <p aria-live="polite" className="sr-only"> {announce} </p> </section> ) } export { Event1, type Event1Props, type Event1Session }Update the import paths to match your project setup.
Usage
"use client"
import { Event1 } from "@/components/ballmac/blocks/event-1/event-1"
export default function Event1Demo() {
return <Event1 />
}States
This block has no states. Static content: nothing to load, submit or fail.
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 2 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
25 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Not applicable: Loading, empty and error
Static content: nothing to load, submit or fail.
Passed: Automated tests
Covered by event-1.test.tsx.
For information: Footprint
4.9 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
An event agenda. <Event1 name tagline dates={['2026-11-12', '2026-11-13']} venue utcOffset={1} sessions={[{ id, day: '2026-11-12', start: '09:00', end: '09:45', title, track, room, speakers: [{ name, role }], description }]} now='2026-10-18T10:00:00Z' defaultSaved={['s1']} onSavedChange />. Days and tracks come from the sessions; overlaps among saved sessions are found for you; 'Add to my calendar' builds an .ics file in the browser using utcOffset. Pass now yourself; the block never reads the clock. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Event 1: agenda and my schedule (@ballmac/event-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A conference, meetup or webinar series page with a schedule
- An internal offsite agenda
Not for
- Ticket purchase (use a pricing block)
- A month calendar of many events (use calendar-1)
Registry JSON: https://ui.ballmac.com/r/event-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.