How to build a date and time slot picker in React
A React booking calendar renders a month grid built from native Date arithmetic, tracks selected day and time slot in local state, and animates slot transitions with Framer Motion's AnimatePresence. No external calendar library is needed.
- Stack: React 18, Framer Motion 11, Lucide React, Tailwind v4, ~230 lines, no calendar library dependency.
- Month grid computed with getDaysInMonth and getFirstDayOfWeek using native Date, with Monday-first week offset.
- Weekend days are auto-disabled via getDay() checks; disabled state styled with opacity-30.
- Accessible: buttons carry native disabled attribute; keyboard navigation follows tab order across calendar and slots.
- The slot panel fades in/out with AnimatePresence mode='wait', swap key={selectedDay} to trigger re-entry animation on each new day.
Contact Calendar Embed is a self-contained React section that lets visitors pick a meeting day and time slot without leaving the page or loading a third-party widget. A month grid on the left and an animated slot panel on the right make the two-step flow obvious at a glance. It suits SaaS onboarding flows, agency contact pages, and any professional service that needs an inline booking surface.
Anatomy
The section contains a centered header (eyebrow label, h2, optional description) and a 5-column CSS grid below it. The calendar panel occupies 3 columns: a month navigator row at the top, a 7-day header row, then the day buttons in a 7-column sub-grid with leading blank cells to align the first weekday correctly. The slot panel (2 columns) shows a context bar with the selected date and session duration, then either an empty-state placeholder or a 2-column grid of time buttons plus a confirm CTA once a slot is chosen.
How it works
All calendar logic runs on plain useState and two pure functions. getDaysInMonth(year, month) calls new Date(year, month+1, 0).getDate() to get the last day. getFirstDayOfWeek returns a Monday-offset (0–6) by remapping Sunday from 0 to 6. Selecting a day writes to selectedDay state and resets selectedSlot; the AnimatePresence block on the slot panel receives a key={selectedDay}, so React unmounts the old list and mounts the fresh one, triggering the fade transition. When a slot is confirmed, a Framer Motion button slides in from y:8 with the spring easing constant E=[0.16,1,0.3,1]. All colors come from CSS custom properties (--color-accent, --color-background-alt, etc.) so the component adapts to any theme preset without code changes.
How to build it in React
Compute the month grid
Two pure functions handle the calendar math. getDaysInMonth uses a Date overflow trick: passing day 0 of month+1 returns the last day of the current month. getFirstDayOfWeek remaps Sunday (JS day 0) to position 6 so weeks start on Monday. Build two arrays, the actual days and a blank-prefix array, to feed the 7-column grid.
function getDaysInMonth(year: number, month: number) { return new Date(year, month + 1, 0).getDate(); } function getFirstDayOfWeek(year: number, month: number) { const day = new Date(year, month, 1).getDay(); return day === 0 ? 6 : day - 1; }Wire month navigation and day selection
Store month, year, selectedDay and selectedSlot in four useState calls. The prev/next handlers wrap around December/January and reset the selection. Day buttons call isWeekend(day) to disable Saturday and Sunday, keeping the disabled prop on the native button so keyboard users can't reach those cells.
const [month, setMonth] = useState(today.getMonth()); const [selectedDay, setSelectedDay] = useState<number | null>(null); const isWeekend = (day: number) => { const d = new Date(year, month, day).getDay(); return d === 0 || d === 6; };Animate the slot panel with AnimatePresence
Wrap the slot list in an AnimatePresence block with mode='wait'. Give the motion.div a key={selectedDay}, when the day changes React destroys the old children and mounts new ones, executing the exit then enter animation. A simple opacity:0 to opacity:1 transition avoids layout shifts while still giving visible feedback.
<AnimatePresence mode="wait"> {selectedDay ? ( <motion.div key={selectedDay} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="grid grid-cols-2 gap-2" > {timeSlots.map((slot) => ( <button key={slot} onClick={() => setSelectedSlot(slot)}> {slot} </button> ))} </motion.div> ) : <EmptyState />} </AnimatePresence>Style with CSS custom properties for theme portability
Never hardcode colors. Use --color-accent for the selected day and slot backgrounds, --color-background-alt for the card surfaces, and --color-border for outlines. The seven theme presets in the registry (lime-light, violet-dark, etc.) all define these tokens, so the calendar renders correctly without any style override.
style={{ background: selected ? "var(--color-accent)" : "transparent", color: selected ? "var(--color-background)" : "var(--color-foreground)", }}
When to use it
Use this section when you want visitors to self-schedule without navigating to a separate page or loading Calendly in an iframe. It fits well on agency contact pages, SaaS product demos, legal and medical consultations, and any service priced by the hour. Avoid it on transactional e-commerce pages where a calendar creates friction, and skip it if your availability data is dynamic, this component accepts a static timeSlots array and does not connect to a backend by default. You will need to wire the confirm button to an API yourself.
Used by
- Calendly, The reference product for embedded scheduling: month grid, day selection, time slot list, and confirm step in a single inline panel.
- Cal.com, Open-source booking platform whose embeddable widget uses the same two-column calendar/slots layout, fully themeable via CSS variables.
- Stripe, Demo scheduling for sales calls on stripe.com uses a minimal inline date+time picker before routing to a video call link.
- Notion, Notion's native date property editor renders a month grid with day buttons and time input that follows the same interaction model: pick day, then pick time.
FAQ
Does this component connect to Google Calendar or Calendly?
No. It is a pure UI component. The timeSlots prop accepts a static string array and the confirm button fires no request by default. Wire an onClick handler to your own API or a Calendly/Cal.com REST endpoint to make it live.
How do I disable specific days beyond weekends?
Pass a disabledDates prop as an array of Date objects or ISO strings, then extend the disabled check in the day button: `disabled={weekend || isDisabled(day)}`. The opacity-30 style is already applied on disabled, so no extra CSS is needed.
Why does changing the month reset the selected slot?
The prevMonth and nextMonth handlers explicitly call setSelectedDay(null) and setSelectedSlot(null). Day 15 in March and day 15 in April are different appointments, so clearing on navigation prevents silent errors where the user lands on a confirm screen for the wrong month.
Can I use this on mobile?
The layout stacks to a single column below the lg breakpoint, so the calendar and slots appear one above the other. Tap events work fine since the interaction uses click handlers. The day buttons use aspect-square for touch-friendly tap targets. Test on small screens to ensure your timeSlots array does not overflow the 2-column grid.