@innosolutions/inno-calendar
v2.0.2
Published
A headless-first, fully customizable React calendar
Readme
@innosolutions/inno-calendar
A React calendar that behaves like Google Calendar and lets your application own everything domain-specific.
Upgrading from 1.x? See CHANGELOG.md — 2.0.0 removes
slots,classNamesand the/presetsentry point.
What it owns, and what it doesn't
The package owns the parts that are the same in every calendar: the grid, the header, the floating card a drag opens, the detail popover, the filter rail, the drag-and-drop, the placement maths.
Your application owns the parts that differ: what an event is, what a form asks for, what a detail card shows, what you can filter by. You pass those in as components. There is one extension mechanism — render props — not three.
<InnoCalendar
events={events}
eventCard={(event, { compact }) => <MyCard event={event} compact={compact} />}
eventDetail={(event, { close, edit }) => <MyDetail … />}
eventForm={({ selection, event }, { close, expand }) => <MyForm … />}
filters={({ close }) => <MyFilters onDone={close} />}
/>Installation
npm install @innosolutions/inno-calendarreact and react-dom (18 or 19) are the only peer dependencies. Everything
else the calendar needs — Radix popover, dropdown, tooltip and dialog, plus
lucide-react — comes with it.
Quick start
import { InnoCalendar, type CalendarEvent } from "@innosolutions/inno-calendar";
import "@innosolutions/inno-calendar/styles";
const events: CalendarEvent[] = [
{
id: "1",
title: "Team meeting",
startDate: new Date("2026-02-04T09:00:00"),
endDate: new Date("2026-02-04T10:00:00"),
color: "blue",
},
];
function MyCalendar() {
return (
<InnoCalendar
events={events}
initialView="week"
onEventClick={(event) => console.log(event)}
onSlotSelect={({ startDate, endDate }) => console.log(startDate, endDate)}
/>
);
}That renders a working calendar. Everything below is opt-in.
Views
| View | Description |
| --- | --- |
| day | Single day, hourly grid |
| 4day | Four days |
| week | Seven days with time slots |
| month | Monthly grid |
| year | Twelve-month overview |
| agenda | Scrollable list |
| resource-day / resource-week | One column per resource |
| timeline-day / timeline-3day / timeline-week | One row per resource |
The event
interface CalendarEvent<TData = Record<string, unknown>> {
id: string;
title: ReactNode;
startDate: Date;
endDate: Date;
color?: "blue" | "green" | "red" | "yellow" | "purple"
| "orange" | "pink" | "teal" | "gray" | "indigo";
hexColor?: string; // overrides `color`
description?: ReactNode;
isCanceled?: boolean;
isAllDay?: boolean;
isRecurring?: boolean;
resourceId?: string; // resource and timeline views
// Client-side filtering. Skip it if you filter server-side.
scheduleTypeId?: number;
participants?: ICalendarUser[];
data?: TData; // your domain fields live here
}Put your own fields on data. The calendar never reads it — it hands the whole
event back to your eventCard, eventDetail and eventForm, which are the
only things that know what a "property" or an "opportunity" is.
type MyData = { raw: AgendaRow };
<InnoCalendar<MyData>
events={rows.map((row) => ({ …, data: { raw: row } }))}
eventCard={(event) => <MyCard row={event.data.raw} />}
/>A handful of domain-shaped fields (meetingTookPlace, companyId, user, …)
still exist at the top level and are marked @deprecated. They will go in 3.0.
Extension points
Each is a function returning JSX. Pass one to take that surface over; leave it out and the calendar handles it.
eventCard — the block in the grid
eventCard={(event, { compact }) => (
<MyCard event={event} compact={compact} />
)}compact is true in the month view and other tight rows.
eventDetail — what a click opens
eventDetail={(event, { close, edit }) => (
<MyDetail event={event} onClose={close} onEdit={edit} />
)}edit is present only when an eventForm is there to open. The calendar
supplies the container: a popover beside the event, or a bottom sheet on a
narrow screen, capped to the room available and scrolling inside.
eventForm — create and edit
eventForm={({ selection, event }, { close, expand }) => (
<MyForm
start={selection.startDate}
end={selection.endDate}
event={event} // present when editing
onDone={close}
onExpand={expand} // ask for the wider card
/>
)}The calendar owns the card — placement, dragging it by its handle, Escape,
click-outside, the height cap, the phone sheet. You own what is inside it.
Pair it with the exported WhenRow and RecurrenceField so your form's time
row behaves like the built-in one.
A drag on the grid opens it on that range. So does the Add button, on the slot
happening now. ref.current.openForm() does the same from anywhere.
filters — the rail
filters={({ close }) => <MyFilters onDone={close} />}The calendar supplies the rail on wide screens and a sheet on narrow ones;
close puts the sheet away after a filter that applies on tap.
Props
<InnoCalendar
// Data
events={events}
users={users} // resource rows
isLoading={isFetching}
// Extension points
eventCard={…}
eventDetail={…}
eventForm={…}
filters={…}
// Initial state
initialView="week"
initialDate={new Date()}
locale="fr"
// Callbacks
onEventClick={(event) => {}}
onSlotSelect={(selection) => {}}
onSlotClick={(date, hour) => {}}
onAddEvent={() => {}}
onEventDrop={(result) => {}}
onDateChange={(date, view) => {}}
onViewChange={(view) => {}}
// Chrome
showHeader
showSidebar
createLabel="Add"
labels={{ header: {…}, agenda: {…}, filters: "Filters" }}
headerActions={<MyActions />}
settingsContent={<MySettings />}
sidebarContent={<MyRail />}
// Behaviour
minSelectionMinutes={30}
preferencesConfig={{ defaults: {…} }}
resourceColumnConfig={{…}}
alwaysShowResources={resources}
/>onEventDrop is worth a note: without it, a drag or resize opens the form
on the new times rather than writing them, because a reschedule that mails the
participants should be confirmed. Pass a handler to take that over and get the
optimistic save with undo instead.
The ref
const calendar = useRef<ICalendarRefHandle>(null);
calendar.current?.openForm(); // new, on the slot happening now
calendar.current?.openForm({ start, end }); // new, on a range
calendar.current?.openForm({ event }); // edit, on its own times
calendar.current?.scrollToToday();
calendar.current?.focusEvent(id);Working hours and preferences
preferencesConfig={{
defaults: {
slotDuration: 30,
visibleHours: { from: 7, to: 20 },
showWorkingHoursOnly: true,
workingHours: { 1: { from: 9, to: 17 }, /* 0 = Sunday */ },
badgeVariant: "colored",
},
}}Preferences persist per user in localStorage; defaults is what a user who
has never changed anything sees.
Styling
import "@innosolutions/inno-calendar/styles";Theme and dimensions are CSS custom properties:
:root {
--inno-border-color: #e5e7eb;
--inno-background: #ffffff;
--inno-foreground: #111827;
--inno-muted: #f3f4f6;
--inno-primary: #3b82f6;
--inno-hour-height: 96px;
--inno-header-height: 56px;
--inno-sidebar-width: 200px;
--inno-event-blue: #3b82f6; /* …and green, red, yellow, purple,
orange, pink, teal, gray, indigo */
}Dark mode reads the same names under .dark.
Anything more specific than those is CSS against the package's class names, which is not yet a stable contract — see the note in the changelog.
Stacking
Anything you portal from inside a calendar surface has to sit above it, and guessing a number that works today is how that breaks later:
import { IC_Z_LAYER } from "@innosolutions/inno-calendar";
<SelectContent style={{ zIndex: IC_Z_LAYER }}>IC_Z_CARD (1000) is the floating card, IC_Z_LAYER (1100) menus and pickers
opened from one, IC_Z_MODAL (1200) a dialog on top of that.
TypeScript
import type {
CalendarEvent,
ICalendarRefHandle,
ICalendarUser,
IEventFormActions,
IEventFormArgs,
IRecurrence,
ISelectionResult,
TCalendarView,
TEventColor,
TWorkingHours,
} from "@innosolutions/inno-calendar";Exported components worth knowing: WhenRow, RecurrenceField, FieldLayer
and QuickCreate for building a form body, and formatClockRange /
reactNodeToText for labels.
License
MIT © InnoSolutions
