02examples
Conference agenda
A public, read-only programme in 24-hour time with tracks as locations and a details sheet on click.
Two days of a conference programme, this week's Wednesday and Thursday. "Programme" is the agenda list; "Timetable" is the day grid, where parallel tracks sit side by side. Click any session for its abstract and speakers. Nothing can be dragged: every session is read-only.
liveConference agendareadOnlyhour12localecomponents
loading
"use client";
import { useEffect, useState } from "react";
import {
EVENT_COLORS,
Kloq,
downloadICS,
formatRange,
initialsFor,
parseInstant,
stepAnchor,
useIsClient,
type EventPanelProps,
type KloqView,
} from "kloq";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CONFERENCE, conferenceDays, programme } from "./data";
const CLOCK = { locale: "en-GB", hour12: false };
const VIEWS: { id: KloqView; label: string }[] = [
{ id: "agenda", label: "Programme" },
{ id: "day", label: "Timetable" },
];
// the details sheet takes the event panel's slot: it opens from every view and replaces the editor
const SLOTS = { EventPanel: SessionSheet };
export default function ConferenceAgendaExample() {
const [view, setView] = useState<KloqView>("agenda");
const [date, setDate] = useState(() => conferenceDays()[0]);
const [events] = useState(() => programme());
const isClient = useIsClient();
return (
<div className="flex h-[34rem] flex-col">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-2.5">
<div className="flex items-baseline gap-3">
<span className="font-heading text-sm font-semibold tracking-tight">{CONFERENCE.name}</span>
<span className="font-mono text-xs text-muted-foreground">
{CONFERENCE.city} · {CONFERENCE.days} days
</span>
</div>
<div className="flex items-center gap-3">
{view === "day" && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-xs" aria-label="Previous day" onClick={() => setDate((d) => stepAnchor("day", d, -1))}>‹</Button>
<span className="min-w-28 text-center font-mono text-xs text-muted-foreground tabular-nums">
{isClient ? date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }) : " "}
</span>
<Button variant="ghost" size="icon-xs" aria-label="Next day" onClick={() => setDate((d) => stepAnchor("day", d, 1))}>›</Button>
</div>
)}
<div role="group" aria-label="View" className="inline-flex rounded-lg border bg-background p-0.5">
{VIEWS.map((v) => (
<button
key={String(v.id)}
type="button"
aria-pressed={view === v.id}
onClick={() => setView(v.id)}
className={cn(
"rounded-md px-2.5 py-1 font-mono text-xs transition-colors",
view === v.id ? "bg-foreground text-background" : "text-muted-foreground hover:text-foreground",
)}
>
{v.label}
</button>
))}
</div>
</div>
</div>
<Kloq
className="relative"
view={view}
onViewChange={setView}
date={date}
onDateChange={setDate}
weekStartsOn={1}
locale="en-GB"
hour12={false}
dayStartHour={8}
dayEndHour={19}
workingHours={false}
events={events}
components={SLOTS}
persistence={false}
designMode={false}
storage={false}
/>
</div>
);
}
function SessionSheet({ event, onClose }: EventPanelProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const session = event.source;
const day = new Date(parseInstant(event.start)).toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "long",
});
return (
<aside
aria-label="Session details"
className="absolute inset-y-0 right-0 z-10 flex w-full max-w-sm flex-col gap-4 overflow-y-auto border-l bg-card p-5 shadow-sm"
>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 font-mono text-xs text-muted-foreground">
<span aria-hidden className="size-2 rounded-full" style={{ backgroundColor: EVENT_COLORS[event.color].solid }} />
{session.location}
</p>
<Button variant="ghost" size="xs" onClick={onClose}>Close</Button>
</div>
<h3 className="font-heading text-lg font-semibold tracking-tight text-balance">{session.title}</h3>
<p className="font-mono text-xs text-muted-foreground tabular-nums">
{day} · {formatRange(event.startMin, event.endMin, CLOCK)}
</p>
{session.description && <p className="text-sm leading-relaxed text-pretty">{session.description}</p>}
{session.attendees?.length ? (
<ul className="flex flex-col gap-2 border-t pt-4">
{session.attendees.map((a) => (
<li key={a.email} className="flex items-center gap-2.5 text-sm">
<span className="grid size-6 place-items-center rounded-full border font-mono text-[10px] text-muted-foreground">
{initialsFor(a)}
</span>
{a.name}
</li>
))}
</ul>
) : null}
<Button variant="outline" size="sm" className="mt-auto self-start" onClick={() => downloadICS(session)}>
Add to my calendar
</Button>
</aside>
);
}
import { instantAt, resolveView, type CalEvent, type EventColor } from "kloq";
export const CONFERENCE = { name: "KinetiConf", city: "Berlin", days: 2 };
export const TRACKS: Record<string, EventColor> = {
"Main stage": "blue",
Workshops: "violet",
Lab: "amber",
Foyer: "emerald",
};
type Session = [
day: number,
at: string,
minutes: number,
track: keyof typeof TRACKS,
title: string,
speakers: string[],
blurb?: string,
];
const SESSIONS: Session[] = [
[0, "09:00", 30, "Foyer", "Registration & coffee", []],
[0, "09:30", 45, "Main stage", "Opening keynote: a calendar is a physics engine", ["Maya Lindqvist"],
"Why every drag in a calendar should be a spring, not a tween — and what that buys you at 120 Hz."],
[0, "10:30", 45, "Main stage", "Time zones without a database", ["Tomasz Wierzbicki"],
"Everything Intl gives you for free, and the three DST edge cases it does not."],
[0, "10:30", 90, "Workshops", "Build a booking page in an hour", ["Priya Raman"],
"Controlled events, a clipped day, read-only blocks and a confirm card. Laptops required."],
[0, "12:00", 60, "Foyer", "Lunch", []],
[0, "13:00", 45, "Main stage", "Drag physics: settle, don't snap", ["Jonah Reyes"],
"Frame-by-frame through a move, a resize and a rejected commit springing home."],
[0, "13:00", 45, "Lab", "Office hours: recurrence edge cases", ["Aiko Tanaka"],
"Bring your worst RRULE. Fifth Fridays, leap days, UNTIL across a DST change."],
[0, "14:00", 45, "Main stage", "Designing for compact density", ["Ines Ferreira"],
"Forty-four pixels an hour: what a title, a time and a colour bar need to stay legible."],
[0, "14:00", 90, "Workshops", "Slots: your own event block", ["Kwame Mensah"],
"Swap the block, keep the engine. The data attributes the drag layer needs and nothing more."],
[0, "15:30", 45, "Main stage", "Panel: what a calendar owes its users", ["Maya Lindqvist", "Jonah Reyes", "Ines Ferreira"]],
[0, "16:30", 30, "Main stage", "Lightning talks", []],
[1, "09:00", 30, "Foyer", "Coffee", []],
[1, "09:30", 45, "Main stage", "Keynote: the command palette is the app", ["Lena Baptiste"],
"Every action reachable from ⌘K, and how natural-language quick add falls out of that."],
[1, "10:30", 45, "Lab", "ICS in the wild", ["Tomasz Wierzbicki"],
"Feeds from six providers, and what each one gets wrong about folding and DTEND."],
[1, "10:30", 90, "Workshops", "Accessible drag and drop", ["Aiko Tanaka"],
"Roving tabindex, live regions and keyboard moves that mirror every pointer gesture."],
[1, "12:00", 60, "Foyer", "Lunch", []],
[1, "13:00", 45, "Main stage", "Optimistic sync that doesn't lie", ["Kwame Mensah"],
"onCommit as a promise: what to show while the server thinks, and how to take it back."],
[1, "14:00", 45, "Main stage", "Closing keynote", ["Maya Lindqvist"]],
[1, "15:00", 60, "Foyer", "Farewell drinks", []],
];
const email = (name: string) => `${name.toLowerCase().replace(/\s+/g, ".")}@kineticonf.dev`;
/** The programme runs Wednesday and Thursday of the current week. */
export function conferenceDays(now = new Date()): Date[] {
const w = resolveView("week", 1, now);
return w.days.slice(2, 2 + CONFERENCE.days);
}
/** Every session is `readOnly`: a public programme is read, not edited. */
export function programme(now = new Date()): CalEvent[] {
const w = resolveView("week", 1, now);
return SESSIONS.map(([day, at, minutes, track, title, speakers, blurb], i) => {
const [h, m] = at.split(":").map(Number);
const startMin = h * 60 + m;
return {
id: `session-${i}`,
title,
description: blurb,
location: track,
start: instantAt(w, 2 + day, startMin),
end: instantAt(w, 2 + day, startMin + minutes),
color: TRACKS[track],
readOnly: true,
busy: track !== "Foyer",
attendees: speakers.map((name) => ({ name, email: email(name) })),
};
});
}
How it works
- Every event is
readOnly: true. The engine skips the drag half of a pointer-down on them and the keyboard layer refuses to move them — a public programme is read, not edited (Events). locale="en-GB"andhour12={false}put every label on a 24-hour clock; the sheet formats its own time line withformatRangeand the same options (Settings).viewanddateare controlled from the bar above the calendar — plain React state, no chrome inside<Kloq>. The segmented control setsview; ‹ › callstepAnchor("day", date, ±1), the helper the built-in nav uses. Click a day heading in the programme and the calendar reports back throughonDateChangeandonViewChange, opening that day's timetable (Controlled state, Views).datestarts on the first conference day. The agenda lists 30 days from there; the day view opens on it.- The details sheet is the
EventPanelslot, not a listener ononEventClick: a slot opens from every view, including the agenda list, and replaces the built-in editor rather than stacking on top of it (Slots). It receives aPlacedOccurrence—event.sourceis the stored session,startMin/endMinare the grid's minutes. - Tracks are the
locationfield, and speakers areattendees; "Add to my calendar" isdownloadICS(session)(ICS).
Take it further
- Colour the tracks through registered calendars instead of per-event colours, and let attendees hide the ones they don't care about.
- Mark the sessions a visitor starred with a second, writable calendar and export just those.
- Multi-city programme:
displayTimeZonedraws the grid in the venue's zone wherever the visitor is (Time zones).