Skip to main content
Urbicon UI
source

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 map dates onto a grid; this one adds a lane per resource.

Playground

Week 25 – June 2026
Week 25 – June 2026
Today
Mon 15Tue 16Wed 17Thu 18Fri 19Sat 20Sun 21
Cala · Menorca
Cala 01 Garden Room
Cala 04 Room
Cala 11 Suite
Firn · Engadin
Firn 02 Room
Firn 08 Suite
Confirmed Option
View
Variant
Size
<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

Rooms are the lanes, nights are the columns. getRange is inclusive, so a booking stored as check-in and check-out becomes a range by subtracting one day from check-out. Two stays that meet on the same morning (one leaving as the next arrives) then sit side by side instead of stacking into two rows. groups adds the house headings, categories colours the bars and their legend, and onItemClick fires for a bar from a click or the keyboard.
Jun 15 – 28, 2026
Jun 15 – 28, 2026
Today
Mon 15Tue 16Wed 17Thu 18Fri 19Sat 20Sun 21Mon 22Tue 23Wed 24Thu 25Fri 26Sat 27Sun 28
Cala · Menorca
Cala 01 Garden Room
Cala 04 Room
Cala 07 Corner Room
Firn · Engadin
Firn 02 Room
Firn 05 Suite
Confirmed Option Blocked

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, the hook for an “add booking” affordance. The cell snippet fills those free cells; activating a day inside an existing stay fires onItemClick instead, from click or keyboard.
Week 25 – June 2026
Week 25 – June 2026
Today
Mon 15Tue 16Wed 17Thu 18Fri 19Sat 20Sun 21
Firn 02 Room
+
+
+
+
Firn 05 Corner Room
+
+
+
Firn 08 Suite
+
+
+
+

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>

The same rows in a calendar and a timeline

A Calendar and a ResourceTimeline can read the very same CalendarEvent[]. You don't convert the array; you point the timeline at the fields it needs: getResourceId picks each lane, getRange the nights. Both legends read the same categories, because DateCategory is one type across Calendar, Planner and ResourceTimeline. Keep a converted copy only where the two views genuinely need different data.
14 – 23 Jun 2026
14 – 23 Jun 2026
Today
Sun 14Mon 15Tue 16Wed 17Thu 18Fri 19Sat 20Sun 21Mon 22Tue 23
Room 01 Garden Room
Room 02 Suite
Garden Room Suite
June 2026
Today
MonTueWedThuFriSatSun
Lindqvist
Amaral
Weber
Garden Room
Suite
<script lang="ts">
  import { Calendar, ResourceTimeline } from '@urbicon-ui/blocks';
  import type { CalendarEvent, DateCategory, TimelineResource } from '@urbicon-ui/blocks';

  // One array, two views. These are `CalendarEvent`s (the shape a Calendar
  // already holds), and the timeline reads them through its accessors instead of
  // a converted copy: `getResourceId` finds the lane, `getRange` the nights.
  const events: CalendarEvent[] = [
    {
      id: 'b1',
      title: 'Lindqvist',
      start: new Date(2026, 5, 15),
      end: new Date(2026, 5, 18),
      categoryId: 'garden',
      meta: { roomId: 'r-01' }
    },
    {
      id: 'b2',
      title: 'Amaral',
      start: new Date(2026, 5, 16),
      end: new Date(2026, 5, 21),
      categoryId: 'suite',
      meta: { roomId: 'r-02' }
    },
    {
      id: 'b3',
      title: 'Weber',
      start: new Date(2026, 5, 19),
      end: new Date(2026, 5, 20),
      categoryId: 'garden',
      meta: { roomId: 'r-01' }
    }
  ];

  // The same categories drive both legends: `DateCategory` is one type across
  // Calendar, Planner and ResourceTimeline, so nothing is mapped here either.
  const categories: DateCategory[] = [
    { id: 'garden', label: 'Garden Room', color: 'oklch(0.62 0.13 250)' },
    { id: 'suite', label: 'Suite', color: 'oklch(0.72 0.15 60)' }
  ];

  const rooms: TimelineResource[] = [
    { id: 'r-01', label: 'Room 01', description: 'Garden Room' },
    { id: 'r-02', label: 'Room 02', description: 'Suite' }
  ];
</script>

<div class="space-y-6">
  <ResourceTimeline
    view="days"
    days={10}
    size="sm"
    value={new Date(2026, 5, 14)}
    locale="en-GB"
    resources={rooms}
    items={events}
    {categories}
    getResourceId={(event) => String(event.meta?.roomId ?? '')}
    getRange={(event) => ({ start: event.start, end: event.end ?? event.start })}
    getCategoryId={(event) => event.categoryId}
    getLabel={(event) => event.title}
  />

  <!-- The same three stays as month bars: one axis instead of two, which is the
       whole difference between the two views. -->
  <Calendar
    view="month"
    views={['month']}
    defaultDate={new Date(2026, 5, 14)}
    showViewSwitcher={false}
    showEventList={false}
    size="sm"
    locale="en-GB"
    {events}
    {categories}
  />
</div>

Bars you render yourself

The span snippet hands you your item with its own type, plus the layout geometry for the bar. isStart and isEnd are false where a stay runs past the window; the leading and trailing ellipses key off them. getLabel still supplies the bar's accessible name.
Week 25 – June 2026
Week 25 – June 2026
Today
Mon 15Tue 16Wed 17Thu 18Fri 19Sat 20Sun 21
Cala 01 Garden Room
Cala 07 Corner Room
Cala 11 Suite
<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

Every slot takes classes of its own; this demo pairs them with the ghost variant (no grid lines, pill-shaped bars). 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 a new template.
Week 25 – June 2026
Week 25 – June 2026
Today
Mon 15Tue 16Wed 17Thu 18Fri 19Sat 20Sun 21
Duna 03
Duna 06
Duna 09
Confirmed Option
<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; resources stay put.
<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 triggers that item's onItemClick; the free-cell hook fires only on cells no bar covers. Where two bars stack on one day, activating again steps to the next, top row first, then wraps back to the top. getLabel is the bar's accessible name; without it a bar reads as “Occupied”.

Today stays announced

Today's column header and cells carry aria-current="date" whether or not highlightToday tints them, so turning the tint off keeps the semantic marker. The localized window title is mirrored into an aria-live="polite" region, so navigating is announced.

Motion and scrolling

Navigation replaces the window instantly, with no transition. overflow-x sits on the day track, never on the root, so a window wider than the viewport scrolls the grid instead of the page.

04 API Reference

42 props
42 props 2 required
Prop
Type
Default
Description

05 Types

Local type definitions used by this component.

18 types
Name
Kind
Category
Used by
Description

06 Installation

Import

import { ResourceTimeline } from '@urbicon-ui/blocks';