Availability
Working hours, closed days and holidays, people and rooms in their own time zones, shading you can restyle, a busy or free status, and a policy that keeps bookings inside working hours.
Business hours are open, and everything else is shaded: the evenings, the weekend and Friday's holiday. Availability is when the calendar, or a person or a room, can be booked. Views shade the time outside it, the month mutes closed days, and a policy decides what happens to a change that lands outside it: the change can be blocked, or saved with a warning. Drag an event into the evening above and the screen reader strip says "outside working hours". Try it with people in other time zones in the live demo, where the Working hours setting switches between hidden, shown, warn and block.
Add working hours
Pass the calendar's hours to Root as availability, and add availabilityPolicy to its policies:
import { availabilityPolicy, type CalendarAvailability } from "@moltenship/calendar";
const time = Temporal.PlainTime.from;
const officeHours: CalendarAvailability = {
weekly: [{ days: [1, 2, 3, 4, 5], start: time("09:00"), end: time("17:00") }],
exceptions: [
{ date: Temporal.PlainDate.from("2026-12-25"), label: "Christmas" },
{
date: Temporal.PlainDate.from("2026-12-24"),
hours: [{ start: time("09:00"), end: time("12:00") }],
},
],
};
const policies = [availabilityPolicy<Meeting, string, string>({ mode: "warn" })];
<Calendar.Root availability={officeHours} policies={policies} {...props} />;Without availabilityPolicy, views still shade the hours, but nothing stops a change outside
them.
Hours and exceptions
- Weekly hours. Each rule opens
starttoendon itsdays, ISO days of the week from1(Monday) to7(Sunday). A day can have several rules, such as a lunch break between two. Anendat or beforestartruns past midnight, so22:00to06:00is a night and00:00to00:00a whole day. Leaveweeklyout to open every day all day. - Exceptions. An exception replaces one date's hours. Without
hoursit closes the date; itslabel, such as "Christmas", names the closed day in the month. - Time zones. The hours are on the display time zone's clock unless
availability.timeZonesays otherwise, such as the office's. - Daylight saving time. Hours are half-open,
[start, end), and read on the wall clock. On a spring-forward day, hours that start in the skipped hour open when the clock jumps. On a fall-back day, hours that end in the repeated hour close after the repeat, so they cover both.
Invalid rules, exceptions and time zones are left out and reported through onDiagnosticsChange
as invalid-availability. The calendar never throws over them.
People and rooms
Resources follow the calendar's hours unless they have their own:
import type { AvailabilityRule } from "@moltenship/calendar";
const weekdays = (start: string, end: string): AvailabilityRule => ({
days: [1, 2, 3, 4, 5],
start: Temporal.PlainTime.from(start),
end: Temporal.PlainTime.from(end),
});
const resources = [
{
id: "orchid",
title: "Orchid",
data: room,
availability: { weekly: [weekdays("08:00", "20:00")] },
},
{ id: "alex", title: "Alex", data: person, timeZone: "America/Los_Angeles" },
{ id: "lee", title: "Lee", data: person, timeZone: "Asia/Kolkata" },
];- Own hours. A resource's
weeklyreplaces the calendar's. Itsexceptionsadd to the calendar's, so a company holiday closes every room, and the resource's own win on the same date. - Own time zone. A resource with a
timeZonekeeps its hours on that clock: Alex's 9 to 5 in Los Angeles shows as the evening in Berlin. - One time axis. Resource columns and the timeline keep a single axis in the display time zone. Only the shading follows each resource's clock, and its header shows its local time and UTC offset, such as "7:18 AM GMT-7", while that clock differs from the display time zone's.
- The same instant. Moving an event to a resource in another time zone keeps its instant: a 9:00 call in New York stays 9:00 in New York, which is 14:00 for London.
Customize the shading
Each stretch of closed time is a div hidden from screen readers. It carries why it is closed and
where it meets open time, so CSS can restyle it:
| Attribute or variable | Value |
|---|---|
data-slot | time-grid-unavailable, resource-timeline-unavailable or month-closed |
data-reason | outside-hours, closed-day (no weekly hours that day) or exception (a closed date) |
data-label | The exception's label, such as "Company holiday" |
data-opens-before, data-opens-after | Present where open time meets the band's start or end in the view |
--band-start, --band-end | Where the band starts and ends along its column or row, as percentages |
/* Gradient: closed time fades out over the last hour and a half before working hours. */
[data-slot="time-grid-unavailable"] {
--calendar-unavailable: color-mix(in oklch, var(--muted-foreground) 16%, transparent);
/* Never more than half the band, so a short closure between open hours still shows. */
--fade: min(calc(var(--calendar-hour-height) * 1.5), 50%);
}
[data-slot="time-grid-unavailable"][data-opens-after] {
mask-image: linear-gradient(to bottom, black calc(100% - var(--fade)), transparent);
}
[data-slot="time-grid-unavailable"][data-opens-before] {
mask-image: linear-gradient(to top, black calc(100% - var(--fade)), transparent);
}
[data-slot="time-grid-unavailable"][data-opens-before][data-opens-after] {
mask-image: linear-gradient(
transparent,
black var(--fade),
black calc(100% - var(--fade)),
transparent
);
}
/* Hatching, with holidays tinted. */
[data-slot="time-grid-unavailable"] {
background: repeating-linear-gradient(
-45deg,
transparent 0 6px,
color-mix(in oklch, var(--muted-foreground) 25%, transparent) 6px 7px
);
}
[data-slot="time-grid-unavailable"][data-reason="exception"] {
background-color: color-mix(in oklch, var(--brand) 8%, transparent);
}
/* A brand tint, through the token every view reads. */
:root {
--calendar-unavailable: color-mix(in oklch, var(--brand) 12%, transparent);
}The default tint is --calendar-unavailable for closed time and --calendar-closed for closed
days in the month. In the dark theme closed time is lighter than open time, since a near-black
surface has little room to get darker. Recipes built on --muted-foreground or your own tokens
follow the theme the same way.
To draw something else, pass renderUnavailable to a view. It works like Base UI's render:
give it an element, or a function of the band's props and its state. The state holds the band
(its reason, label and edges), the column's date and the resource whose hours it shows,
so rooms can look different from people. Keep the props on your element: they place it and keep it
hidden from screen readers.
import type { UnavailableTimeRender } from "@/components/calendar/unavailable-time";
const tintRooms: UnavailableTimeRender = (props, { resource }) =>
// Views hand over resources as the root got them; read your own fields from `data`.
(resource?.data as Room | Person | undefined)?.kind === "room" ? (
<div
{...props}
style={{
...props.style,
"--calendar-unavailable": "color-mix(in oklch, var(--color-sky-500) 14%, transparent)",
}}
/>
) : (
<div {...props} />
);
const views = [
createWeekView<Meeting>({ renderUnavailable: tintRooms }),
createResourceColumnsView<Meeting, Room | Person>({ renderUnavailable: tintRooms }),
];Every view takes renderUnavailable: week, day, month, the scrolling views, resource columns and
the timeline.
Busy or available
Working hours and events answer two different questions. Unavailable means outside working hours. Busy means an event occupies the time. A person can be busy outside their hours, at a late call, or free during them. The status API keeps the two apart:
import { availabilityAt, busyAt, resourceStatusAt } from "@moltenship/calendar";
availabilityAt(availability, now, "maya");
// { available: true, closesAt: Temporal.Instant }
// { available: false, reason: "outside-hours" | "closed-day" | "exception", label?, opensAt }
// `events` is the root's event snapshot, as views get it in their props.
busyAt(events, now, { resourceId: "maya", timeZone: "Europe/Berlin" });
// { busy: true, events: [...], freeAt: Temporal.Instant }
// { busy: false, nextEventAt: Temporal.Instant | null }- Time zones. Hours are read on the resource's own clock, as the shading is, and are safe across daylight saving changes. All-day and floating events count on the wall clock you pass, usually the display time zone, as the views show them.
- Which events are busy. Timed and floating events are; all-day ones, such as a holiday
banner, aren't, as in Google Calendar. The event adapter's
isBusyoverrides that; see Customize. - Overlaps.
eventslists everything in progress.freeAtis when the last of a run of overlapping or back-to-back events ends, so a 10:00 standup followed by an 11:00 review is busy until the review ends. - Horizons.
opensAtandclosesAtlook up to a year ahead, andfreeAtandnextEventAta month ahead. They arenullpast that.
In React, useResourceStatus(resourceId, at?) returns both, plus changesAt, the next instant
either can change. Without at it follows the calendar's clock or now. It re-renders only
when the status changes, waking at changesAt rather than on every tick. It is null while
server rendering, unless the calendar fixes now.
import { useResourceStatus } from "@moltenship/calendar";
function Presence({ id }: { id: string }) {
const status = useResourceStatus(id);
if (status === null) return null;
return status.busy.busy ? "In a meeting" : status.availability.available ? "Around" : "Away";
}Busy badge in resource views
Set resourceStatus: true on the resource columns or timeline view to show a badge on each
resource:
| State | Shows |
|---|---|
| Busy | "Busy until 3:00 PM", while events occupy it |
| Free | "Free until 5:00 PM", until its next event or closing time |
| Outside hours | "Outside hours until Mon 9:00 AM", until it opens |
| Closed today | "Closed today", or "Closed today, Company holiday" |
The words carry the state; the dot before them is colored with --calendar-busy or
--calendar-free. Times are on the display clock, with the weekday within the next six days and the date further out. Busy
wins over the hours, so a late call shows as busy. The badge is shadcn's Base UI Badge. In
your own ResourceHeader, add ResourceHeaderStatus where you want it:
import {
ResourceHeaderStatus,
ResourceHeaderTitle,
ResourceHeaderToggle,
} from "@/components/calendar/resource-columns-view/resource-header";
function ResourceHeader() {
return (
<>
<ResourceHeaderToggle />
<span className="flex min-w-0 flex-col">
<ResourceHeaderTitle />
<ResourceHeaderStatus />
</span>
</>
);
}ResourceHeaderStatus shows nothing for a group, whose own bookings aren't its members', or
without resourceStatus, which makes room for it in the header. For a badge anywhere else,
useResourceStatusSummary(environment, resourceId) from resource-status.tsx returns the words
and state, and ResourceStatusBadge draws them; environment is the locale and timeZone a
view gets in its props.
Scheduling: who's busy?
While you place an event, by dragging or with the keyboard, each resource's header says whether it's busy then: Busy when an event overlaps the draft, Outside hours when the time is outside its hours, Free otherwise. Create or move an event above, and watch the row headers. The draft's own event never conflicts with itself, and events that only touch don't conflict.
It all rests on one function. busyResources answers for many resources at once, with one query
of the event index, bucketed by each event's resources, so a busy person never marks the others:
import { busyResources } from "@moltenship/calendar";
const busy = busyResources(
events,
{ start, end },
{
exclude: "evt_42", // the event being moved, if any
resourceIds: ["room-orchid", "room-lotus", "maya"], // or leave out for every resource
timeZone: "Europe/Berlin", // for all-day and floating events
},
);
busy.get("maya"); // [standup, review]: the events that make Maya busy then
busy.has("room-lotus"); // false: free resources are absentThe range can also be a placement, all-day or floating, read on timeZone. In React:
| Hook | Returns |
|---|---|
useBusyResources(range, opts?) | busyResources over the root's events, once per change of events or range |
useCalendarDraft() | The change being placed, by pointer or keyboard: { eventId, proposed }, or null |
useDraftBusy(resourceId) | A resource during the draft: { events, outsideHours }, or null without one |
Drafts step by slots, so these change at most once a step. useDraftBusy works the draft's busy
resources out once for every header that asks. During a pointer drag or a keyboard create, a header
re-renders only when its own answer changes; a keyboard move or resize changes the events the view
shows on each step, so headers re-render then too, which stays cheap.
An all-day draft shows who has busy time that day, even though an all-day event is free by default
and so doesn't conflict under conflictPolicy; the status is information, not a rule. To flag or prevent double-booking on save, add conflictPolicy({ mode: "warn" | "block" })
to the root's policies; it runs the same query, with the reason "resource-conflict".
Customize
Everything the views draw is a layer over these hooks, so you can replace any of it.
Your own status in the header. Pass resourceStatus options instead of true. DraftStatus
draws the status during a draft in place of the badge, and gets the resource, its busy events and
whether the draft is outside its hours. Headers also carry data-busy and data-outside-hours
during a draft, so CSS alone can style them.
function AvatarRing({ resource, events, outsideHours }: ResourceDraftStatusProps) {
const ring =
events.length > 0 ? "ring-red-500" : outsideHours ? "ring-amber-500" : "ring-green-500";
return (
<span
className={`size-5 rounded-full ring-2 ${ring}`}
title={events.map((e) => e.title).join(", ")}
/>
);
}
createResourceColumnsView<Meeting>({ resourceStatus: { DraftStatus: AvatarRing } });[data-slot="resource-header"][data-busy] {
background: color-mix(in oklch, var(--calendar-busy) 12%, transparent);
}Your own words. labels replaces the English the badge shows and screen readers hear, now and
during a draft. until is a time on the display clock, absent during a draft:
createTimelineWeekView<Meeting>({
resourceStatus: {
labels: {
busy: (until) => (until ? `Occupé jusqu'à ${until}` : "Occupé"),
free: (until) => (until ? `Libre jusqu'à ${until}` : "Libre"),
outsideHours: (until) => (until ? `Hors horaires jusqu'à ${until}` : "Hors horaires"),
closedToday: (label) => (label ? `Fermé aujourd'hui, ${label}` : "Fermé aujourd'hui"),
},
},
});Your own idea of busy. The event adapter's isBusy is the one place that decides, for the
status, the draft headers, busyResources and conflictPolicy alike:
const eventAdapter = defineEventAdapter<Meeting>()({
// ...getId, getInterval, getResourceIds, getTitle
// Focus time and tentative meetings leave you bookable; all-day ones block when marked busy.
isBusy: (meeting) =>
meeting.title !== "Focus time" &&
meeting.showAs !== "tentative" &&
(meeting.kind !== "all-day" || meeting.showAs === "busy"),
});Your own "who's busy" UI. Any component can ask. The live demo's
event dialog has a room and people picker split into Available and Busy, built on
useBusyResources rather than shipped as a part; busy rooms stay selectable, since a double
booking can be on purpose:
function RoomPicker({ interval, eventId }: { interval: CalendarEventInterval; eventId?: string }) {
const busy = useBusyResources(interval, { exclude: eventId });
const [free, taken] = partition(rooms, (room) => !busy.has(room.id));
return (
<>
<Group title="Available" rooms={free} />
<Group
title="Busy"
rooms={taken}
chip={(room) =>
busy
.get(room.id)!
.map((e) => e.title)
.join(", ")
}
/>
</>
);
}Keep changes inside
availabilityPolicy({ mode }) checks every move, resize and create, whether it comes from a drag,
the keyboard, or actions.proposeEventChange. An event with resources must fit the hours of each
resource it books, on that resource's clock: its own hours, or the calendar's when it has none. So a
room open until 20:00 takes an 18:00 booking even when the office closes at 17:00. An event without
resources must fit the calendar's hours. A timed event must fall in open time, and an all-day event
on dates with open time. Only what a change alters is checked, so events already outside the hours
can still be edited.
| Mode | Outside the hours, the change is… |
|---|---|
"block" | Rejected with the reason "outside-availability", and the event goes back where it was. |
"warn" | Saved, with "outside-availability" in its warnings, so you can say so or ask to confirm. |
While a gesture is in progress, its preview shows what the policies would say: a drag preview or a
keyboard-moved event gets a dashed outline, red when the change would be rejected and amber when it
would be saved with a warning. Your own UI can ask the same question with
actions.checkEventChange(proposal), which runs the policies without saving anything.
Keyboard and screen readers
Availability adds no keys and no tab stops; each view keeps its key map. The shading is hidden from screen readers, which hear the same information in words:
| Where | What a screen reader hears |
|---|---|
| A time-grid slot, with ↑ ↓ | "Thursday, October 8 at 8:45 AM, outside working hours" |
| A month day or a timeline day | "Friday, December 25, 2026, Christmas, closed, no events" |
| A step of a move, resize or create | "Thursday, October 8, 4:45 – 5:15 PM, outside working hours" |
| A blocked drop | "Standup can't go outside working hours, so it's back where it was." |
| A saved change with a warning | "Moved Standup to Thursday, October 8, 4:45 – 5:15 PM, outside working hours." |
| A resource header in another zone | "Lee, local time 7:48 PM GMT+5:30" |
| A resource header with its status | "Maya, Busy until 3:00 PM" |
| A resource header during a draft | "Maya, Busy", or "Leo, Outside hours" |
The messages live in announcements.ts in your copy of the registry source, ready to translate.
Anatomy
- Time grid
TimeGridView - Day header
TimeGridHeader - All-day row
TimeGridAllDay - Title
- Time
- Timed event
TimeGridEvent - Resize handle
data-resize="end" - Time gutter
--calendar-gutter-width - Outside working hours
TimeGridAvailability - Now line
useNow - Overlap lanes
TimeGridEventLayout - Day column
TimeGridColumn
TimeGridColumn time-grid-column.tsx
├── TimeGridAvailability time-grid-availability.tsx, data-slot="time-grid-unavailable"
├── TimeGridAnnotations time-grid-annotations.tsx
└── TimeGridEvent time-grid-event.tsx- Time grid and resource columns.
TimeGridAvailabilitydraws anUnavailableTimeband behind the events for each closed stretch of a column, in--calendar-unavailable. - Timeline.
TimelineAvailabilityshades each resource's closed time along hour rows, and its closed days along day rows, whose cells also getdata-closed. - Month. A closed day gets a
month-closedband in--calendar-closed, light enough that its date and events stay readable, and its cell getsdata-closed. - Resource headers.
ResourceHeaderTimeshows a resource's local time andResourceHeaderStatusits status; add them to your ownResourceHeadercomposition.
Your own view
The shading is separate from the event projection, like annotations, so working hours never change event layout. These hooks give a view what to draw, for the calendar's hours or, with a resource ID, that resource's:
| Hook | Returns |
|---|---|
useTimeGridAvailability(columns, id?) | The closed bands of each time-grid column, as fractions of 24 h |
useTimelineAvailability(columns, id?) | The closed stretches along a timeline, as fractions of its width |
useClosedDates(range, id?) | The dates with no open time, keyed by ISO date, with a reason and label |
useResourceStatus(id?, at?) | Working hours and events at an instant, and when either changes |
useCalendarAvailability() | The resolved hours: calendar, forResource(id), timeZone |
Bands and stretches carry their reason, label, opensBefore and opensAfter, as the
attributes above do.
Each schedule answers available(range), unavailable(range) and isAvailable(range) for any
instant range, and resolves each date once, so views can ask on every render.
Data model
Prop
Type
A resource's availability takes weekly and exceptions, and its timeZone sits on the
resource itself.
Last updated on