Moltenship Time
Views

Resource columns

A day of rooms or people side by side, grouped by team, with hundreds of columns.

Live, in your time zone. Drag events between rooms and people, or resize and create them.

Try it in the live demo: pick Resources in the view switcher.

createResourceColumnsView shows one time-grid column for each resource, such as a room or a person, for the anchor date. Pass a number of days to give each resource that many day columns. Resources with children appear as groups that collapse. Only the columns in view are rendered, so the view stays fast with hundreds of resources.

Add it

Copy registry/resource-columns-view into your app, along with registry/time-grid-view, whose columns, events and all-day row it reuses. You also need the theme and registry/calendar, as described in UI source. The view depends on @tanstack/react-virtual.

import { createResourceColumnsView } from "@/components/calendar/resource-columns-view/resource-columns-view";

export const Calendar = createCalendar<Meeting, Room>()({
  eventAdapter,
  views: [createResourceColumnsView<Meeting>({ getEventColor: (meeting) => meeting.color })],
});

The view is named resources. Previous and Next move by its number of days: createResourceColumnsView<Meeting>(options, 3) shows three days for each resource.

Resources

Pass the resources to Root. Each one has an id, a title, and optionally a parentId, which puts it in a group under that resource. Events list the resources they book in resourceIds, read through your event adapter's getResourceIds. An event that books several resources shows in each of their columns.

const rooms = [
  { data: null, id: "floor-1", title: "First floor" },
  { data: null, id: "orchid", parentId: "floor-1", title: "Orchid" },
  { data: null, id: "lotus", parentId: "floor-1", title: "Lotus" },
];

<Calendar.Root resources={rooms} defaultExpandedResourceIds={["floor-1"]} /* … */>

A group's children show while its ID is in expandedResourceIds. The chevron on a group's header toggles it. To control expansion yourself, pass expandedResourceIds and onExpandedResourceIdsChange. Groups start collapsed unless you list them in defaultExpandedResourceIds.

Avatars and icons

Columns often mix people and rooms, and a bare name doesn't say which is which. To show it, pass your own ResourceHeader. It draws each header's name row from small parts, so you can put a person's avatar or a room's icon before the name, as the preview above does. Teams there keep just their chevron.

Keep what the header needs in each resource's data, and give it a type. The view takes it as its second type parameter, so the component can read it:

import type { CalendarResource } from "@moltenship/calendar";
import { DoorOpenIcon } from "lucide-react";

import {
  ResourceHeaderIcon,
  ResourceHeaderTitle,
  ResourceHeaderToggle,
  type ResourceHeaderProps,
} from "@/components/calendar/resource-columns-view/resource-header";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";

// For example { data: { avatar: "/avatars/maya.png", kind: "person" }, id: "maya", title: "Maya" }
type Resource = CalendarResource<
  { kind: "room" } | { kind: "team" } | { avatar: string; kind: "person" }
>;

Then compose the parts: the chevron, an avatar for a person or a door for a room, and the name. Pass the component to the view:

function ResourceHeader({ resource, title }: ResourceHeaderProps<Resource>) {
  return (
    <>
      <ResourceHeaderToggle />
      {resource?.data.kind === "person" && (
        <ResourceHeaderIcon>
          <Avatar size="sm">
            <AvatarImage alt="" src={resource.data.avatar} />
            <AvatarFallback>{title.slice(0, 1)}</AvatarFallback>
          </Avatar>
        </ResourceHeaderIcon>
      )}
      {resource?.data.kind === "room" && (
        <ResourceHeaderIcon className="size-6 justify-center rounded-md bg-muted text-muted-foreground">
          <DoorOpenIcon className="size-3.5" />
        </ResourceHeaderIcon>
      )}
      <ResourceHeaderTitle />
    </>
  );
}

const view = createResourceColumnsView<Meeting, Resource>({ ResourceHeader });

The parts live in resource-header.tsx:

  • ResourceHeaderToggle is a group's chevron. It renders nothing for a resource without children.
  • ResourceHeaderIcon wraps anything you draw before the name. Screen readers skip it, because the name beside it already says who or what the column is.
  • ResourceHeaderTitle is the name, cut short with an ellipsis. Children replace it.
  • useResourceHeader() gives your own parts the resource, its name and its groups. DefaultResourceHeader is the toggle and the title.

resource is null for the Unassigned column. The view keeps the rest of the header: the group bands, the row heights and the group names screen readers hear. A row is 2rem tall, so keep icons at 24px (size-6) or smaller. Avatar is shadcn's: npx shadcn@latest add avatar. It shows the fallback, here the person's initial, until the image loads.

Anatomy

ResourceColumnsView renders the parts below. The view builds them itself from its projection, so they're not parts you compose: to change one, edit its file. The exception is each header's name row, which you can replace with ResourceHeader. The tree shows where each part lives.

  1. Resource columnsResourceColumnsView
  2. Group banduseResourceExpansion
  3. Resource headerResourceColumnsHeader
  4. All-day rowResourceColumnsAllDay
  5. Resource columnresourceIdAttributes
  6. Time gutter--calendar-gutter-width
  7. Timed eventTimeGridEvent
  8. Now lineuseNow
Rendered by ResourceColumnsView
ResourceColumnsView              resource-columns-view.tsx
├── ResourceColumnsHeader        resource-columns-header.tsx
│   └── Resource header          one per rendered resource
│       └── ResourceHeader       resource-header.tsx, or your own composition
├── ResourceColumnsDates         resource-columns-dates.tsx, over N days
├── ResourceColumnsAllDay        resource-columns-all-day.tsx
│   └── TimeGridAllDayCells      one set per rendered resource
└── ResourceColumnsBody          resource-columns-body.tsx, scrolls both ways
    ├── Time gutter              --calendar-gutter-width
    └── Resource column          data-resource-id
        └── TimeGridColumn       time-grid-column.tsx, one per day
            ├── TimeGridEvent    time-grid-event.tsx
            └── Now line         data-slot="time-grid-now"
  • useColumnVirtualizer in column-virtualizer.ts decides which columns render. Columns share the width until they are 128px per day, then the view scrolls sideways.
  • The header, all-day row and gutter sit beside the scrolling body, not over it. useAxisSync in axis-sync.ts moves them with the body's scroll, so a drag never lands on a column hidden behind them.

Options

Prop

Type

Set options when you create the view, or per calendar with viewOptions={{ resources: { unassigned: "bucket" } }} on Root.

What it shows

  • Header: each resource's name, or what your ResourceHeader draws. An expanded group's band names it across its own column and its children's columns. Over several days, a row under the names shows each day's weekday and date.
  • All-day row: each resource's all-day events, and timed events of 24 hours or more. Busy days collapse to three rows with "+N more" in every column at once, so the row keeps one height.
  • Columns: the same hours, events, daylight-saving bands and now line as the day view.
  • Editing: with onEventChange on Root, drag an event within its column to move or resize it, or into another resource's column to move it there. A move swaps the resource you dragged from for the one you dropped on and keeps the event's other resources. Drag across empty time or all-day cells to create an event in that resource. See Interactions.
  • Accessible names: events, slots and all-day cells include their resource, as in "Design review, Tuesday, October 6, 10:00 – 11:30 AM, Orchid". Announcements name it too: "Moved Design review to Tuesday, October 6, 10:00 – 11:30 AM, Cedar, Maya, Alex."

Keyboard

Tab into the preview above, or click a slot, and try these keys. Columns run resource by resource: each resource's days, then the next resource. Inside a column the keys are the day view's. The Accessibility page has the full map and what a screen reader hears.

FocusKeyEffect
A day or slot← →Same slot in the previous or next column, crossing into the next resource
A day or slot← → past the endsOn to the day before or after the range, in the last or first resource
A day or slotHome, EndFirst or last column
A day or slotPage Up, Page DownSame resource, a whole day (or range) earlier or later
A day or slot↑ ↓, EnterAs in the day view: slots, the all-day cell, and the events covering a slot
A day or slotSpaceStart an event there, in that column's resource
An eventSpacePick it up. ← → move it across columns, and into another resource it swaps that one resource

Focus can move to a resource that's scrolled out of view: its column renders and scrolls in, and it stays rendered while you scroll away, so focus is never lost.

Markup for interactions

The view connects the pointer layer for you. Each resource's cells sit inside an element with resourceIdAttributes(id), which sets data-resource-id: a drop or create inside it lands in that resource. Inside, the attributes are the day view's: data-date on day columns and all-day cells, data-all-day on all-day cells, data-event-id on events, and data-resize handles.

Edit on GitHub

Last updated on

On this page