react-weekly-availability-calendar
v1.6.1
Published
A customizable, draggable weekly availability calendar component for React. Create, resize, move time slots with zero external dependencies.
Maintainers
Readme
react-weekly-availability-calendar
A customizable, draggable weekly availability calendar component for React. Create, resize, and move time slots with zero external styling dependencies.
→ Try the live demo
Dragging is the whole point, so a screenshot undersells it. Every example on the demo page is interactive — create a slot by dragging on empty space, move one by dragging it, resize it by its edges.
Features
- Drag to create, resize, and move availability slots
- Blocked time slots with striped overlay
- Multi-day create: one drag lays the same range across several days
- Per-slot colors, plus a built-in
darkThemepreset - Locale-aware day names and times via
Intl - Undo / redo through the
useAvailabilityHistoryhook - Fully customizable via
theme,classNames, or render props - Zero external dependencies (only
reactandreact-domas peer deps) - Styles auto-injected at runtime — no CSS import needed
- SSR safe, and ships a
"use client"directive for the Next.js App Router - TypeScript first
Install
npm install react-weekly-availability-calendarQuick Start
import { useState } from "react";
import { AvailabilityCalendar } from "react-weekly-availability-calendar";
import type { AvailabilitySlot } from "react-weekly-availability-calendar";
function App() {
const [slots, setSlots] = useState<AvailabilitySlot[]>([
{ id: 1, dayOfWeek: 1, startTime: "09:00", endTime: "12:00" },
{ id: 2, dayOfWeek: 3, startTime: "14:00", endTime: "17:00" },
]);
return (
<AvailabilityCalendar
slots={slots}
onSlotsChange={setSlots}
snapMinutes={30}
timeFormat="12"
/>
);
}Keyboard
The calendar is fully operable without a pointer.
| Focus | Key | Action |
| ------------ | -------------------------------------------- | ------------------------------------------ |
| A day column | Enter / Space | Add a slot at the earliest free time |
| A slot | ↑ / ↓ | Move earlier / later by one snap increment |
| A slot | ← / → | Move to the previous / next day |
| A slot | Shift + ↑/↓ | Resize from the end edge |
| A slot | Delete / Backspace | Remove the slot |
| A slot | Enter / Space | Fire onSlotClick |
Every change is announced in a polite live region. Moves that would collide with another slot or a blocked range are refused and announced rather than silently ignored.
The calendar deliberately does not use ARIA's grid role. That role requires
row and gridcell descendants across the whole surface — over a thousand cells
at a ten-minute snap — which would leave a screen-reader user traversing all of
them to reach a handful of slots. Slots and day columns are exposed as labelled
buttons instead.
Knowing what changed
onSlotsChange receives a description of the edit as its second argument, so
you can issue targeted writes instead of re-saving the week:
<AvailabilityCalendar
slots={slots}
onSlotsChange={(next, { created, updated, removed }) => {
setSlots(next);
created.forEach((s) => api.post("/slots", s));
updated.forEach((s) => api.patch(`/slots/${s.id}`, s));
removed.forEach((id) => api.delete(`/slots/${id}`));
}}
snapMinutes={30}
timeFormat="12"
/>Ignore the second argument and nothing changes — it is purely additive.
A merge appears as an update plus a removal: the surviving slot's range grows and the absorbed id is reported as removed, which is exactly what a backend needs to hear.
Tooltips
Slots and blocked ranges carry a native title by default — useful because a
slot at a small snap increment can be too short to show its own labels, and long
blocked labels are truncated. Override or suppress it:
<AvailabilityCalendar
slotTooltip={(slot, info) => `${info.startLabel}–${info.endLabel}`}
blockedSlotTooltip={(slot) => slot.label}
// return null from either to remove the tooltip
/>Slots that touch are merged
When an edit leaves two slots touching or overlapping, they are merged into one.
The merge keeps the fields — including the id — of the slot that starts
earliest:
// before
[
{ id: "a", dayOfWeek: 1, startTime: "09:00", endTime: "10:00" },
{ id: "b", dayOfWeek: 1, startTime: "10:00", endTime: "11:00" },
][
// after onSlotsChange — one slot, and "b" is gone
{ id: "a", dayOfWeek: 1, startTime: "09:00", endTime: "11:00" }
];This applies to every path: dragging, resizing, and keyboard edits. Treat the
array onSlotsChange hands you as a fresh statement of the week's
availability, not as a diff against what you passed in — reconciling it by id
will lose merged slots.
End-of-day slots and storage
A slot running to midnight ends at "24:00". That is deliberate, and matches
ISO 8601, which allows hour 24 as the end of an interval. It is also the
only representation that survives a round trip:
| End value | Parses back to | Result |
| --------- | -------------- | ------------------------------------------------------------- |
| "24:00" | 1440 | correct |
| "00:00" | 0 | end is before the start — the slot is rejected and disappears |
| "23:59" | 1439 | silently a minute short |
Some stores reject hour 24 — SQL TIME, and most date parsers. Convert at that
boundary, where you know the string is an end time:
import {
toStorageSlots,
fromStorageSlots,
} from "react-weekly-availability-calendar";
await db.save(toStorageSlots(slots)); // "24:00" -> "00:00"
const slots = fromStorageSlots(await db.load()); // "00:00" -> "24:00"Both are pure, leave every other slot untouched, and preserve custom fields.
Documentation
The full API — every prop, every variant, with live controls — is generated from the TypeScript types, so it never drifts from the source:
→ Storybook: full API reference
At a glance
| | |
| ---------------- | ---------------------------------------------------------------------- |
| Required | slots, onSlotsChange, snapMinutes, timeFormat |
| Range | startHour, endHour — show only the hours you schedule in |
| Week | startDay, dayLabelFormat, locale, gridLineStyle |
| Behaviour | readOnly, multiDayCreate, onSlotClick, blockedSlots |
| Limits | disabledDays, minSlotMinutes, maxSlotMinutes |
| Styling | theme (plus the darkTheme preset), classNames, per-slot color |
| Render props | renderSlot, renderBlockedSlot, slotTooltip, blockedSlotTooltip |
| Hook | useAvailabilityHistory for undo / redo |
Types are exported for all of the above, and every prop carries TSDoc — your editor will show the same descriptions Storybook does.
Types
type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6;
interface AvailabilitySlot {
id: number | string;
dayOfWeek: DayOfWeek;
startTime: string; // "HH:mm"
endTime: string; // "HH:mm"
color?: string; // optional per-slot background
}
interface BlockedSlot {
dayOfWeek: DayOfWeek;
startTime: string;
endTime: string;
label: string;
}Links
Notes
- End-of-day slots: Slots ending at midnight display as
24:00in 24-hour format (not00:00) to clearly represent end-of-day rather than start-of-day. - SSR safe: All DOM access is guarded. Works with Next.js, Remix, etc.
- No CSS import needed: Styles are auto-injected via a
<style>tag on first render.
License
MIT
