Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelogContact
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. React Appointment Booking That Gets Time Zones Right
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. React Appointment Booking That Gets Time Zones Right
Build it: screens and apps
Build it: screens and apps

React Appointment Booking That Gets Time Zones Right

SR
Serban Rusu · Founder of Wingo UI
Nov 6, 2026 · 9 min read

On this page

0%
  1. What does a good appointment booking flow need?
  2. How do I get the whole booking flow in one component?
  3. How do I build a time slot picker in React myself?
  4. Why keep host hours in the host's time zone?
  5. When does the Time Picker fit instead of slot buttons?
  6. How do I stop double bookings on the server?
  7. What should booking look like on a phone?
  8. When should I embed a hosted scheduler instead?
  9. Where do I start?
  10. FAQ
    1. How do I build an appointment booking page in React?
    2. How should a booking calendar handle time zones?
    3. How do I stop two people booking the same time slot?
    4. Should I embed Calendly or Cal.com instead of building booking myself?
    5. Is there a shadcn booking calendar with time slots?

TL;DR

React appointment booking works best as three steps: pick a day, pick a time slot, then enter details. Store the host's hours in the host's IANA time zone, work out the slots for each visitor's zone, drop taken slots and anything inside the minimum notice, and check the booking again on the server so two people cannot get the same time. Wingo UI's Demo Booking Page (a Pro template) does the whole flow, and Calendar, Time Picker and Timezone Select cover the parts when you build your own.

Published Nov 6, 2026

React appointment booking sounds like a calendar plus a few buttons, and most tutorials stop there. Then a visitor in Berlin books your New York host at 3 AM her time, someone grabs a slot that starts in ten minutes, two people confirm the same 2 PM, and on a phone the time grid sits under a sticky header nobody can reach. This guide builds the flow in three steps (date, time slot, details) with Wingo UI: host hours kept in the host's time zone, taken slots, minimum notice, a server check, and a phone layout. It ends with the cases where embedding a hosted scheduler is the better call.

What does a good appointment booking flow need?

Ask for a react appointment booking calendar or a shadcn booking calendar and the usual answer is a month grid beside a column of time buttons. shadcn/ui's own calendar-20 block (opens in a new tab) (checked November 6, 2026) is exactly that: fixed times every 15 minutes from 9:00 to 18:00 and a hardcoded list of booked days. That is the right shape. What it leaves out is everything that decides whether a booking is correct:

  1. One question per step. Date, then time, then name and email. On a phone each step fits one screen, and the visitor never scrolls past a form to find the times.
  2. Days without times are disabled. A weekend, a day past the booking window, or a day where every slot is gone should not be clickable. Opening on the first day with an open time saves a tap.
  3. Times in the visitor's zone, hours in the host's. The host works 9 to 5 in New York. A visitor in Berlin should see afternoon and evening slots, and a visitor in Tokyo may see them spill onto the next calendar day.
  4. Minimum notice. No slot starts sooner than, say, four hours from now.
  5. A server check. The UI hides taken slots, the database refuses the second booking.
  6. A confirmation people can use. The day, time range and zone, a meeting link, add to calendar, and a way to reschedule.

How do I get the whole booking flow in one component?

The Demo Booking Page template is all six points in one page. The booking card walks through the date, time and details steps with a stepper, lists times in the visitor's zone (detected from the browser and changeable), validates each step in place, and ends on a confirmation with Google Calendar, Outlook and .ics buttons and a Reschedule action that keeps the details.

Switch the time zone to Berlin, pick a day and continue: the same New York hours come back as afternoon and evening slots. Continue without a name to see the errors in place
$ npx wingo-ui@latest add demo-booking-page
ProDemo Booking Page docs

Install it, then pass your hours and a submit handler:

bash
npx wingo-ui@latest add demo-booking-page
tsx
// app/(marketing)/book/page.tsx
"use client"
import Link from "next/link"
import { DemoBookingPage } from "@/components/templates/demo-booking-page"
export default function BookRoute() {
return (
<DemoBookingPage
linkComponent={Link}
// the host's week, in the host's zone: Monday to Thursday, 10 AM to 4 PM
hours={{ timeZone: "America/Chicago", days: [1, 2, 3, 4], start: 10, end: 16 }}
duration={45}
interval={15}
leadHours={12}
daysAhead={28}
meetingUrl="https://meet.example.com/emma-carter"
// shown when onSubmit rejects, e.g. after a 409 from the API
labels={{ failed: "Someone just booked that time. Go back and pick another one." }}
onSubmit={async (values) => {
const response = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...values,
start: values.start.toISOString(),
end: values.end.toISOString(),
}),
})
if (!response.ok) throw new Error("Slot taken")
}}
/>
)
}

The props that matter for booking:

PropDefaultWhat it does
hoursMonday to Friday, 9 to 17, New York{ timeZone, days, start, end }: the host's week, with days as 0 (Sunday) to 6 and whole hours
duration30Call length in minutes; also the end time and the calendar event
interval30Minutes between start times
leadHours4Minimum notice: no slot starts sooner than this
daysAhead42How far ahead days can be booked
defaultTimeZone""The visitor's zone on load; empty uses the browser's
hourCycle"auto""12h", "24h" or the locale's choice
onSubmitwaits 900 msGets { start, end, timeZone, name, email, companySize, notes, rescheduled } with start and end as Date; a rejected promise shows labels.failed in the details step

One caveat: the template marks some slots as taken with a stable pseudo random pick so the sample looks real. It is copied into your project as source, so for production you replace that taken check with your real busy times (the next section shows the shape). The template is Pro, and so are the Calendar, Time Picker and Timezone Select below; the Pro plan is $8 a month, $80 a year or $150 once.

How do I build a time slot picker in React myself?

If the booking lives inside your app, say a clinic dashboard or a coaching tool, you may want only the parts. The Calendar picks the day, a list of buttons is the time slot picker, and the Timezone Select sets the zone. The template exports its slot math as getDemoBookingSlots(day, options), so you do not have to write the zone conversion yourself:

tsx
"use client"
import { useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { TimeZoneSelect } from "@/components/ui/timezone-select"
import { getDemoBookingSlots } from "@/components/templates/demo-booking-page"
import type { DemoBookingHours } from "@/components/templates/demo-booking-page-data"
const HOURS: DemoBookingHours = { timeZone: "America/New_York", days: [1, 2, 3, 4, 5], start: 9, end: 17 }
const DURATION = 30
type Busy = { start: number; end: number } // epoch ms, from your API
const isoDay = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
// open slots per day, cached: the calendar asks for every visible day on each render
function slotLookup(zone: string, busy: Busy[], notBefore: number) {
const cache = new Map<string, Date[]>()
return (date: Date) => {
const key = isoDay(date)
let slots = cache.get(key)
if (!slots) {
slots = getDemoBookingSlots(key, {
hours: HOURS,
timeZone: zone,
duration: DURATION,
interval: 30,
notBefore,
hideTaken: false,
}).filter((slot) => {
const start = slot.getTime()
const end = start + DURATION * 60_000
return !busy.some((b) => start < b.end && end > b.start)
})
cache.set(key, slots)
}
return slots
}
}
export function SlotPicker({ busy, onPick }: { busy: Busy[]; onPick: (start: Date) => void }) {
const [zone, setZone] = useState("America/New_York")
const [day, setDay] = useState<Date | null>(null)
// "now", fixed when the picker mounts so renders stay pure
const [now] = useState(() => Date.now())
const notBefore = now + 4 * 3_600_000 // four hours of notice
const slotsFor = useMemo(() => slotLookup(zone, busy, notBefore), [zone, busy, notBefore])
const format = new Intl.DateTimeFormat("en-US", { timeZone: zone, hour: "numeric", minute: "2-digit" })
return (
<div className="grid gap-6 md:grid-cols-[auto_1fr]">
<Calendar
value={day}
onValueChange={setDay}
min={new Date(now)}
disabled={(date) => slotsFor(date).length === 0}
/>
<div className="grid content-start gap-4">
<TimeZoneSelect label="Time zone" value={zone} onValueChange={setZone} date={day ?? undefined} />
{day && (
<ul className="grid grid-cols-2 gap-2 sm:grid-cols-3" aria-label="Open times">
{slotsFor(day).map((slot) => (
<li key={slot.getTime()}>
<Button variant="outline" className="w-full" onClick={() => onPick(slot)}>
{format.format(slot)}
</Button>
</li>
))}
</ul>
)}
</div>
</div>
)
}

That is a complete React time slot picker in under 80 lines. Four details in it do most of the work. disabled takes a function, so a day is off exactly when it has no open slot, and disabled days stay focusable so the keyboard can pass over them. hideTaken: false turns off the template's sample busy times so only your busy list counts. The cached slotLookup matters because the Calendar runs disabled for every visible day on each render; pass a stable busy array (from state or your data hook) or the cache resets every time. And date on the Timezone Select makes the offsets in the list follow daylight saving time for the picked day, not today.

Type EST, tokyo or +5:30 into the field: each finds its zone, and the offsets follow daylight saving time
$ npx wingo-ui@latest add timezone-select
ProTimezone Select docs

Why keep host hours in the host's time zone?

Because offsets move, and they do not move on the same day everywhere. In 2026 Europe left summer time on October 25 and the US on November 1, so for that week Berlin was 5 hours ahead of New York instead of 6. A host who works 9 to 5 in New York was 13:00 to 21:00 UTC in October and is 14:00 to 22:00 UTC since November 1. Store the October UTC range and every slot starts an hour early on the host's own clock from November on. Store { timeZone: "America/New_York", start: 9, end: 17 }, compute instants for each day, and send start.toISOString() to the server. The visitor's zone is only for display.

When does the Time Picker fit instead of slot buttons?

A React calendar with time slots shows fixed starts, which is right for public booking. The Time Picker fits the other side: the host setting their hours, or a request form where any time inside a window is fine. min and max bound the window, minuteStep sets the grid, and disabledTimes blocks single times such as a lunch break or booked slots:

tsx
import { TimePicker } from "@/components/ui/time-picker"
<TimePicker
label="Preferred time"
min="09:00"
max="17:00"
minuteStep={15}
disabledTimes={(time) => time >= "12:00" && time < "13:00"}
/>

On a phone it opens a bottom sheet with snapping wheels instead of raising the keyboard. If you need a date and a time in one field, see our shadcn date time picker guide and the React time picker guide.

How do I stop double bookings on the server?

Hiding taken slots is a hint. Two visitors can load the page at 9:00 and both confirm 2 PM. Let the database decide. In Postgres an exclusion constraint refuses overlapping ranges for the same host:

sql
create extension if not exists btree_gist;
create table bookings (
id uuid primary key default gen_random_uuid(),
host_id uuid not null,
slot tstzrange not null,
email text not null,
exclude using gist (host_id with =, slot with &&)
);
ts
// app/api/bookings/route.ts
import { Pool } from "pg"
const pool = new Pool()
export async function POST(request: Request) {
const { start, end, email } = await request.json()
// re-check here too: inside the host's hours, past the minimum notice, inside the booking window,
// and derive end from start plus your duration instead of trusting the client
try {
await pool.query(
"insert into bookings (host_id, slot, email) values ($1, tstzrange($2, $3, '[)'), $4)",
[process.env.HOST_ID, start, end, email],
)
return Response.json({ ok: true })
} catch (error) {
if ((error as { code?: string }).code === "23P01") {
return Response.json({ error: "slot_taken" }, { status: 409 })
}
throw error
}
}

23P01 is Postgres's exclusion violation. The '[)' bounds let one booking end at 2:30 and the next start at 2:30. When the route answers 409, the onSubmit above throws, the template shows the labels.failed text, and the visitor goes back a step to pick another time. Our resource scheduler guide uses the same constraint for rooms and cars.

What should booking look like on a phone?

The steps help most here. In the Demo Booking Page the stepper becomes a compact "Step 1 of 3" header, Back and Continue sit in a bar pinned to the bottom of the screen, and the time zone list opens as a bottom sheet. The calendar cells grow to 44px on touch and a swipe changes the month. Keep the slot list to two or three columns of full-width buttons; a seven-column week of times does not fit 390px.

When should I embed a hosted scheduler instead?

Building it is worth it when the booking is your product data. If you mainly need a "book a call" link, a hosted scheduler does more on day one: it reads busy times from Google or Outlook calendars, sends confirmations, and handles reschedule and cancel links. Team scheduling sits on the paid plans. Their public pricing pages, checked November 6, 2026:

OptionFree planPaid plansSource
Cal.comFree forever, 1 user, unlimited calendarsTeams $12 per user per month, Organizations $28 per user per monthcal.com/pricing (opens in a new tab)
CalendlyAlways free, 1 event type, 1 calendar connectionStandard $10 and Teams $16 per seat per month, billed yearlycalendly.com/pricing (opens in a new tab)

Embed one when calendar sync matters more than design and you are fine with the booking living in their system. Build your own when you need your own fields, your own look in light and dark, bookings next to your users and payments in your database, or no per-seat fee as the team grows. Starting with an embed and moving once booking becomes a core feature is a reasonable path too.

For more screens built end to end, see the Build it category and the Next.js admin dashboard tutorial.

Where do I start?

Open the Demo Booking Page template, switch the time zone and walk the three steps on your phone. If it fits, sign in with your Pro account and npx wingo-ui@latest add demo-booking-page copies it into your project with the Calendar, the stepper and the fields it uses, ready for your hours and your /api/bookings route.

Components in this post

  • Demo Booking Page

    A React book-a-demo page template with the agenda, host and logos beside a three step booking.

    Pro
  • Calendar

    A React calendar to pick one day, several days or a range, with disabled days, event dots, several months and a quick month / year jump.

    Pro
  • Time Picker

    A React time picker to type a time or pick it from columns, with 12 / 24 hours by locale, minute steps, limits and wheels on phones.

    Pro
  • Timezone Select

    A searchable time zone picker with live UTC offsets, optional local times, region groups and a one-tap Use my time zone row.

    Pro

FAQ

How do I build an appointment booking page in React?

Split it into three steps: a calendar that only enables days with open times, a list of time slots for the picked day, and a short details form. Generate the slots from the host's weekly hours, remove taken ones and those inside the minimum notice, then confirm the booking on the server before you show the success screen.

How should a booking calendar handle time zones?

Save the host's hours as wall clock times in an IANA zone such as America/New_York, never as a fixed UTC offset. Convert each slot to the visitor's zone when you render it and send the booking to your server as an ISO timestamp, so daylight saving changes cannot shift it.

How do I stop two people booking the same time slot?

Hide taken slots in the UI, but treat that as a hint, because two visitors can confirm at the same second. In Postgres an exclusion constraint on a tstzrange column refuses overlapping bookings for the same host, and your API answers the losing request with a 409.

Should I embed Calendly or Cal.com instead of building booking myself?

Embed one when you need calendar sync with Google or Outlook, reminders, reschedule links and team routing soon and the booking does not need to live in your own database. Build it yourself when the booking is part of your product data, needs your own fields or design, or you do not want a per-seat fee.

Is there a shadcn booking calendar with time slots?

Yes, shadcn/ui's calendar-20 block pairs its Calendar with a column of fixed 15 minute times and a few booked days, but it has no time zones, minimum notice or details step. Wingo UI installs the same way, as source code, and its Demo Booking Page adds the time zone math, taken slots, minimum notice, validation and a phone layout.

Try it in your project

Demo Booking Page

Pro

A React book-a-demo page template with the agenda, host and logos beside a three step booking.

75 components free, no account needed. Pro from $8 a month.

Open Demo Booking Page liveSee pricing
  • Booking
  • Calendar
  • Time zones
  • Mobile UI

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

Shadcn Date Time Picker with Time Zones and Presets

A shadcn date time picker in one field: calendar, free time slots, presets and a time zone label, a zone select beside it, and a bottom sheet on phones.

SRSerban Rusu·Nov 5, 2026·9 min read
Component guides

React Country Select With Flags, Search and Dial Codes

Build a React country select with flags, names localized by the browser, search by name, code or +dial code, a suggested group, multiple picks and ISO values.

SRSerban Rusu·Nov 6, 2026·9 min read
Component guides

React Calendar Component with Events: Month, Week, Day

A React calendar component with events: month, week, day and agenda views, drag to reschedule, saves with rollback, and an agenda list on phones.

SRSerban Rusu·Oct 19, 2026·10 min read
Newsletter

Get new posts by email

New posts, product news and occasional offers from Wingo UI. We count clicks to keep it relevant.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • For teams
  • Theme generator

Resources

  • Docs
  • Contact
  • Programs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Compare

  • Wingo UI vs paid kits
  • Wingo UI vs shadcn/ui
  • shadcn/ui alternatives
  • MUI alternatives
  • Tailwind Plus alternatives
  • Magic UI alternatives
  • Free components

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI