02examples
Custom event rendering
A deploy pipeline drawn with its own EventBlock — status stripe, progress bar, mono duration — with the engine contract honoured so drags and resizes still work.
Three days of deploys, each one a CalEvent rendered by DeployBlock
instead of the stock block: a status stripe, an icon per state, a live
progress bar on the running one, and a mono duration. Grab a block and move
it, take an edge and resize it, press Enter on a focused one — the physics
are the engine's, the pixels are yours.
"use client";
import { useState } from "react";
import {
continuesAfter,
continuesBefore,
EVENT_COLORS,
formatDurationShort,
Kloq,
KloqNav,
KloqToolbar,
useKloqSettings,
type EventBlockProps,
} from "kloq";
import { CircleCheck, CircleX, Clock, LoaderCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { deployById, deployEvents, STATUS_COLOR, type DeployStatus } from "./data";
const STATUS_ICON = {
passed: CircleCheck,
running: LoaderCircle,
failed: CircleX,
queued: Clock,
} satisfies Record<DeployStatus, typeof Clock>;
const PROGRESS_KEYFRAMES = "@keyframes deploy-progress{from{width:15%}to{width:85%}}";
function DeployBlock({
event,
dayCount,
dayNamesShort,
layout,
selected,
tabIndex,
registerNode,
onPointerDown,
onActivate,
}: EventBlockProps) {
const g = useKloqSettings();
const status = deployById.get(event.source.id)?.status ?? "queued";
const Icon = STATUS_ICON[status];
const c = EVENT_COLORS[event.color];
const cutTop = continuesBefore(event);
const cutBottom = continuesAfter(event);
const time = g.formatRange(event.startMin, event.endMin);
const height = g.durationToPx(event.endMin - event.startMin);
const slice = 100 / dayCount / layout.cols;
const left = (event.dayIndex * layout.cols + layout.col) * slice;
const compact = height < 44;
const label =
`${event.title}, ${status}, ${dayNamesShort[event.dayIndex]}, ${time}` +
(cutTop ? ", continued from the previous day" : "") +
(cutBottom ? ", continues" : "");
return (
<div
data-cal-event={event.id}
ref={(node) => registerNode(event.id, node)}
onPointerDown={(e) => onPointerDown(e, event)}
onClick={(e) => {
if (e.detail === 0) onActivate?.(event);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onActivate?.(event);
}
}}
role="button"
aria-pressed={selected}
aria-label={label}
tabIndex={tabIndex ?? (selected ? 0 : -1)}
data-selected={selected || undefined}
className={cn(
"group pointer-events-auto absolute z-10 cursor-grab touch-none select-none rounded-md border bg-card text-foreground shadow-xs outline-none transition-shadow duration-150",
"focus-visible:z-20 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
cutTop && "rounded-t-none border-t-0",
cutBottom && "rounded-b-none border-b-0",
selected && cn("z-20 ring-2 ring-offset-1 ring-offset-background", c.ring),
)}
style={{
top: g.minutesToPx(event.startMin),
height: Math.max(height - 2, 14),
left: `calc(${left}% + 2px)`,
width: `calc(${slice}% - 5px)`,
}}
>
{/* text and the stripe clip here; the resize zones below sit outside the box */}
<div className="absolute inset-0 overflow-hidden rounded-[inherit]">
<span aria-hidden className={cn("absolute inset-y-0 left-0 w-1", c.bar)} />
<div
className={cn(
"flex h-full min-w-0 flex-col pl-3 pr-2",
compact ? "justify-center" : "justify-between py-1.5",
)}
>
<div className="flex min-w-0 items-center gap-1.5">
<Icon
aria-hidden
className={cn("size-3 shrink-0", status === "running" && "motion-safe:animate-spin")}
/>
<span className="truncate text-xs font-medium leading-none">{event.title}</span>
{compact ? (
<span data-slot="event-time" className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
{time}
</span>
) : null}
</div>
{compact ? null : (
<div className="flex items-baseline justify-between gap-2 font-mono text-[10px] text-muted-foreground tabular-nums">
<span data-slot="event-time" className="truncate">
{time}
</span>
<span className="shrink-0">{formatDurationShort(event.endMin - event.startMin)}</span>
</div>
)}
</div>
{status === "running" ? (
<>
<style href="deploy-progress" precedence="default">
{PROGRESS_KEYFRAMES}
</style>
<span aria-hidden className="absolute inset-x-0 bottom-0 h-0.5 bg-border">
<span
className={cn("block h-full", c.bar)}
style={{ animation: "deploy-progress 4s ease-in-out infinite alternate" }}
/>
</span>
</>
) : null}
</div>
{cutTop ? null : (
<div
aria-hidden
data-resize-edge="start"
className="absolute -top-1 left-0 right-0 h-2 cursor-ns-resize pointer-coarse:-top-3 pointer-coarse:h-6"
/>
)}
{cutBottom ? null : (
<div
aria-hidden
data-resize-edge="end"
className="absolute -bottom-1 left-0 right-0 h-2 cursor-ns-resize pointer-coarse:-bottom-3 pointer-coarse:h-6"
/>
)}
</div>
);
}
function Legend() {
return (
<ul className="flex items-center gap-3 font-mono text-xs text-muted-foreground">
{(Object.keys(STATUS_COLOR) as DeployStatus[]).map((status) => (
<li key={status} className="flex items-center gap-1.5">
<span className={cn("size-1.5 rounded-full", EVENT_COLORS[STATUS_COLOR[status]].bar)} />
{status}
</li>
))}
</ul>
);
}
export default function CustomEventBlockExample() {
const [events] = useState(() => deployEvents(new Date()));
return (
<div className="h-[28rem]">
<Kloq
defaultEvents={events}
defaultView={3}
components={{ EventBlock: DeployBlock }}
workingHours={false}
persistence={false}
designMode={false}
storage={false}
>
<KloqToolbar>
<KloqNav />
<Legend />
</KloqToolbar>
</Kloq>
</div>
);
}
import { instantAt, resolveView, type CalEvent, type EventColor } from "kloq";
export type DeployStatus = "passed" | "running" | "failed" | "queued";
export interface Deploy {
id: string;
service: string;
status: DeployStatus;
/** days from today, minutes from midnight, length in minutes */
day: number;
at: number;
minutes: number;
}
export const STATUS_COLOR: Record<DeployStatus, EventColor> = {
passed: "emerald",
running: "amber",
failed: "rose",
queued: "blue",
};
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
export const DEPLOYS: Deploy[] = [
{ id: "dep-api-1", service: "api", status: "passed", day: 0, at: h(8, 30), minutes: 40 },
{ id: "dep-web-1", service: "web", status: "passed", day: 0, at: h(9), minutes: 40 },
{ id: "dep-worker-1", service: "worker", status: "failed", day: 0, at: h(10, 15), minutes: 20 },
{ id: "dep-api-2", service: "api", status: "running", day: 0, at: h(11), minutes: 45 },
{ id: "dep-search-1", service: "search", status: "queued", day: 0, at: h(14), minutes: 30 },
{ id: "dep-billing-1", service: "billing", status: "queued", day: 0, at: h(16), minutes: 60 },
{ id: "dep-web-2", service: "web", status: "queued", day: 1, at: h(9), minutes: 40 },
{ id: "dep-db-1", service: "db-migrate", status: "queued", day: 1, at: h(11, 30), minutes: 90 },
{ id: "dep-api-3", service: "api", status: "queued", day: 1, at: h(15), minutes: 25 },
{ id: "dep-worker-2", service: "worker", status: "queued", day: 2, at: h(10), minutes: 20 },
{ id: "dep-web-3", service: "web", status: "queued", day: 2, at: h(13, 30), minutes: 40 },
];
/** The domain record behind an event, joined by store id. */
export const deployById = new Map(DEPLOYS.map((d) => [d.id, d]));
/** The deploys as calendar events, laid over the three days from `now`. */
export function deployEvents(now: Date): CalEvent[] {
const view = resolveView(3, 1, now);
return DEPLOYS.map((d) => ({
id: d.id,
title: d.service,
color: STATUS_COLOR[d.status],
start: instantAt(view, d.day, d.at),
end: instantAt(view, d.day, d.at + d.minutes),
}));
}
How it works
components={{ EventBlock: DeployBlock }} swaps one of the six
slots. Everything else — the gutter, the day headers, the
panel, the now-line — stays on DEFAULT_COMPONENTS.
The block receives EventBlockProps: a PlacedEvent already projected into
the view's column and minute space, the overlap layout, selected, the
roving tabIndex, and the three engine hooks. It honours the
engine contract in five lines: the root
carries data-cal-event={event.id}, registers itself with
registerNode(event.id, node), forwards onPointerDown(e, event), keeps a
[data-slot="event-time"] span the engine rewrites mid-drag, and marks its
resize zones with data-resize-edge="start" and "end". The drag engine
addresses the registered node directly, so a custom block gets the weld, the
snap and the settle spring without knowing they exist.
Geometry comes from useKloqSettings() — minutesToPx for the top edge,
durationToPx for the height — so the block cannot drift from the grid it
sits on. Column width is 100 / dayCount / layout.cols, the same slice the
stock block draws.
continuesBefore / continuesAfter say whether this placement is a cut of a
cross-midnight event. A cut edge is squared off and gets no resize zone,
because it is not the event's edge.
The deploy status lives beside the events, not on them: data.ts keeps a
Map keyed by store id and the block joins on event.source.id. CalEvent
has no free-form field, and that is on purpose — domain data belongs in your
store, the calendar only needs start, end and a color.
Take it further
Read event.selection to render an "n of m" badge when several blocks are
selected, or swap AllDayChip the same way — it is the horizontal twin, with
data-lane-edge="left" | "right" instead of data-resize-edge.