04core concepts
Resource timeline
Rooms, staff or machines as rows along a horizontal time axis — drag to book, drag across rows to reassign, drag an edge to resize.
view="timeline" turns the grid ninety degrees: time runs left to right
instead of top to bottom, and the rows are whatever you name them — rooms,
staff, trucks, machines — instead of days. It is the seventh view shape, and
the odd one out: it doesn't draw the all-day lane, it isn't part of the
responsive collapse (there's no
narrower n-day span to fall back to, so a timeline just scrolls), and it
keeps its own visible-span model instead of view.days.
Rows: the lanes prop
<Kloq view="timeline" lanes={lanes} />lanes is empty by default — nothing renders without it. Each one is a
Lane:
interface Lane {
id: string;
label: string;
sublabel?: string; // an optional second line, e.g. "2nd floor · 8 seats"
height?: number; // px; see "Row height and the overflow cap" below
parentId?: string; // reserved for grouping — ignored in v1
data?: unknown; // handed back to a custom ResourceGutter untouched
}parentId is on the type today but does nothing yet — see
what's not here. How an event maps onto a lane is a
separate question, covered in Resources & lanes.
The visible span: timelineWindow
A timeline has no "week" or "day off" the anchor — how much time it shows, and how finely, is its own prop:
interface TimelineWindowConfig {
start?: Date;
end?: Date;
slotMinutes?: number; // the snap and tick unit. Default 30.
pxPerSlot?: number; // px per slot, before zoom. Default 40.
startOffsetMinutes?: number; // derived mode only. Default 480 (8:00 AM).
spanMinutes?: number; // derived mode only. Default 600 (10 hours).
}There are two modes, and which one you're in changes how navigation behaves:
- Derived (the default) — omit both
startandend. The window is computed off the calendar's own anchor date:startis the anchor's local midnight plusstartOffsetMinutes,endisstartplusspanMinutes. This is what makes ‹ › and "Today" move the window with zero extra wiring —stepAnchormoves the anchor, the anchor derives the window. - Controlled — pass both
startandend. They're honoured exactly, andresolveWindownever recomputes them from the anchor again. The toolbar's ‹ › still moves the calendar's anchor date (nothing stops that), but the visible span no longer follows it — moving the window becomes your job, typically by deriving a newstart/endin your ownonDateChangeand passing it back in as new props.
start/end are all-or-nothing: pass only one and the window derives off
the anchor as if you'd passed neither, the same fallback rule
slotMinutes/pxPerSlot each get independently.
A worked room-booking config — a bookable working day, half-hour slots:
<Kloq
view="timeline"
lanes={rooms}
timelineWindow={{
startOffsetMinutes: 8 * 60, // 8:00 AM
spanMinutes: 10 * 60, // 8:00 AM – 6:00 PM
slotMinutes: 30,
}}
/>And a roster pinned to a fixed span — controlled, because a shift roster is about dates, not the calendar's current anchor. Day-sized slots turn it into a fortnight planner:
<Kloq
view="timeline"
lanes={staff}
timelineWindow={{
start: periodStart,
end: periodEnd, // any pair of Dates — 14 days here
slotMinutes: 1440, // one slot per day
pxPerSlot: 96,
}}
/>See the full example for the first shape end to end; Shift scheduler is the controlled shape too, pinning the current calendar week with hour slots.
Try it
"use client";
import { useState } from "react";
import { Kloq, type CalEvent } from "kloq";
import { ROOMS, ROOM_ADAPTER, roomBookings } from "./data";
export default function RoomBookingExample() {
const [events, setEvents] = useState<CalEvent[]>(() => roomBookings());
return (
<div className="h-[36rem]">
<Kloq
view="timeline"
lanes={ROOMS}
laneAdapter={ROOM_ADAPTER}
timelineWindow={{ startOffsetMinutes: 7 * 60, spanMinutes: 12 * 60 }}
events={events}
onEventsChange={setEvents}
persistence={false}
designMode={false}
storage={false}
>
<Kloq.Toolbar>
<Kloq.Nav />
<span className="font-mono text-xs text-muted-foreground">4 rooms · 7 AM–7 PM</span>
</Kloq.Toolbar>
</Kloq>
</div>
);
}
import { byResourceId, formatInstant, type CalEvent, type Lane } from "kloq";
export type Booking = CalEvent & { resourceId: string };
/** Each room is a lane; a booking names its room through a plain `resourceId` field. */
export const ROOMS: Lane[] = [
{ id: "sunroom", label: "Sunroom", sublabel: "2nd floor · 8 seats" },
{ id: "boardroom", label: "Boardroom", sublabel: "3rd floor · 14 seats", height: 132 },
{ id: "studio", label: "Studio", sublabel: "Ground floor · 6 seats" },
{ id: "focus-pod", label: "Focus pod", sublabel: "3rd floor · 2 seats" },
];
/** `byResourceId()` reads `resourceId` — the default a host gets by omitting `laneAdapter` entirely. */
export const ROOM_ADAPTER = byResourceId();
/** Today at the given local hour/minute, as an offset-qualified instant. */
function at(hour: number, minute = 0): string {
const d = new Date();
d.setHours(hour, minute, 0, 0);
return formatInstant(d);
}
export function roomBookings(): Booking[] {
return [
{ id: "rb-standup", title: "Standup", resourceId: "studio", start: at(9, 0), end: at(9, 15), color: "blue" },
{ id: "rb-budget", title: "Budget review", resourceId: "boardroom", start: at(9, 0), end: at(11, 0), color: "violet" },
{ id: "rb-interview", title: "Interview: Sam Okafor", resourceId: "boardroom", start: at(10, 0), end: at(10, 45), color: "amber" },
{ id: "rb-focus", title: "Focus block", resourceId: "focus-pod", start: at(8, 0), end: at(12, 0), color: "emerald" },
{ id: "rb-demo", title: "Product demo", resourceId: "studio", start: at(13, 0), end: at(14, 0), color: "rose" },
{ id: "rb-1on1", title: "1:1: Priya", resourceId: "sunroom", start: at(14, 0), end: at(14, 30), color: "blue" },
{ id: "rb-allhands", title: "All-hands prep", resourceId: "boardroom", start: at(15, 0), end: at(16, 0), color: "violet" },
];
}
Gestures
Every gesture from the calendar's own drag engine exists here, remapped onto lane + minute instead of day + minute:
- Drag to create — drag on empty canvas to draw a new booking, snapped to
slotMinutes. - Drag to move — grab a bar and drag it. Drag across rows and the drop
reassigns it: the engine calls your
LaneAdapter'sassign(event, newLaneId)for you, the same inverselaneOfdescribes. - Resize — drag either edge to change the start or end independently.
- Double-click — creates a default-duration booking at the clicked lane and time, same as the calendar's double-click quick-create.
Constraints apply unchanged: minDate/maxDate,
isSlotDisabled and canDrop all run against timeline gestures exactly as
they do against the calendar's, including the origin exemption — a
booking already sitting across refused time can still be dragged, so long as
the drag doesn't newly occupy more refused time. See
Constraints for what "newly
occupies" means, and its timeline gesture names
if you're writing a canDrop that branches on which surface asked.
A booking that crosses midnight is drawn as a single continuous bar — the timeline has no day columns to slice it against, unlike the calendar's own cross-midnight segmentation.
Zoom
Zoom is the calendar's existing control (the same ladder hourHeight scales
through, useKloqSettings().zoom) — it scales pxPerSlot, never
slotMinutes or the span the window covers. Zooming in makes 8:00–18:00 wider
on screen; it never shows you more or less of the day.
Row height and the overflow cap
Lane.height is more than a row's pixel height — it's also the cap on how
many overlapping bookings that row draws before the rest collapse into a
"+N more" popover, the same treatment the calendar's all-day lane uses for
an over-full day. The cap is height ÷ bar height, floored, with a floor of
one row. The default row height caps at 3 overlapping bookings; a room
or shift that's routinely double- or
triple-booked wants a taller Lane.height, not a different prop — there is
deliberately no separate "max overlaps" knob.
{ id: "boardroom", label: "Boardroom", height: 132 } // taller row, higher cap: height ÷ bar height, flooredSlots
Four new component slots join the six the
calendar already has: TimelineBlock, ResourceGutter, TimelineHeader,
TimelineRow. Their contracts are on the Slots page.
What is not here
This ships the surface, not a project-management tool:
- No virtualization. Every lane and every bar renders. It hasn't been exercised past a modest row count, and a long resource list is likely to perform poorly — plan around it rather than assuming it scales the way a virtualized grid would.
- No resource grouping or trees.
Lane.parentIdexists on the type for forward compatibility and does nothing today; lanes render as one flat list in the order you pass them. - No dependency arrows, critical path, or baselines. This is a booking grid, not a Gantt chart.
- Keyboard support is partial. ⌘K, undo/redo and page up/down work, and
Tab reaches bars through the shell's usual roving tabindex.
Copy/cut/duplicate/delete work for bars on the window's anchor date
only: the selection layer still reasons about the calendar's day-column
projection, which for a timeline covers just that one day, so on a
multi-day window a bar on any other day can't hold a selection. Arrow-key
navigation between bars,
⌥+arrow move/resize, select-all, Escape/Enter on a selected bar, the 1–5 recolour keys andn-to-create don't work — same cause, no lane-aware equivalent yet. Drag, resize and create gestures are unaffected on any day; click-to-select (and the panel it opens) shares the selection layer's anchor-date limit.