# React Resource Scheduler: Drag to Book Rooms and Cars

> Build a React resource scheduler for rooms, cars and people: drag to book, move and resize, block double bookings in Postgres, and show an agenda on phones.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-resource-scheduler

## TL;DR

A React resource scheduler puts the things you book (rooms, cars, people) in rows and time in columns, so a free slot is a visible gap you drag across to book, and a booking is a bar you drag to move or stretch. It must refuse overlaps and unavailable time during the drag, re-check every booking on the server, and become an agenda with a Book button on a phone, where a 12 hour grid does not fit. The Wingo UI Scheduler does this in one component fed by resources, events and a few callbacks.

A React resource scheduler is the screen behind room booking, fleet planning and shift rotas: resources down the side, hours across the top, bookings as bars you can drag. It looks simple until people drag a meeting onto a taken room, book a car during its service, or both grab the Hudson room at 9 AM, and on a phone the 12 hour grid becomes a strip nobody can tap. This guide covers what a resource timeline needs, the props that matter in the Wingo UI [Scheduler](https://wingo-ui.com/components/scheduler), how to keep bookings honest on the server, and what to show on a phone.

## What is a resource scheduler, and how is it different from a calendar?

A calendar answers "what is on my day". A resource scheduler answers "which room, car or person is free at 2 PM". That changes the layout:

- **Rows are resources.** Each meeting room, vehicle or team member gets a lane. Lanes can be grouped (Rooms, Team, Cars) and folded.
- **Columns are time slots.** A day in 15, 30 or 60 minute slots, or a week of days.
- **Availability is part of the row.** Off hours are shaded, maintenance or time off refuses bookings.
- **Bookings belong to one resource.** Dragging to another row reassigns it.

Every room booking UI needs these four. If your users mostly look at their own appointments, a month and week calendar such as [Calendar View](https://wingo-ui.com/components/calendar-view) fits better; for availability across many things you want a react calendar resource timeline.

## Which React resource scheduler options exist in 2026?

The main choices are FullCalendar Premium, Bryntum Scheduler, Syncfusion, the open source react-big-schedule and the Wingo UI Scheduler. Here is what their own pages say as of October 2026:

| Option | Resource timeline | Price and license | Source |
|---|---|---|---|
| FullCalendar | Timeline View and Vertical Resource View are premium | Standard free, Premium from $480 (1 to 10 developers) | [pricing](https://fullcalendar.io/pricing) |
| Bryntum Scheduler | yes, it is the product | from $680 per developer (price shown for 3 or more licenses) | [store](https://www.bryntum.com/store/) |
| Syncfusion React Scheduler | timeline and resource views | commercial; free community license for under $1M annual revenue, 5 or fewer developers, 10 or fewer employees and no more than $3M in outside capital | [community license](https://www.syncfusion.com/products/communitylicense) |
| react-big-schedule | resource rows, built on Ant Design | open source, MIT | [README](https://cdn.jsdelivr.net/npm/react-big-schedule@5.5.1/README.md) |
| Wingo UI Scheduler | day and week timeline plus a phone agenda | Pro, source copied into your project | [docs](https://wingo-ui.com/components/scheduler) |

Pick Bryntum or Syncfusion when a large team needs a full planning suite with vendor support, FullCalendar Premium when you already use FullCalendar for month views, and react-big-schedule when your app runs on Ant Design. The Wingo UI Scheduler is for a Tailwind app that wants the timeline to match the rest of its UI, work on a phone and live in the repository as source you can edit.

## What does a resource timeline look like in practice?

It is one grid with every resource's day side by side, so a free slot is a gap you can see. The demo shows nine resources in three groups on a Thursday in September. The red line is now, the bar under each name is how much of its working hours is booked, and the Potomac room is in maintenance from 2 PM to 4 PM.

> Live demo (Scheduler): Drag across free time in a row to book it, drag a booking to another room or car, or stretch its edge. Drops on the Potomac room during its maintenance are refused and the bar springs back. Try it at [Scheduler](https://wingo-ui.com/components/scheduler) and install it with `npx wingo-ui@latest add scheduler`.

Tentative bookings are dashed; cancelled ones are struck through and never block a slot.

## How do you set up resources and bookings?

You pass two arrays, `resources` for the rows and `events` for the bookings, plus callbacks that save what people change. Install it with `npx wingo-ui@latest add scheduler`. A resource is `{ id, name }` plus optional `avatar`, `icon`, `description`, `group`, `capacity`, `tone`, `color`, `unavailable` and `workingHours`. A booking is a Calendar View event (`id`, `title`, `start`, `end` and optional extras) plus `resourceId` and an optional `status`.

```tsx
"use client"

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",
    capacity: 8,
    icon: <Presentation />,
    tone: "info",
    unavailable: [{ start: new Date(2026, 9, 16, 14), end: new Date(2026, 9, 16, 16), label: "Maintenance" }],
  },
  { id: "emma", name: "Emma Carter", description: "Sales", group: "Team", workingHours: { start: 9, end: 17 } },
  { id: "corolla", name: "Toyota Corolla", description: "NY 12 ACM", group: "Cars", icon: <Car /> },
]

async function send(url: string, method: "POST" | "PATCH", body: unknown) {
  const res = await fetch(url, {
    method,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  })
  if (!res.ok) throw new Error(await res.text())
  return res.json()
}

export function RoomBookings({ initial }: { initial: SchedulerEvent[] }) {
  const [events, setEvents] = useState(initial)

  return (
    <Scheduler
      resources={resources}
      events={events}
      slotMinutes={15}
      dayStartHour={7}
      dayEndHour={19}
      onEventChange={async (event, change) => {
        await send(`/api/bookings/${event.id}`, "PATCH", change)
        setEvents((list) => list.map((e) => (e.id === event.id ? { ...e, ...change } : e)))
      }}
      onCreateEvent={async (input) => {
        const { id } = await send("/api/bookings", "POST", input)
        setEvents((list) => [...list, { id, ...input }])
      }}
      onEventCancel={async (event) => {
        await send(`/api/bookings/${event.id}`, "PATCH", { status: "cancelled" })
        setEvents((list) => list.map((e) => (e.id === event.id ? { ...e, status: "cancelled" } : e)))
      }}
    />
  )
}
```

The props that shape the grid are `slotMinutes` (what everything snaps to), `dayStartHour` and `dayEndHour` (8 and 20 by default), `slotWidth`, `rowHeight` and `resourceWidth` in pixels, and `height` (640px) before it scrolls with the time header and names pinned.

## How does drag to create, move and resize work?

React scheduler drag and drop comes down to three gestures:

1. **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 with the title focused. Enter saves, Escape cancels. Save calls `onCreateEvent` with `{ start, end, resourceId, title }`.
2. **Drag a booking to move it.** It follows the pointer to another time or another row, a ghost shows where it will land and turns red when that time is taken, and it settles into the slot on release.
3. **Drag an edge to resize.** It stops at its neighbors instead of running over them.

The grid scrolls by itself near its edges, and Escape cancels any drag. Moves and resizes call `onEventChange(event, change)` with `{ start, end, resourceId }`. The booking moves at once; return `false` or a rejected promise and it goes back, with the failure announced.

If you already have a booking form, pass `onSlotSelect` instead of `onCreateEvent`: a drag, click or Enter on a free slot hands you `{ start, end, resourceId }`. `onEventClick` replaces the details popover the same way.

## How do you stop double bookings?

Refuse overlaps in the UI and make them impossible in the database. In the UI, `allowOverlap` is `false` by default. A drop, a resize, a selection or a form save that overlaps another booking of the same resource is refused, the booking shakes and springs back, and screen readers hear "Hudson room is already booked then". Unavailable time refuses bookings even with `allowOverlap` on. Turn it on for people who can be double-booked; overlapping bars then stack in lanes and the row grows.

The UI check is not enough: two people can grab the same free slot at the same second. The file exports `findConflicts(events, { start, end, resourceId, id? })`, which returns the bookings of that resource that overlap the candidate and skips cancelled ones. Use it in your own forms on the client, and pass the booking's `id` when you check an edit, or the booking clashes with itself:

```ts
import { findConflicts, type SchedulerEvent } from "@/components/ui/scheduler"

export function clashMessage(
  events: SchedulerEvent[],
  candidate: { start: Date; end: Date; resourceId: string; id?: string },
) {
  const [first] = findConflicts(events, candidate)
  return first ? `That time is taken by ${first.title}` : null
}
```

`scheduler.tsx` is a client module (`"use client"`), so do not import it into a server action or a route handler. Repeat the same rule in the database instead. In Postgres an exclusion constraint makes the overlap impossible, whatever the client sends:

```sql
create extension if not exists btree_gist;

alter table bookings add constraint no_overlap
  exclude using gist (resource_id with =, tstzrange(start_at, end_at) with &&)
  where (status <> 'cancelled');
```

A write that breaks the constraint fails with `exclusion_violation` (SQLSTATE `23P01`). Catch that code and answer with a 409. `send` throws on any status outside 200 to 299, so a moved booking springs back and the quick-book form shows "Could not save" plus the title (the `failed` label), whatever text the server sent.

## How should a React booking calendar work on a phone?

On a phone it should be a list of the day's bookings with one Book button, not a grid. A day from 8 AM to 8 PM in 30 minute slots is 24 columns of 48px, about 1,150px of grid to scroll sideways on a 390px phone. So when the scheduler is narrower than 576px of its own width (a container query, so a narrow side panel gets it too and the server HTML is already right) it becomes an agenda:

- a strip of the week's days, with dots on the busy ones,
- the day's bookings grouped by resource with their times,
- the resources that are free all day as chips that book them,
- a full-width Book button that suggests the next free slot.

Bookings and the booking form open as bottom sheets. Try it in a forced phone layout:

> Live demo (Scheduler): Tap a day in the strip, tap a booking to open its sheet, or press Book to start a booking in the next free slot. Try it at [Scheduler](https://wingo-ui.com/components/scheduler) and install it with `npx wingo-ui@latest add scheduler`.

For tablets in portrait, `mobileView="day"` keeps the day grid, scrolling sideways with the names pinned. On touch a tap on a free slot opens the booking sheet and a long press picks a booking up to move it, so a normal swipe still scrolls; our post on [dnd kit on mobile](https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag) explains the same trade-off in a sortable list. Lanes are at least 56px tall on touch screens, so every booking is a 44px target, and `layout="mobile"` or `"desktop"` forces one layout. For why sheets beat popovers on phones, see [bottom sheets for the web](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web).

## How do you make a resource scheduler keyboard accessible?

Make the whole grid one Tab stop and move inside it with the arrow keys, so a keyboard user does not tab through hundreds of slots. In the Wingo UI Scheduler, Left and Right move through a row's free slots and bookings, Up and Down between resources, PageUp and PageDown a day or a week. Shift with the arrows selects slots and Enter books them; Alt with the arrows moves the focused booking and Alt with Shift resizes it. Moves, resizes, new bookings and refused drops are announced in a polite live region, and reduced motion makes the settles and the shake instant.

## What breaks when a resource scheduler goes to production?

Time zones, the current time and data that does not match the rows cause most of the bugs:

- **Time zones.** `start` and `end` are `Date` objects, and hours are formatted in the zone of whatever renders them. A server on UTC and a browser in New York print different hours into the same HTML, and React reports a hydration mismatch. Store instants (`timestamptz`), run the server with `TZ` set to your users' zone, or load the scheduler with `next/dynamic` and `ssr: false` from a client component when users span zones.
- **The current time.** Without `defaultDate` the server renders the first booking's day and the browser moves to today, so hydration matches. Pin `now` in tests.
- **Missing resources.** A booking whose `resourceId` is not in `resources` lands in a "No resource" row. Treat that row as a bug report.
- **Working hours are whole hours.** `workingHours: { start: 9, end: 17 }` shades the rest of the day and the weekend. Lunch breaks and one-off closures go in `unavailable` with a `label`.
- **Labels.** Every string is a prop. `labels={{ book: "Reserve", newBooking: "New reservation" }}` changes the wording, `conflict` takes a function of the resource name, and `DEFAULT_SCHEDULER_LABELS` has the full set to spread under a translation.

## When is a regular event calendar the better choice?

A regular calendar is better when people mostly look at their own meetings. For that, use [Calendar View](https://wingo-ui.com/components/calendar-view): month, week, day and agenda views with drag to reschedule. A Scheduler booking is its event plus `resourceId`, so the same data feeds both. Our [React calendar component with events guide](https://wingo-ui.com/blog/react-calendar-component-with-events) covers that calendar in depth, and when the rows are tasks with dependencies rather than bookings, the [React Gantt chart guide](https://wingo-ui.com/blog/react-gantt-chart) fits better.

> Live demo (Calendar View): Switch between Month, Week, Day and Agenda, then drag an event to another time to reschedule it. Try it at [Calendar View](https://wingo-ui.com/components/calendar-view) and install it with `npx wingo-ui@latest add calendar-view`.

To pick one slot in a form, a [Time Picker](https://wingo-ui.com/components/time-picker) next to a date field is lighter than either. The [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) covers how those fields fit together, and the [component guides](https://wingo-ui.com/blog/category/components) have more single-component deep dives.

## How do you add the scheduler to a Next.js app?

Run `npx wingo-ui@latest add scheduler`. It copies `components/ui/scheduler.tsx` and the 21 Wingo UI items it imports directly (plus their own dependencies), among them Calendar View (for the event type), Popover, Select, Time Picker, Tooltip and the date, motion and tone helpers, and installs `lucide-react` and `motion`. The Scheduler is part of [Wingo UI Pro](https://wingo-ui.com/pricing), so sign in first with `npx wingo-ui@latest login`.

## Components in this post

- [Scheduler](https://wingo-ui.com/components/scheduler) (Pro): A React resource scheduler to book rooms, cars and people on a timeline: drag to book, move and resize, conflicts refused, agenda on phones. Install: `npx wingo-ui@latest add scheduler`
- [Calendar View](https://wingo-ui.com/components/calendar-view) (Pro): A React event calendar for appointments and meetings: month, week, day and agenda views, a now line and drag to reschedule. Install: `npx wingo-ui@latest add calendar-view`

## FAQ

### What is a resource scheduler in React?

It is a timeline where each row is a resource, such as a meeting room, a vehicle or a team member, and time runs across the columns. Bookings are bars in those rows, so you see availability per resource at a glance instead of one combined calendar.

### Is FullCalendar's resource timeline free?

No. As of October 2026 the FullCalendar pricing page lists Timeline View and Vertical Resource View as premium features, with Premium starting at $480 for 1 to 10 developers. The Standard edition, which has the month, week and day views, is free.

### How do I prevent double bookings in a React scheduler?

Refuse overlapping drops in the UI and check again on the server, because two people can book the same slot at the same moment. In the Wingo UI Scheduler allowOverlap is false by default, unavailable time always refuses bookings, and a rejected promise from onEventChange puts the booking back.

### How should a booking calendar work on mobile?

Show one day as a list of bookings grouped by resource with a full-width Book button, instead of a wide grid that needs sideways scrolling. Moving a booking on touch should start with a long press, so a normal swipe still scrolls the page.

### Should I use a resource scheduler or a regular calendar?

Use a resource scheduler when the question is which room, car or person is free at a given time. Use a regular event calendar with month and week views when each user mostly looks at their own appointments.

---

Source: https://wingo-ui.com/blog/react-resource-scheduler
