Scheduler React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React resource scheduler to book rooms, cars and people on a timeline: drag to book, move and resize, conflicts refused, agenda on phones.
Resources run down the side in collapsible groups (a click on a group heading folds it) and time runs across: a day in 15, 30 or 60 minute slots, or a week of days, with the hours outside someone's working day shaded, unavailable time hatched with its reason, a red now line and a thin utilization bar under every name. The toolbar steps a day or a week back or forward, Today jumps home and the switch picks Day, Week or Agenda; a click on a day in the week header opens that day. With a mouse, drag across free slots to book: the range snaps to the slots, stops at the next booking and opens a quick-book form under it (title focused, Enter saves, Escape cancels). Drag a booking to another time or resource: it follows the pointer, a ghost shows where it will land (red when that time is taken) and it settles into the slot on release, or shakes and springs back with the reason announced when the time is taken or unavailable. Drag either edge to stretch it; it stops at its neighbours. Near the edges the grid scrolls by itself, and Escape cancels any drag. Click a booking for its details with Edit and Cancel booking. Keyboard: the grid is one Tab stop; Left and Right move through the free slots and bookings of a row (a day at a time in the week view), Up and Down move between resources and group headings (Left and Right fold a heading), Home and End jump to the ends of the row (with Ctrl or Cmd to the first or last resource), PageUp and PageDown go a day or a week back or forward, Shift with Left or Right selects more slots and Enter or Space books them, Enter on a booking opens it, Alt with the arrows moves the focused booking a slot or a resource at a time and Alt with Shift with Left or Right changes where it ends. Moves, stretches, new bookings and conflicts are announced. Touch: the grid scrolls freely; a tap on a free slot opens the booking sheet and a long press on a booking picks it up to move it. When the scheduler is narrower than 576px it becomes an agenda: a strip of the week's days (dots mark busy days), the day's bookings grouped by resource with their times, the resources free all day as chips that book them, and a full-width Book button; bookings open as a bottom sheet with Edit and Cancel booking, and the booking form is a sheet too. Reduced motion turns the settles and slides into instant changes and drops the shake.
Scheduler is a Pro React component in the React data display components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add scheduler, together with Avatar, Badge, Button, Calendar View, Empty State and 16 more and the npm packages lucide-react and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add schedulerScheduler is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import Scheduler from @/components/ui/scheduler.
import { useState } from "react"
import { Car, Presentation } from "lucide-react"
import { Scheduler, type SchedulerEvent, type SchedulerResource } from "@/components/ui/scheduler"
const resources: SchedulerResource[] = [
{ id: "hudson", name: "Hudson room", description: "8 seats · screen", group: "Rooms", icon: <Presentation />, tone: "info" },
{ id: "mihai", name: "Michael Reed", avatar: "/demo/mihai-ionescu.jpg", group: "Team", workingHours: { start: 9, end: 18 } },
{ id: "corolla", name: "Toyota Corolla", description: "NY 12 ACM", group: "Cars", icon: <Car /> },
]
const bookings: SchedulerEvent[] = [
{ id: "r1", resourceId: "hudson", title: "Tech Solutions demo", start: new Date(2026, 8, 24, 10, 0), end: new Date(2026, 8, 24, 11, 30) },
{ id: "r2", resourceId: "mihai", title: "BuildRight Group negotiation", start: new Date(2026, 8, 24, 11, 0), end: new Date(2026, 8, 24, 12, 0), status: "tentative" },
{ id: "r3", resourceId: "corolla", title: "Client visit, Denver", start: new Date(2026, 8, 24, 15, 0), end: new Date(2026, 8, 24, 17, 30) },
]
export function Bookings() {
const [events, setEvents] = useState(bookings)
return (
<Scheduler
resources={resources}
events={events}
defaultDate={new Date(2026, 8, 24)}
onEventChange={(event, change) =>
setEvents((list) => list.map((e) => (e.id === event.id ? { ...e, ...change } : e)))
}
onCreateEvent={(input) => setEvents((list) => [...list, { id: crypto.randomUUID(), ...input }])}
onEventCancel={(event) =>
setEvents((list) => list.map((e) => (e.id === event.id ? { ...e, status: "cancelled" } : e)))
}
labels={{
book: "Reserve",
newBooking: "New reservation",
conflict: (resource) => `${resource} is already reserved then`,
}}
/>
)
}Props
resources*SchedulerResource[]The rows: { id, name, avatar?, icon?, description?, group?, capacity?, tone?, color?, unavailable?: { start, end, label? }[], workingHours?: { start, end } }. People get their photo or initials, things their icon in a tile. Outside workingHours (whole hours) the lane is shaded, weekends too; unavailable stretches are hatched with their label and refuse every booking.
eventsdefault"[]"SchedulerEvent[]The bookings: calendar-view's CalendarEvent (id, title, start, end, allDay?, tone?, color?, location?, description?, attendees?, editable?, meta?) plus resourceId and status?: "confirmed" | "tentative" | "cancelled". Tentative ones are dashed, cancelled ones untinted, muted and struck through, and they never block. A resourceId that is not in resources lands in a "No resource" row. The file also exports findConflicts(events, { start, end, resourceId, id? }): the bookings of that resource that overlap the candidate (cancelled ones never do), to check a booking on the server before it is saved.
defaultViewdefault"day""day" | "week" | "agenda"The view it opens in: the day in slots, the week in days (bookings keep their place in each day's hours), or the agenda of one day.
view"day" | "week" | "agenda"The view, controlled; pair it with onViewChange.
onViewChange(view: SchedulerView) => voidWhen the switch, a week header day or the phone switch changes the view.
viewsdefault"["day","week","agenda"]"SchedulerView[]The views the switch offers, in order. One view hides the switch; a day in the week header only opens the day view when it is listed.
dateDateThe day on screen (any day of the week in the week view), controlled; pair it with onDateChange.
defaultDateDateThe day it opens on. Left out, the server renders the first booking's day and the scheduler moves to today (from now) once the clock is known; with neither, it shows skeleton lanes until then.
onDateChange(date: Date) => voidWhen the arrows, Today, PageUp / PageDown, the day strip or a week header day change the day.
mobileViewdefault"agenda""agenda" | "day"What it shows when it is narrower than 576px (a container query, so a narrow side panel gets it too and the server HTML is already right on a phone): the agenda with the day strip and the Book button, or the day grid scrolling sideways with the names pinned, for tablets in portrait.
layoutdefault"responsive""responsive" | "desktop" | "mobile"responsive switches to the narrow layout under 576px of its own width; desktop and mobile force one, e.g. a phone frame on a wide screen.
dayStartHourdefault8numberThe first visible hour. An end at or before the start falls back to 8 to 20.
dayEndHourdefault20numberThe hour the visible day ends at. Bookings that run past either edge are cut with an arrow and read as continuing.
slotMinutesdefault"30"15 | 30 | 60The slot: what dragging, stretching and booking snap to, and one column of the day view. Hour lines are stronger than the ones between.
slotWidthdefault48numberpx per slot in the day view; the slots stretch when the scheduler is wider than they need.
dayWidthdefault160numberpx per day in the week view.
rowHeightdefault56numberpx per resource lane (at least 56px on touch screens, so a booking is a 44px target). A row of overlapping bookings grows by this much per stack.
resourceWidthdefault220numberpx of the pinned resource column; it never takes more than 42% of a narrow scheduler.
heightdefault640numberThe most the grid (and the agenda list) grows to before it scrolls, in px. The time header and the names stay pinned while it scrolls both ways. When narrow it also stays within 75% of the screen (the agenda list 60%), so the Book button is always in sight.
weekStartsOndefault-10 | 1 | 2 | 3 | 4 | 5 | 6 | -1The first day of the week view and the day strip: 0 is Sunday, 1 Monday ... 6 Saturday; -1 follows the locale (Sunday in en-US, Monday in en-GB).
hideWeekendsdefaultfalsebooleanLeaves the locale's weekend out of the week view and the day strip.
groupResourcesdefaulttruebooleanGroups the rows under their resource.group, in the order the groups first appear, each heading folding its rows away.
defaultCollapsedGroupsdefault"[]"string[]Group names that start folded.
showNowIndicatordefaulttruebooleanThe red line at the current time, with a dot in the time header. It moves once a minute.
showUtilizationdefaulttruebooleanA thin bar and a percentage under every name: the booked share of its working hours in view, unavailable time left out.
toolbardefaulttruebooleanThe title, the arrows, Today and the view switch. Off, a page can drive view and date itself.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The color of bookings that have no tone or color of their own or from their resource, and of the drag-to-book selection, the utilization bars and today's dot.
variantdefault"surface""surface" | "outline" | "plain"surface: a raised panel. outline: a hairline border on the page. plain: no container, for a page that is only the scheduler.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"The panel's corners: 0 / 12 / 16 / 24px (full reads as lg).
editabledefaulttruebooleanDrag and stretch bookings, drag across free time to book (with onCreateEvent or onSlotSelect), and Edit / Cancel booking in the details. Off, it is an availability view: bookings still open their details. A booking with editable: false stays put either way.
allowOverlapdefaultfalsebooleanOff, a drop, a stretch, a selection or a form save that overlaps another booking of the same resource is refused (the selection stops at the neighbour). On, people can be double-booked and overlapping bookings stack in lanes that grow the row. Unavailable time refuses bookings either way.
loadingdefaultfalsebooleanSkeleton lanes in the real row shape (skeleton rows in the agenda); the toolbar keeps working.
onEventChange(event: SchedulerEvent, change: SchedulerChange) => (void | false | Promise<unknown>)After a move, a stretch, an Alt+arrow or the edit form: change is { start, end, resourceId, title? }. The booking moves at once; false or a rejected promise puts it back (announced). Without it moves stay on screen until events change.
onCreateEvent(input: SchedulerCreateInput) => (void | Promise<unknown>)The quick-book form's Save, with { start, end, resourceId, title } (an empty title saves as labels.titlePlaceholder). A promise keeps Save loading and a rejection shows the error in the form. Needed, with editable, for booking from the grid and the Book button.
onSlotSelect(slot: SchedulerSlot) => voidReplaces the built-in form: a drag across free time, a click, a tap or Enter on a slot (and the Book button, with a suggested free slot) calls it with { start, end, resourceId }.
onEventClick(event: SchedulerEvent) => voidReplaces the details popover (and sheet) when a booking is clicked or tapped.
onEventCancel(event: SchedulerEvent) => (void | Promise<unknown>)Adds Cancel booking to the details of bookings that are not cancelled yet; a promise keeps the button loading.
renderEvent(event: SchedulerEvent, info: { view: SchedulerView; resource: SchedulerResource | null }) => ReactNodeReplaces the title and time inside a booking bar (the bar, its color, drag and focus stay).
renderEventDetails(event: SchedulerEvent) => ReactNodeReplaces the rows of the details (time, resource, location, attendees); the title, the status and the actions stay.
renderResource(resource: SchedulerResource, info: { utilization: number | null }) => ReactNodeReplaces what the pinned resource cell shows; utilization is 0 to 1, or null without working time in view.
toolbarActionsReactNodeExtra actions at the end of the toolbar, e.g. an "Export" button or a filter.
nowDate | numberThe current time for today, the now line and the Book button's suggestion. Defaults to a live clock that ticks every minute; demos pin 24.09.2026 16:40.
localedefault""stringHours (AM/PM in en-US, 24h in en-GB), day names and the week start. Empty uses the UIProvider locale, then the browser's.
labelsPartial<SchedulerLabels>Every string, English by default: today, previous, next, day, week, agenda, views, resources, caption(period), keyboardHint, book, newBooking, editBooking, title, titlePlaceholder, resource, date, start, end, save, cancel, edit, cancelBooking, close, details, location, attendees, allDay, confirmed, tentative, cancelled, unavailable, noResource, noResources, noBookings, freeAllDay, continues, loading, previousWeek, nextWeek, capacity(count), bookings(count, formatted), utilization(percent), slot(resource, time), timeRange(start, end), event({ title, resource, time, status }), conflict(resource), unavailableConflict(resource), invalidRange, moved(title, resource, time), resized(title, time), created(title, resource, time), cancelledBooking(title), failed(title). DEFAULT_SCHEDULER_LABELS has them all, to spread under a translation.
classNamesPartial<Record<"toolbar" | "title" | "nav" | "switch" | "actions" | "body" | "grid" | "header" | "corner" | "group" | "row" | "resource" | "utilization" | "lane" | "slot" | "event" | "unavailable" | "selection" | "now" | "agenda" | "dayStrip" | "agendaEvent" | "booking" | "details", string>>Extra classes for the parts. booking goes on the quick-book panel, details on the details panel.
classNamestringExtra classes for the root, merged last. The rest of the props land on the root too.
Parts
findConflicts
functionfindConflicts(events, { start, end, resourceId, id? }): the bookings of that resource that overlap the candidate, cancelled ones excluded. Use it to check a booking on the server before saving it.
DEFAULT_SCHEDULER_LABELS
constantThe English strings for the toolbar, the booking and details panels, statuses, empty states and every screen reader announcement. Override keys through labels.
SchedulerResource
typeA row of the timeline (a room, a car or a person): { id, name, avatar?, icon?, description?, group?, capacity?, tone?, color?, unavailable?, workingHours? }.
SchedulerEvent
typeA booking: a calendar event plus resourceId and an optional status ("confirmed", "tentative" or "cancelled").
SchedulerChange
typeWhat a create, move or resize hands you: { start, end, resourceId, title? }.
SchedulerProps
typeThe props of Scheduler, for wrappers that load resources and bookings from your API.
Examples
Week without weekends
defaultView="week" with hideWeekends: rooms and cars across five days.
Clinic in 15-minute slots
slotMinutes={15} with narrow slots, two dentists and the X-ray room.
Double booking
allowOverlap stacks overlapping bookings in lanes instead of refusing them.
Read-only availability
editable={false} on an outline panel: look, do not touch.
Agenda on a phone
layout="mobile": the day strip, bookings grouped by resource and the Book button.
Loading
Skeleton rows keep the layout still; Reload plays it again.
Dependencies
Files
- components/ui/scheduler.tsx
FAQ
Is the React Scheduler component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Scheduler?
Run npx wingo-ui@latest add scheduler. It also adds the Wingo UI items it builds on (Avatar, Badge, Button, Calendar View, Empty State and 16 more) and the npm packages lucide-react and motion. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/scheduler.
How does Scheduler behave on phones?
When the scheduler is narrower than 576px it becomes an agenda: a strip of the week's days (dots mark busy days), the day's bookings grouped by resource with their times, the resources free all day as chips that book them, and a full-width Book button; bookings open as a bottom sheet with Edit and Cancel booking, and the booking form is a sheet too.
See also
More React data display components
Articles using this component
- React Resource Scheduler: Drag to Book Rooms and Cars
Component guides10 min read