@savvycal/calendar
v0.8.0
Published
A fully-featured calendar component library built with React, Tailwind CSS v4, and the Temporal API. Includes a resource grid view (resources as columns) and a day grid view (days as columns).
Readme
@savvycal/calendar
A fully-featured calendar component library built with React, Tailwind CSS v4, and the Temporal API. Includes a resource grid view (resources as columns) and a day grid view (days as columns).
Installation
npm install @savvycal/calendarPeer dependencies
react^18 || ^19react-dom^18 || ^19
The package ships temporal-polyfill as a direct dependency, so you don't need to install it separately.
CSS setup (Tailwind CSS v4)
Add three imports to your app's CSS in this exact order:
@import '@savvycal/calendar/preset.css';
@import 'tailwindcss';
@import '@savvycal/calendar/components.css';Why order matters: The preset registers @theme tokens and a @source directive before Tailwind processes them, so Tailwind can generate the utility classes the library uses. The components stylesheet must come after Tailwind so its custom CSS (e.g. the unavailable-time cross-hatch pattern) can reference Tailwind's generated values.
Quick start
import { ResourceGridView, Temporal } from '@savvycal/calendar';
const today = Temporal.Now.plainDateISO();
function App() {
return (
<ResourceGridView
date={today}
timeZone="America/Chicago"
resources={[
{ id: '1', name: 'Alice', color: '#3b82f6' },
{ id: '2', name: 'Bob', color: '#8b5cf6' },
]}
events={[
{
id: 'evt-1',
title: 'Meeting',
resourceId: '1',
startTime: today
.toPlainDateTime({ hour: 10 })
.toZonedDateTime('America/Chicago'),
endTime: today
.toPlainDateTime({ hour: 11 })
.toZonedDateTime('America/Chicago'),
},
]}
/>
);
}ResourceGridView props
Required
| Prop | Type | Description |
| ----------- | -------------------- | ----------------------------------------------------- |
| date | Temporal.PlainDate | The date to display. |
| timeZone | string | IANA time zone identifier (e.g. "America/Chicago"). |
| resources | CalendarResource[] | Array of resources (columns). |
| events | CalendarEvent[] | Array of timed and/or all-day events. |
Time axis
| Prop | Type | Default | Description |
| ---------- | ---------------- | ---------------------------------------------------- | ---------------------------------------------------- |
| timeAxis | TimeAxisConfig | { startHour: 0, endHour: 24, intervalMinutes: 60 } | Controls the visible hour range and gutter interval. |
Layout
| Prop | Type | Default | Description |
| ---------------- | ---------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| hourHeight | number | 60 | Height in pixels for one hour. |
| columnMinWidth | number | 120 | Minimum width in pixels for each resource column. |
| eventLayout | 'columns' \| 'stacked' | 'columns' | How overlapping events are laid out. 'columns' places them side-by-side; 'stacked' offsets them. |
| eventGap | number | — | Vertical gap in pixels between the edge of events and column borders. |
| stackOffset | number | 8 | Horizontal pixel offset for each stacked event (only applies when eventLayout is 'stacked'). |
| className | string | — | Class name applied to the root element. |
| classNames | ResourceGridViewClassNames | See Customizing styles. | Override default class names for internal elements. |
Events & interaction
| Prop | Type | Default | Description |
| --------------------- | -------------------------------------------------- | ------- | -------------------------------------------------------------------- |
| onEventClick | (event: CalendarEvent) => void | — | Called when an event is clicked. |
| onSlotClick | (info: { resource, startTime, endTime }) => void | — | Called when an empty time slot is clicked. |
| snapDuration | number | — | Snap interval in minutes for drag selection. |
| placeholderDuration | number | 15 | Duration in minutes for the hover placeholder shown before dragging. |
Selection
| Prop | Type | Default | Description |
| ----------------------- | ---------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| selectedRange | SelectedRange \| null | — | The currently selected time range (controlled). |
| onSelect | (range: SelectedRange \| null) => void | — | Called when the user selects or clears a range by dragging. |
| selectionAppearance | SelectionAppearance | — | How the selection is rendered. 'highlight' shows a translucent overlay; { style: 'event', eventData? } renders it as a phantom event. |
| dragPreviewAppearance | SelectionAppearance | — | Appearance of the drag preview while the user is actively dragging (before releasing). Falls back to selectionAppearance if not set. |
| selectionRef | Ref<HTMLDivElement> | — | Ref attached to the selection element, useful for positioning popovers (e.g. with Floating UI). |
| selectedEventId | string \| null | — | ID of the currently selected event (controlled). Applies selected styling and enables selectedEventRef. |
| selectedEventRef | Ref<HTMLDivElement> | — | Ref attached to the selected event element, useful for positioning popovers. |
Availability
| Prop | Type | Default | Description |
| -------------------- | ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------- |
| availability | Record<string, AvailabilityRange[]> | — | Map of resource ID to available time ranges. Times outside these ranges are shown as unavailable. |
| unavailability | Record<string, AvailabilityRange[]> | — | Map of resource ID to explicitly unavailable time ranges. Applied on top of availability. |
| defaultUnavailable | boolean | — | When true, resources without an entry in availability are shown as entirely unavailable. |
Render props
| Prop | Type | Description |
| -------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| renderHeader | (props: { resource: CalendarResource }) => ReactNode | Custom renderer for resource column headers. |
| renderEvent | (props: { event: TimedCalendarEvent, position: PositionedEvent }) => ReactNode | Custom renderer for timed events. |
| renderCorner | () => ReactNode | Custom renderer for the top-left corner cell (e.g. time zone label). |
DayGridView
A day-based grid view that shows days as columns (e.g. a week view).
Quick start
import { DayGridView, Temporal } from '@savvycal/calendar';
const today = Temporal.Now.plainDateISO();
const weekStart = today.subtract({ days: today.dayOfWeek - 1 });
const weekEnd = weekStart.add({ days: 6 });
function App() {
return (
<DayGridView
activeRange={{ startDate: weekStart, endDate: weekEnd }}
timeZone="America/Chicago"
events={[
{
id: 'evt-1',
title: 'Meeting',
resourceId: '',
startTime: today
.toPlainDateTime({ hour: 10 })
.toZonedDateTime('America/Chicago'),
endTime: today
.toPlainDateTime({ hour: 11 })
.toZonedDateTime('America/Chicago'),
},
]}
/>
);
}DayGridView props
Required
| Prop | Type | Description |
| ------------- | ------------------------------------------------- | ------------------------------------------------------ |
| activeRange | { startDate: PlainDate; endDate: PlainDate } | The date range to display (inclusive). |
| timeZone | string | IANA time zone identifier (e.g. "America/Chicago"). |
| events | CalendarEvent[] | Array of timed and/or all-day events. |
Availability
| Prop | Type | Default | Description |
| --------------------- | --------------------- | ------- | ------------------------------------------------------------------------------------------------ |
| availability | AvailabilityRange[] | — | Time ranges that are available. Times outside these ranges are shown as unavailable. |
| unavailability | AvailabilityRange[] | — | Time ranges explicitly marked unavailable. Applied on top of availability. |
| defaultUnavailable | boolean | — | When true and no availability ranges are provided, the entire grid is shown as unavailable. |
Layout, events & interaction
The following props work identically to ResourceGridView: timeAxis, hourHeight, columnMinWidth, eventLayout, eventGap, stackOffset, className, classNames (uses DayGridViewClassNames), onEventClick, snapDuration, placeholderDuration, selectionAppearance, dragPreviewAppearance, selectedEventId, selectedEventRef.
Selection
| Prop | Type | Description |
| --------------- | ------------------------------------------------- | ----------------------------------------------- |
| selectedRange | DayGridSelectedRange \| null | The currently selected time range (controlled). |
| onSelect | (range: DayGridSelectedRange \| null) => void | Called when the user selects or clears a range. |
| selectionRef | Ref<HTMLDivElement> | Ref attached to the selection overlay element. |
Slot click
| Prop | Type | Description |
| ------------- | ---------------------------------------------------------- | --------------------------------------- |
| onSlotClick | (info: { date, startTime, endTime }) => void | Called when an empty time slot is clicked. Receives the date (PlainDate) and time range. |
Render props
| Prop | Type | Description |
| -------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| renderHeader | (props: { date: PlainDate, isToday: boolean }) => ReactNode | Custom renderer for day column headers. |
| renderEvent | (props: { event: TimedCalendarEvent, position: PositionedEvent }) => ReactNode | Custom renderer for timed events. |
| renderCorner | (props: { timeZone: string }) => ReactNode | Custom renderer for the top-left corner cell (e.g. time zone label). |
DayGridSelectedRange
interface DayGridSelectedRange {
startTime: Temporal.ZonedDateTime;
endTime: Temporal.ZonedDateTime;
}DayGridViewClassNames
Extends GridViewClassNames with day-specific keys:
| Key | Description |
| ------------------ | ------------------------------------------- |
| headerWeekday | Weekday abbreviation text (e.g. "Mon"). |
| headerDayNumber | Day number text. |
| headerToday | Additional classes for today's day number. |
All shared keys from GridViewClassNames (root, grid, event, etc.) also apply.
Data types
CalendarResource
interface CalendarResource {
id: string;
name: string;
avatarUrl?: string;
color?: string;
}CalendarEvent
A discriminated union of TimedCalendarEvent and AllDayCalendarEvent:
interface TimedCalendarEvent {
id: string;
title: string;
resourceId: string;
startTime: Temporal.ZonedDateTime;
endTime: Temporal.ZonedDateTime;
allDay?: false;
color?: string;
clientName?: string;
status?: 'confirmed' | 'canceled' | 'tentative';
ariaLabel?: string;
metadata?: Record<string, unknown>;
}
interface AllDayCalendarEvent {
id: string;
title: string;
resourceId: string;
startDate: Temporal.PlainDate;
endDate: Temporal.PlainDate;
allDay: true;
color?: string;
clientName?: string;
status?: 'confirmed' | 'canceled' | 'tentative';
ariaLabel?: string;
metadata?: Record<string, unknown>;
}The optional ariaLabel field provides a plain-text label used for aria-label attributes and screen reader announcements. When title or clientName are React elements (not plain strings), set ariaLabel to avoid "[object Object]" in accessible labels.
AvailabilityRange
interface AvailabilityRange {
startTime: Temporal.ZonedDateTime;
endTime: Temporal.ZonedDateTime;
}SelectedRange
interface SelectedRange {
resourceId: string;
startTime: Temporal.ZonedDateTime;
endTime: Temporal.ZonedDateTime;
}TimeAxisConfig
interface TimeAxisConfig {
startHour?: number; // default: 0
endHour?: number; // default: 24
intervalMinutes?: number; // default: 60
}PositionedEvent
Provided to the renderEvent render prop with layout information:
interface PositionedEvent {
event: TimedCalendarEvent;
top: number;
height: number;
subColumn: number;
totalSubColumns: number;
}Customizing styles
CSS custom properties
The preset defines a set of --color-cal-* CSS custom properties under @theme. Override them in your own CSS to change the calendar's color palette:
| Variable | Light default | Description |
| --------------------------------- | ---------------- | --------------------------------- |
| --color-cal-surface | white | Background color of the calendar. |
| --color-cal-border | zinc-300 | Border color for grid lines. |
| --color-cal-text | zinc-950 | Primary text color (headers). |
| --color-cal-text-body | zinc-900 | Body text color (event titles). |
| --color-cal-text-muted | zinc-600 | Muted text color (times, labels). |
| --color-cal-event-bg | zinc-100 @ 90% | Event background. |
| --color-cal-event-ring | zinc-900 @ 15% | Event border ring. |
| --color-cal-event-ring-selected | zinc-900 @ 30% | Ring color for selected events. |
| --color-cal-event-shadow | black @ 10% | Shadow color for selected events. |
| --color-cal-now | orange-500 | Now indicator line color. |
| --color-cal-slot-highlight | blue-400 @ 15% | Hover slot highlight. |
| --color-cal-selection | blue-400 @ 25% | Drag selection highlight. |
Example override:
@import '@savvycal/calendar/preset.css';
@import 'tailwindcss';
@import '@savvycal/calendar/components.css';
@theme {
--color-cal-now: var(--color-red-500);
--color-cal-selection: color-mix(
in oklab,
var(--color-indigo-400) 25%,
transparent
);
}Dark mode
The preset uses a .dark class convention (matching Tailwind's @custom-variant dark). Add the dark class to a parent element to switch to dark mode. All --color-cal-* variables are automatically overridden in the .dark scope.
The classNames prop
Every internal element can be restyled via the classNames prop. Each key maps to a specific part of the calendar:
| Key | Description |
| -------------------- | ------------------------------------------------ |
| root | Outermost wrapper (scroll container). |
| grid | The CSS grid element. |
| cornerCell | Top-left corner cell (sticky). |
| headerCell | Resource column header cell (sticky). |
| headerName | Resource name text inside the header. |
| headerAvatar | Avatar image inside the header. |
| gutterCell | Time gutter cell for hour labels (sticky). |
| gutterCellMinor | Time gutter cell for sub-hour intervals. |
| gutterLabel | Text label inside gutter cells. |
| bodyCell | Hour-start body cell in the grid. |
| bodyCellMinor | Sub-hour body cell in the grid. |
| eventColumn | Container for events within a resource column. |
| event | Individual event element. |
| eventSelected | Additional classes applied to a selected event. |
| eventColorBar | Vertical color bar on the left edge of an event. |
| eventTitle | Event title text. |
| eventTime | Event time text. |
| eventClientName | Client name text on the event. |
| nowIndicator | Horizontal "now" indicator line. |
| slotHighlight | Hover highlight on time slots. |
| selectionHighlight | Drag selection highlight overlay. |
| allDayCell | All-day event row cell. |
| unavailableOverlay | Unavailable time cross-hatch overlay. |
resourceGridViewDefaults
The library exports resourceGridViewDefaults, an object containing the default Tailwind classes for every classNames key. Use it to extend rather than replace defaults:
import {
ResourceGridView,
resourceGridViewDefaults,
cn,
} from '@savvycal/calendar';
<ResourceGridView
classNames={{
event: cn(resourceGridViewDefaults.event, 'rounded-lg'),
headerCell: cn(resourceGridViewDefaults.headerCell, 'border-b'),
}}
// ...other props
/>;The cn() utility (re-exported from the library) merges Tailwind classes with conflict resolution via tailwind-merge.
Selection & interaction
Controlled selection
Selection is controlled via selectedRange and onSelect:
const [selectedRange, setSelectedRange] = useState<SelectedRange | null>(null);
<ResourceGridView
selectedRange={selectedRange}
onSelect={setSelectedRange}
// ...
/>;Selection appearance
'highlight'— renders a translucent overlay on the selected time range.{ style: 'event', eventData? }— renders the selection as a phantom event. PasseventDatato customize its title, color, etc.
<ResourceGridView
selectionAppearance={{
style: 'event',
eventData: {
title: 'New appointment',
color: '#3b82f6',
},
}}
// ...
/>Popover positioning with selectionRef
Attach selectionRef to use Floating UI (or a similar library) to position a popover next to the selection:
import {
useFloating,
autoUpdate,
flip,
shift,
offset,
} from '@floating-ui/react';
const { refs, floatingStyles } = useFloating({
open: selectedRange !== null,
middleware: [flip(), shift(), offset(5)],
placement: 'right',
whileElementsMounted: autoUpdate,
});
<ResourceGridView
selectionRef={refs.setReference}
selectedRange={selectedRange}
onSelect={setSelectedRange}
// ...
/>;
{
selectedRange && (
<div ref={refs.setFloating} style={floatingStyles}>
{/* Popover content */}
</div>
);
}Event selection & popover positioning
Use selectedEventId and selectedEventRef to track which event is selected and position a popover next to it:
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const { refs, floatingStyles } = useFloating({
open: selectedEventId !== null,
onOpenChange: (open) => {
if (!open) setSelectedEventId(null);
},
middleware: [flip(), shift(), offset(5)],
placement: 'right',
whileElementsMounted: autoUpdate,
});
<ResourceGridView
selectedEventId={selectedEventId}
selectedEventRef={refs.setReference}
onEventClick={(event) => setSelectedEventId(event.id)}
// ...
/>;
{
selectedEventId && (
<div ref={refs.setFloating} style={floatingStyles}>
{/* Event popover content */}
</div>
);
}Slot and event clicks
onSlotClickfires when clicking an empty time slot, receiving theresource,startTime, andendTime.onEventClickfires when clicking an event.snapDuration(in minutes) controls the snap interval for drag-to-select.placeholderDuration(in minutes, default15) controls the height of the hover placeholder shown before the user starts dragging.
Exports
// Components
export { ResourceGridView, DayGridView } from '@savvycal/calendar';
// Defaults
export {
resourceGridViewDefaults,
dayGridViewDefaults,
} from '@savvycal/calendar';
// Temporal polyfill
export { Temporal } from '@savvycal/calendar';
// Utility
export { cn } from '@savvycal/calendar';
// Types
export type {
CalendarResource,
CalendarEvent,
TimedCalendarEvent,
AllDayCalendarEvent,
TimeSlot,
AvailabilityRange,
TimeAxisConfig,
ResourceGridViewProps,
ResourceGridViewClassNames,
GridViewClassNames,
DayGridViewProps,
DayGridViewClassNames,
DayGridSelectedRange,
PositionedEvent,
SelectedRange,
SelectionAppearance,
SelectionEventData,
EventLayout,
} from '@savvycal/calendar';Accessibility
The calendar includes built-in ARIA support for screen readers:
- Grid region — The grid container has
role="region"witharia-roledescription="calendar"and a descriptivearia-label(e.g. "Schedule for Monday, February 9, 2026"). - Column headers — Each resource header has
role="columnheader". - Event labels — Interactive events include an
aria-labelwith the event title, time range, client name, and status (if canceled or tentative). All-day events are labeled with the title and "all day". - Live region announcements — A visually-hidden
aria-live="polite"region announces state changes:- Selecting an event:
"Selected: Meeting, 2 pm to 3 pm, Jane Doe" - Selecting a time range:
"Selected time: 2 pm to 3 pm, Dr. Smith"
- Selecting an event:
- Decorative overlays — The now indicator, unavailability overlays, selection overlay, and slot highlights are marked
aria-hidden="true"to reduce noise.
Custom renderers
When using renderEvent, the library does not add aria-label or role attributes to the wrapper <div>. Your custom renderer is responsible for providing its own accessible markup.
License
MIT
