ResourceTimelinebeta
One lane per room, chair or vehicle against a day axis: each item is a bar over the days it occupies, stacked where two overlap. Calendar and Planner lay out dates; this lays out resource × date.
Playground
<script lang="ts">
import { ResourceTimeline } from '@urbicon-ui/blocks';
import { addDays, isoToDate } from '@urbicon-ui/blocks/date';
const value = new Date(2026, 5, 15);
const resources = [
{ id: 'cala-01', label: 'Cala 01', description: 'Garden Room', groupId: 'cala' },
{ id: 'cala-04', label: 'Cala 04', description: 'Room', groupId: 'cala' },
{ id: 'cala-11', label: 'Cala 11', description: 'Suite', groupId: 'cala' },
{ id: 'firn-02', label: 'Firn 02', description: 'Room', groupId: 'firn' },
{ id: 'firn-08', label: 'Firn 08', description: 'Suite', groupId: 'firn' }
];
const groups = [{ id: 'cala', label: 'Cala · Menorca' }, { id: 'firn', label: 'Firn · Engadin' }];
const categories = [
{ id: 'confirmed', label: 'Confirmed', color: 'oklch(0.62 0.13 250)' },
{ id: 'option', label: 'Option', color: 'oklch(0.83 0.13 90)' }
];
const items = [
{ id: 'b1', roomId: 'cala-01', guest: 'Lindqvist', checkIn: '2026-06-12', checkOut: '2026-06-18', state: 'confirmed' },
{ id: 'b2', roomId: 'cala-01', guest: 'Okafor', checkIn: '2026-06-20', checkOut: '2026-06-26', state: 'confirmed' },
{ id: 'b3', roomId: 'cala-04', guest: 'Bianchi', checkIn: '2026-06-16', checkOut: '2026-06-19', state: 'option' },
{ id: 'b4', roomId: 'cala-11', guest: 'Sørensen', checkIn: '2026-06-18', checkOut: '2026-06-23', state: 'confirmed' },
{ id: 'b5', roomId: 'firn-02', guest: 'Weber', checkIn: '2026-06-15', checkOut: '2026-06-18', state: 'confirmed' },
{ id: 'b6', roomId: 'firn-08', guest: 'Ferreira', checkIn: '2026-06-21', checkOut: '2026-06-28', state: 'option' }
];
const getResourceId = (booking) => booking.roomId;
const getCategoryId = (booking) => booking.state;
const getLabel = (booking) => booking.guest;
const getRange = (booking) => ({ start: booking.checkIn, end: addDays(isoToDate(booking.checkOut), -1) });
</script>
<ResourceTimeline
{value}
{resources}
{groups}
{items}
{categories}
{getResourceId}
{getCategoryId}
{getLabel}
{getRange}
/>01 Examples
Hotel occupancy
getRange is inclusive, so a booking stored as check-in and check-out converts by subtracting one day from check-out. Firn 02 shows what that buys: two stays meeting on the same morning sit side by side instead of stacking into two rows. groups adds the house headings, categories colours the bars and draws the legend, and onItemClick fires for a bar from a click or from the keyboard.Pick a bar to select a stay.
<script lang="ts">
import { ResourceTimeline } from '@urbicon-ui/blocks';
import { addDays, isoToDate } from '@urbicon-ui/blocks/date';
interface Booking {
roomId: string;
guest: string;
/** The first night of the stay. */
checkIn: string;
/** The morning the guest leaves — not a night. */
checkOut: string;
state: 'confirmed' | 'option' | 'blocked';
}
const houses = [
{ id: 'cala', label: 'Cala · Menorca' },
{ id: 'firn', label: 'Firn · Engadin' }
];
const rooms = [
{ id: 'cala-01', label: 'Cala 01', description: 'Garden Room', groupId: 'cala' },
{ id: 'cala-04', label: 'Cala 04', description: 'Room', groupId: 'cala' },
{ id: 'cala-07', label: 'Cala 07', description: 'Corner Room', groupId: 'cala' },
{ id: 'firn-02', label: 'Firn 02', description: 'Room', groupId: 'firn' },
{ id: 'firn-05', label: 'Firn 05', description: 'Suite', groupId: 'firn' }
];
const states = [
{ id: 'confirmed', label: 'Confirmed', color: 'oklch(0.62 0.13 250)' },
{ id: 'option', label: 'Option', color: 'oklch(0.83 0.13 90)' },
{ id: 'blocked', label: 'Blocked', color: 'oklch(0.62 0.02 260)' }
];
const bookings: Booking[] = [
// Weber leaves on the 18th and Haldar arrives the same morning: the two bars
// meet without touching. Drop the −1 below and they overlap into two rows.
{
roomId: 'firn-02',
guest: 'Weber',
checkIn: '2026-06-15',
checkOut: '2026-06-18',
state: 'confirmed'
},
{
roomId: 'firn-02',
guest: 'Haldar',
checkIn: '2026-06-18',
checkOut: '2026-06-24',
state: 'confirmed'
},
{
roomId: 'cala-01',
guest: 'Lindqvist',
checkIn: '2026-06-12',
checkOut: '2026-06-18',
state: 'confirmed'
},
{
roomId: 'cala-04',
guest: 'Bianchi',
checkIn: '2026-06-15',
checkOut: '2026-06-19',
state: 'option'
},
{
roomId: 'cala-07',
guest: 'Amaral',
checkIn: '2026-06-16',
checkOut: '2026-07-02',
state: 'confirmed'
},
{
roomId: 'firn-05',
guest: 'Repainting',
checkIn: '2026-06-17',
checkOut: '2026-06-21',
state: 'blocked'
}
];
let picked = $state('');
</script>
<div class="w-full">
<ResourceTimeline
view="days"
days={14}
value={new Date(2026, 5, 15)}
locale="en-US"
resources={rooms}
groups={houses}
items={bookings}
categories={states}
getResourceId={(booking) => booking.roomId}
getCategoryId={(booking) => booking.state}
getLabel={(booking) => booking.guest}
onItemClick={(booking, room) => (picked = `${booking.guest} · ${room.label}`)}
getRange={(booking) => ({
// Both ends are nights: the stay's last night is check-out minus one day.
start: booking.checkIn,
end: addDays(isoToDate(booking.checkOut), -1)
})}
/>
<p class="text-text-secondary mt-3 text-sm">
{picked ? `Selected: ${picked}` : 'Pick a bar to select a stay.'}
</p>
</div>
A free night is where you add one
onCellClick fires only for a cell no bar covers, which makes it the hook for an “add booking” affordance. The cell snippet paints it; a day inside an existing stay reports onItemClick instead, from either input.Click a free night, or press Enter on one.
<script lang="ts">
import { ResourceTimeline } from '@urbicon-ui/blocks';
import { toIso } from '@urbicon-ui/blocks/date';
interface Stay {
id: string;
roomId: string;
guest: string;
from: string;
/** Inclusive: the last night, not the check-out morning. */
to: string;
}
const rooms = [
{ id: 'firn-02', label: 'Firn 02', description: 'Room' },
{ id: 'firn-05', label: 'Firn 05', description: 'Corner Room' },
{ id: 'firn-08', label: 'Firn 08', description: 'Suite' }
];
const stays: Stay[] = [
{ id: 's1', roomId: 'firn-02', guest: 'Weber', from: '2026-06-15', to: '2026-06-17' },
{ id: 's2', roomId: 'firn-05', guest: 'Haldar', from: '2026-06-18', to: '2026-06-21' },
{ id: 's3', roomId: 'firn-08', guest: 'Ferreira', from: '2026-06-16', to: '2026-06-18' }
];
let status = $state('');
</script>
<div class="w-full">
<ResourceTimeline
value={new Date(2026, 5, 15)}
locale="en-US"
resources={rooms}
items={stays}
getResourceId={(stay) => stay.roomId}
getRange={(stay) => ({ start: stay.from, end: stay.to })}
getLabel={(stay) => stay.guest}
onCellClick={(room, date) => (status = `New booking: ${room.label} · ${toIso(date)}`)}
onItemClick={(stay) => (status = `${stay.guest} is already booked here`)}
>
{#snippet cell({ isOccupied, isDisabled })}
<!-- Painted on free nights only; the click itself is onCellClick's job, so
the cell keeps its single tab stop and works from the keyboard too. -->
{#if !isOccupied && !isDisabled}
<span class="text-text-tertiary grid h-full place-items-center text-xs opacity-50">+</span>
{/if}
{/snippet}
</ResourceTimeline>
<p class="text-text-secondary mt-3 text-sm">
{status || 'Click a free night, or press Enter on one.'}
</p>
</div>
Bars you render yourself
span snippet gets your item with its own type plus the geometry the layout worked out. isStart and isEnd are false where a stay runs past the window, which is what the leading and trailing ellipses read from. getLabel still supplies the bar's accessible name.<script lang="ts">
import { ResourceTimeline } from '@urbicon-ui/blocks';
interface Stay {
id: string;
roomId: string;
guest: string;
firstNight: string;
lastNight: string;
}
const rooms = [
{ id: 'cala-01', label: 'Cala 01', description: 'Garden Room' },
{ id: 'cala-07', label: 'Cala 07', description: 'Corner Room' },
{ id: 'cala-11', label: 'Cala 11', description: 'Suite' }
];
const stays: Stay[] = [
{
id: 's1',
roomId: 'cala-01',
guest: 'Amaral',
firstNight: '2026-06-10',
lastNight: '2026-06-17'
},
{
id: 's2',
roomId: 'cala-07',
guest: 'Bianchi',
firstNight: '2026-06-13',
lastNight: '2026-06-25'
},
{
id: 's3',
roomId: 'cala-11',
guest: 'Marek',
firstNight: '2026-06-17',
lastNight: '2026-06-19'
}
];
</script>
<ResourceTimeline
size="lg"
value={new Date(2026, 5, 15)}
locale="en-US"
resources={rooms}
items={stays}
getResourceId={(stay) => stay.roomId}
getRange={(stay) => ({ start: stay.firstNight, end: stay.lastNight })}
getLabel={(stay) => `${stay.guest}, ${stay.firstNight} to ${stay.lastNight}`}
>
{#snippet span({ item, totalDays, isStart, isEnd })}
<span class="truncate">
{isStart ? '' : '… '}{item.guest} · {totalDays} nights{isEnd ? '' : ' …'}
</span>
{/snippet}
</ResourceTimeline>
02 Customization
slotClasses
ghost variant drops the grid lines, the bars become pills, the room type is hidden and the legend moves to the right. Density is a separate axis: size sets --rt-lane-w, --rt-day-w and --rt-bar-h on the track slot, so a slotClasses.track of [--rt-day-w:4rem] re-tunes the geometry without restating a template.<script lang="ts">
import { ResourceTimeline } from '@urbicon-ui/blocks';
interface Stay {
id: string;
roomId: string;
guest: string;
firstNight: string;
lastNight: string;
state: 'confirmed' | 'option';
}
const rooms = [
{ id: 'duna-03', label: 'Duna 03', description: 'Room' },
{ id: 'duna-06', label: 'Duna 06', description: 'Garden Room' },
{ id: 'duna-09', label: 'Duna 09', description: 'Suite' }
];
const states = [
{ id: 'confirmed', label: 'Confirmed', color: 'oklch(0.62 0.13 250)' },
{ id: 'option', label: 'Option', color: 'oklch(0.83 0.13 90)' }
];
const stays: Stay[] = [
{
id: 's1',
roomId: 'duna-03',
guest: 'Varela',
firstNight: '2026-06-15',
lastNight: '2026-06-17',
state: 'confirmed'
},
{
id: 's2',
roomId: 'duna-06',
guest: 'Camacho',
firstNight: '2026-06-17',
lastNight: '2026-06-20',
state: 'option'
},
{
id: 's3',
roomId: 'duna-09',
guest: 'Pons',
firstNight: '2026-06-16',
lastNight: '2026-06-21',
state: 'confirmed'
}
];
</script>
<ResourceTimeline
variant="ghost"
value={new Date(2026, 5, 15)}
locale="en-US"
resources={rooms}
items={stays}
categories={states}
getResourceId={(stay) => stay.roomId}
getCategoryId={(stay) => stay.state}
getRange={(stay) => ({ start: stay.firstNight, end: stay.lastNight })}
getLabel={(stay) => stay.guest}
slotClasses={{
base: 'bg-surface-elevated border-border-subtle rounded-2xl border px-4 pb-3',
header: 'border-b-0',
headerTitle: 'text-text-tertiary text-xs tracking-[0.18em] uppercase',
corner: 'bg-transparent',
dayHeaderRow: 'border-b-0',
dayHeaderWeekday: 'text-text-tertiary',
dayHeaderDate: 'text-text-secondary font-normal',
laneHeader: 'bg-transparent',
laneLabel: 'text-2xs text-text-secondary tracking-[0.14em] uppercase',
laneDescription: 'hidden',
span: 'rounded-full shadow-[var(--blocks-shadow-sm)]',
legend: 'justify-end'
}}
/>
Load only the visible window
onNavigate fires after every step with the reference date and the window that is now on screen. Fetch there and swap items; the lanes stay as they are.<script lang="ts">
import { ResourceTimeline } from '@urbicon-ui/blocks';
import { toIso } from '@urbicon-ui/blocks/date';
let { data } = $props();
let bookings = $state(data.bookings);
async function load(range: { start: Date; end: Date }) {
const query = new URLSearchParams({ from: toIso(range.start), to: toIso(range.end) });
bookings = await fetch(`/api/bookings?${query}`).then((r) => r.json());
}
</script>
<ResourceTimeline
view="days"
days={14}
resources={data.rooms}
items={bookings}
getResourceId={(b) => b.roomId}
getRange={(b) => ({ start: b.firstNight, end: b.lastNight })}
onNavigate={(_date, range) => load(range)}
/>03 Accessibility
The ARIA grid pattern
The day track is a role="grid": one row per resource, the lane
label as its rowheader, one gridcell per day carrying aria-colindex, and a columnheader per column in the header row. A
roving tabindex keeps the whole grid to a single tab stop.
Keyboard
ArrowLeft/ArrowRight move one day inside the lane, ArrowUp/ArrowDown move one lane at the same day. An arrow at the window edge stays put rather than paging. Home/End jump to the first and last column of the lane, with Ctrl to the first and last cell of the grid, and PageUp/PageDown step the window. Enter/Space activate.
Bars are reachable from the cells they cover
A bar is a button anchored in its first visible day and overhangs the rest, so activating
any cell it covers reports that item rather than the free-cell hook. Where two bars stack on
one day, repeated activation walks them from the top row and wraps. getLabel is the bar's accessible name; without it a bar announces as “Occupied”.
Today stays announced
Today's column header and cells carry aria-current="date" whether or not highlightToday tints them: the highlight is a visual preference, the pointer is
the semantics. The localized window title is mirrored into an aria-live="polite" region, so navigating is announced.
Motion and scrolling
Navigation swaps the window without a transition and there is no swipe gesture, because the
sticky resource column cannot survive a transform ancestor and the day track
already owns the horizontal gesture. overflow-x sits on that track, never on the
root, so a window wider than the viewport scrolls the grid instead of the page.
04 API Reference
42 propsProp | Type | Default | Description | |
|---|---|---|---|---|
getRange required | (item: T) => TimelineRange | — | The item's **inclusive** day range — both start and end are days the
bar covers. A stay ending at check-out passes checkOut − 1. Return
Dates, or local date strings ('2026-06-16') which are read verbatim and
never UTC-parsed. A range whose end precedes its start is rendered with the
two swapped and warns in DEV. Required. | |
getResourceId required | (item: T) => string | — | Which lane an item belongs to. An id that is in no resources entry drops the item (DEV warns). Required. | |
categories | TimelineCategory[] | — | Colour buckets for the bars, and the legend below the grid. | |
cell | Snippet<[TimelineCellContext]> | — | Render extra content inside every (resource, day) cell, e.g. an "add" affordance on free days. | |
class | string | — | Extra classes merged onto the root element. | |
dayHeader | Snippet<[TimelineDayContext]> | — | Customise a day column's header. | |
days | number | 14 | Column count for view="days". Ignored in week. | |
disabled | boolean | false | Disable navigation and cell activation. | |
empty | Snippet | — | Replace the "no resources" message shown when resources is empty. | |
getCategoryId | (item: T) => string | undefined | — | The item's category id, looked up in categories. Falls back to resource.categoryId. | |
getId | (item: T) => string | — | Stable key for an item, used as the {#each} key. Defaults to resource id + start day + index. | |
getLabel | (item: T) => string | — | The bar's text and accessible name. Without it a bar renders as a plain occupancy block. | |
groupLabel | Snippet<[TimelineGroupContext]> | — | Customise a group heading row. | |
groups | TimelineGroup[] | — | Heading rows above the lanes carrying the matching groupId. Supplying
them re-orders the lanes to follow this list; a lane whose groupId names
no group is appended without a heading rather than dropped. | |
header | Snippet<[TimelineHeaderContext]> | — | Replace the default toolbar (prev/title/today/next). | |
highlightToday | boolean | true | Tint today's column. Visual only — aria-current="date" is set on today's
header and cells either way, so switching the highlight off never costs the
semantic pointer. | |
highlightWeekend | boolean | false | Tint Saturday/Sunday columns. | |
isDateDisabled | (date: Date) => boolean | — | Predicate for days that cannot be activated, on top of minDate/maxDate. | |
items | T[] | — | The items to lay out as bars. | |
legend | Snippet<[TimelineLegendContext]> | — | Replace the default category legend. | |
locale | string | 'auto' | BCP 47 locale tag for the weekday names and the header title. Defaults to
'auto', which follows the active <I18nProvider> locale, so an app that
already declares its language does not repeat it here. SSR-safe: the locale
comes from context, so server and client resolve the same tag. Pass an
explicit tag (e.g. 'de-DE') to override. | |
maxDate | Date | — | Latest navigable date. The window clamps span-preserving, so it never collapses at the bound. | |
maxRowsPerLane | number | — | Bar rows to render per lane; anything past it becomes a +n chip at the lane's right edge. Unset renders every row. | |
minDate | Date | — | Earliest navigable date. The window clamps span-preserving, so it never collapses at the bound. | |
onCellClick | (resource: TimelineResource, date: Date) => void | — | Fires when a cell **no bar covers** is activated — the hook for an "add
booking" affordance. A day inside an existing stay reports onItemClick
instead, from either input. | |
onItemClick | (item: T, resource: TimelineResource) => void | — | Fires when a bar is activated — a click on it, or Enter/Space on **any** cell it covers (the bar overhangs those cells, so the keyboard reaches what the pointer hits). Where several bars stack on one day, repeated activation walks them top row first and wraps. | |
onNavigate | (date: Date, range: { start: Date; end: Date }) => void | — | Fires after navigation with the new reference date and the visible window — load data here. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ ResourceTimeline: {...} }}>. | |
resourceLabel | Snippet<[TimelineResourceContext]> | — | Customise a lane's label in the resource column. | |
resources | TimelineResource[] | — | The lanes, top to bottom. An empty list renders the empty state. | |
showLegend | boolean | true | Render the category legend below the grid. Ignored without categories. | |
size | smmdlg | 'md' | Density: lane width, day-column width and bar height. | |
slotClasses | Partial<Record<ResourceTimelineSlots, string>> | — | Per-slot class overrides merged with tv() styles. Slots: base | header | headerTitle | nav | navButton | track | dayHeaderRow | corner | dayHeader | dayHeaderWeekday | dayHeaderDate | body | groupRow | groupLabel | lane | laneHeader | laneLabel | laneDescription | dayCell | span | spanLabel | overflow | legend | legendItem | legendDot | legendLabel | empty | |
span | Snippet<[TimelineSpanContext<T>]> | — | Render a bar's content — the core of the API. Receives the clipped geometry and the typed item. | |
stickyResourceColumn | boolean | true | Keep the resource column pinned while the day track scrolls sideways. Turn it off inside a shell that already provides its own horizontal scrolling. | |
unstyled | boolean | false | Remove all default tv() classes — only user-provided classes apply. Note
that this also strips the layout's custom properties (--rt-lane-w,
--rt-day-w, --rt-bar-h …), so an unstyled timeline has to re-declare
them along with the look. | |
value | Date | today | Reference date the window is anchored on. Supports bind:value. | |
variant | defaultborderedghost | 'default' | Visual style variant for the ResourceTimeline component | |
view | ResourceTimelineView | 'week' | week snaps to the week containing value; days starts at value. | |
weekStartsOn | 0123 +3 more | 1 | First day of the week for view="week" (0=Sun … 6=Sat). | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children') | |
...ResourceTimelineVariants variant | VariantProps | — | Styling variants from ResourceTimelineVariants |
05 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
ResourceTimelineProps | interface | props | 0 | — | |
ResourceTimelineView | type | helper | 1 | Window mode. week is the ISO week containing the reference date; days is
a fixed N-day window **starting at** the reference date. | |
TimelineCategory | interface | helper | 1 | A colour bucket for spans. color takes the same values as CalendarEventCategory — hex, rgb(), oklch() or var(--…). | |
TimelineCellContext | interface | helper | 0 | Per-cell context for the cell snippet — one (resource, day) intersection. | |
TimelineDayContext | interface | helper | 0 | Per-column context for the dayHeader snippet. | |
TimelineGroup | interface | helper | 1 | A heading row above the lanes that carry its id in groupId. | |
TimelineGroupContext | interface | helper | 0 | Context for the groupLabel snippet. | |
TimelineHeaderContext | interface | helper | 0 | Context for the header snippet — everything to build a custom toolbar. Same shape as PlannerHeaderContext, deliberately. | |
TimelineLegendContext | interface | helper | 0 | Context for the legend snippet. | |
TimelineRange | interface | helper | 0 | The **inclusive** day range a span occupies — both start and end are days
the bar covers. A hotel stay converts by subtracting one day from check-out
(the last *night* is checkOut − 1); the same convention CalendarEvent.end
and getEventDayInfo use.
Strings are read as local calendar days, verbatim, through the same parser
Planner.getDate uses — '2026-06-16' is never UTC-parsed and so never
shifts a day west of Greenwich. | |
TimelineResource | interface | helper | 1 | One lane of the timeline — a room, a chair, a vehicle, a person. | |
TimelineResourceContext | interface | helper | 0 | Context for the resourceLabel snippet. | |
TimelineSpanContext | interface | helper | 0 | The value the span snippet receives per bar — the heart of the API. | |
ResourceTimelineSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
ResourceTimelineVariants | type | variant | 1 | — | |
SlotNames | type | helper | 0 | Extracts the slot-name union from a slotted tv() config function — the
companion to VariantProps. The slot-mode overload returns
(props?) => { [K in keyof S]: SlotFn }, so keyof ReturnType<T> is exactly
the set of slot names a component declares in tv({ slots: … }).
Use it to type a component's slotClasses prop from the single source of
truth (its *.variants.ts) instead of hand-maintaining a parallel union
that silently drifts when a slot is added or renamed: | |
VariantProps | type | helper | 1 | — |
06 Installation
Import
import { ResourceTimeline } from '@urbicon-ui/blocks';