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 lay out dates; this lays out resource × date.

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 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.
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, 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.
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>

Bars you render yourself

The 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.
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. Here the 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.
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; 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 props
42 props 2 required
Prop
Type
Default
Description

05 Types

Local type definitions used by this component.

17 types
Name
Kind
Category
Used by
Description

06 Installation

Import

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