Resource columns
A day of rooms or people side by side, grouped by team, with hundreds of columns.
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:
ResourceHeaderToggleis a group's chevron. It renders nothing for a resource without children.ResourceHeaderIconwraps anything you draw before the name. Screen readers skip it, because the name beside it already says who or what the column is.ResourceHeaderTitleis the name, cut short with an ellipsis. Children replace it.useResourceHeader()gives your own parts the resource, its name and its groups.DefaultResourceHeaderis 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.
- Resource columns
ResourceColumnsView - Group band
useResourceExpansion - Resource header
ResourceColumnsHeader - All-day row
ResourceColumnsAllDay - Resource column
resourceIdAttributes - Time gutter
--calendar-gutter-width - Timed event
TimeGridEvent - Now line
useNow
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"useColumnVirtualizerincolumn-virtualizer.tsdecides 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.
useAxisSyncinaxis-sync.tsmoves 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
ResourceHeaderdraws. 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
onEventChangeonRoot, 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.
| Focus | Key | Effect |
|---|---|---|
| A day or slot | ← → | Same slot in the previous or next column, crossing into the next resource |
| A day or slot | ← → past the ends | On to the day before or after the range, in the last or first resource |
| A day or slot | Home, End | First or last column |
| A day or slot | Page Up, Page Down | Same resource, a whole day (or range) earlier or later |
| A day or slot | ↑ ↓, Enter | As in the day view: slots, the all-day cell, and the events covering a slot |
| A day or slot | Space | Start an event there, in that column's resource |
| An event | Space | Pick 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.
Last updated on