02examples
Multiple time zones
Draw the grid in any zone, add London / New York / Tokyo gutter columns, and keep each event's authored zone.
Three days of a team spread over four cities, drawn on London's clock to start. The standup was agreed as 9:00 in Tokyo, the retro as 16:00 in Sydney, the release sync as 7:30 in New York — and each lands at the right hour on whatever clock you draw the grid in: scroll up to 1 AM for the standup, then switch the display zone to Tokyo and it sits at 9. Toggle the gutter columns, or press the zone labels in the gutter corner.
"use client";
import { useState } from "react";
import { Kloq, KloqNav, KloqToolbar, localZone, useIsClient, zoneCityLabel, zoneOffsetLabel } from "kloq";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DISPLAY_ZONES, GUTTER_ZONES, teamEvents } from "./data";
export default function MultiTimezoneExample() {
const [events] = useState(() => teamEvents(new Date()));
const [zone, setZone] = useState("Europe/London");
const [columns, setColumns] = useState(GUTTER_ZONES);
const [pickerOpen, setPickerOpen] = useState(false);
const isClient = useIsClient();
const displayTimeZone = zone === "local" ? undefined : zone;
// the browser's zone is only knowable on the client; the readout waits for it
const resolvedZone = isClient ? (displayTimeZone ?? localZone()) : null;
const toggleColumn = (id: string, on: boolean) =>
setColumns(GUTTER_ZONES.filter((z) => (z === id ? on : columns.includes(z))));
return (
<div className="flex h-[30rem] flex-col">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-b px-4 py-2.5">
<label className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">display zone</span>
<Select
items={DISPLAY_ZONES}
value={zone}
onValueChange={(v) => setZone(v ?? "local")}
open={pickerOpen}
onOpenChange={setPickerOpen}
>
<SelectTrigger size="sm" className="w-32 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectPopup>
{DISPLAY_ZONES.map((z) => (
<SelectItem key={z.value} value={z.value}>
{z.label}
</SelectItem>
))}
</SelectPopup>
</Select>
</label>
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-muted-foreground">gutter</span>
{GUTTER_ZONES.map((id) => (
<label key={id} className="flex items-center gap-1.5 text-sm">
<Checkbox checked={columns.includes(id)} onCheckedChange={(on) => toggleColumn(id, on)} />
{zoneCityLabel(id)}
</label>
))}
</div>
</div>
<div className="min-h-0 flex-1">
<Kloq
displayTimeZone={displayTimeZone}
timeZones={columns.filter((z) => z !== displayTimeZone)}
onTimeZonesClick={() => setPickerOpen(true)}
defaultEvents={events}
defaultView={3}
persistence={false}
designMode={false}
storage={false}
>
<KloqToolbar>
<KloqNav />
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{resolvedZone ? `${resolvedZone} · UTC${zoneOffsetLabel(new Date(), resolvedZone)}` : ""}
</span>
</KloqToolbar>
</Kloq>
</div>
</div>
);
}
import { instantAt, resolveView, zoneCityLabel, type CalEvent, type EventColor } from "kloq";
/** What the display-zone picker offers. `"local"` means "no `displayTimeZone`" — the browser's own zone. */
export const DISPLAY_ZONES = [
{ value: "local", label: "Local" },
...["Europe/London", "America/New_York", "Asia/Tokyo", "Australia/Sydney"].map((id) => ({
value: id,
label: zoneCityLabel(id),
})),
];
/** The extra gutter columns, in the order they are drawn. */
export const GUTTER_ZONES = ["Europe/London", "America/New_York", "Asia/Tokyo"];
interface Meeting {
title: string;
/** the zone the time was agreed in; omit for the browser's own */
timeZone?: string;
/** days from today, minutes from that zone's midnight, length in minutes */
day: number;
at: number;
minutes: number;
color: EventColor;
}
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
const MEETINGS: Meeting[] = [
{ title: "Tokyo standup", timeZone: "Asia/Tokyo", day: 0, at: h(9), minutes: 30, color: "rose" },
{ title: "Tokyo standup", timeZone: "Asia/Tokyo", day: 1, at: h(9), minutes: 30, color: "rose" },
{ title: "Tokyo standup", timeZone: "Asia/Tokyo", day: 2, at: h(9), minutes: 30, color: "rose" },
{ title: "Sydney retro", timeZone: "Australia/Sydney", day: 0, at: h(16), minutes: 45, color: "amber" },
{ title: "London design review", timeZone: "Europe/London", day: 0, at: h(10), minutes: 60, color: "violet" },
{ title: "NY release sync", timeZone: "America/New_York", day: 0, at: h(7, 30), minutes: 45, color: "blue" },
{ title: "London 1:1s", timeZone: "Europe/London", day: 1, at: h(9, 30), minutes: 90, color: "violet" },
{ title: "NY handoff", timeZone: "America/New_York", day: 1, at: h(8), minutes: 30, color: "blue" },
{ title: "Sydney planning", timeZone: "Australia/Sydney", day: 2, at: h(15), minutes: 60, color: "amber" },
{ title: "London design review", timeZone: "Europe/London", day: 2, at: h(11), minutes: 60, color: "violet" },
{ title: "Focus block", day: 2, at: h(14), minutes: 120, color: "emerald" },
];
/** Each meeting at its own wall-clock time, stored as an absolute instant. */
export function teamEvents(now: Date): CalEvent[] {
const view = resolveView(3, 1, now);
return MEETINGS.map((m, i) => ({
id: `team-${i}`,
title: m.title,
color: m.color,
timeZone: m.timeZone,
start: instantAt(view, m.day, m.at, m.timeZone),
end: instantAt(view, m.day, m.at + m.minutes, m.timeZone),
}));
}
How it works
displayTimeZone redraws the whole calendar in another zone — headers,
gutter, now-line and all. The events never change: their instants are
absolute, only the wall clock you read them against does. "Local" in the
picker passes undefined, which means the browser's own zone. A column that
matches the display zone is filtered out of timeZones, since it would only
repeat the main axis. See
time zones.
timeZones={["Europe/London", "America/New_York", "Asia/Tokyo"]} adds a
gutter column per zone, left of the main one and in that order. Setting it
swaps the built-in TimeGutter for the multi-zone one; the checkboxes just
filter the list.
Each meeting is authored in its own zone: instantAt(view, col, minutes, timeZone) reads minutes on that zone's clock and returns an
offset-qualified instant, and the event carries the same id in its
timeZone field. That field is provenance, not placement — start and
end decide where a block sits. When it differs from the display zone, the
panel and the block's accessible name add the event's own clock:
"9:00 AM – 9:30 AM GMT+9 · Tokyo".
onTimeZonesClick fires when the zone labels in the gutter corner are
pressed. The component ships no picker; here it opens the select above the
calendar, which is exactly the host-owns-the-picker split described on the
time zones
page.
The readout on the right is zoneOffsetLabel(Date.now(), zone). The
browser's zone is only knowable on the client, so it waits for useIsClient()
before rendering.
Take it further
Replace the five-zone select with a search over listAllZones() /
searchZones(query), or give a column a custom heading with the object form:
{ timeZone: "Asia/Tokyo", label: "TOK" }.