New (update: 6th Oct)Launched Animated Blocks + 56+ New Blocks & Components - production-grade & copy-paste ready

Sep 22, 2026

Tutorials23 min read

How to Build a Calendar App with shadcn/ui

Mihir Koshti

Mihir Koshti

Full Stack Developer @shadcnspace.com

How to Build a Calendar App with shadcn/ui

A calendar looks simple until you have to build one that actually handles scheduling.

Selecting a date is only the beginning. A real calendar application has to deal with event management, available time slots, multi-day planning, RSVP actions, different calendar views, and time zone differences. Once these requirements start coming together, a simple calendar component quickly turns into a much larger interface system.

We built this Calendar & Scheduling application to explore how those different workflows can be composed from reusable shadcn/ui components instead of building every interface from scratch.

The application includes a monthly calendar, date and time scheduling, multi-day sprint planning, an agenda with RSVP actions, timezone selection, filters, notifications, and persistent application state. The complete project is built with Next.js and uses components from ShadcnSpace throughout the interface.

In this guide, we’ll break down the application structure, how each calendar experience works, and how the components come together to create a complete scheduling workflow. We’ll also look at practical implementation details such as event modeling, timezone handling, state persistence, and component maintenance.

The complete application is available as an open-source project, so you can explore the implementation and use it as a reference for your own calendar-based applications.


Why did we build this calendar application?

A calendar component can handle date selection, but a scheduling application has to solve several different problems at the same time.

While building this project, we wanted to cover the workflows that commonly appear in real calendar and scheduling interfaces:

  • Event planning: Viewing scheduled events, inspecting a selected day, and creating or removing meetings.
  • Appointment booking: Selecting a date, choosing an available time slot, and confirming a booking.
  • Multi-day planning: Selecting continuous date ranges for sprint planning, leave management, and capacity planning.
  • Event participation: Managing attendees, RSVP actions, and meeting links.
  • Timezone management: Handling schedules across different time zones.
  • Multiple calendar views: Using a monthly calendar, scheduler, range planner, or agenda depending on the task.

The application brings these workflows together through four main views: a monthly calendar, date and time scheduler, multi-day range planner, and interactive agenda with RSVP actions.

The goal was not to build several disconnected calendar screens. We wanted to create one application where these experiences share the same event model and work together as part of a single scheduling workflow.

This also gave us a practical way to explore how reusable shadcn/ui components can be combined to build a more complete application instead of treating every interface as an isolated component


What did we build?

We wanted the application to support more than a traditional month-view calendar, so we built four focused experiences for different scheduling tasks.

Monthly Calendar
A month-based view for browsing dates, spotting scheduled events, and inspecting the selected day’s agenda.

Date & Time Scheduler
A booking flow where users select a date, choose an available time slot, enter meeting details, and confirm an appointment.

Sprint & Range Planner
A range-based calendar for selecting multiple days and calculating planning metrics such as total days and working days.

Agenda & RSVP
An event-focused view that displays meetings as cards with attendees, RSVP actions, meeting details, and cancellation controls.

Calendar App View

All four views work with the same event data, so an appointment created in the scheduler can also appear in the monthly calendar and agenda. Supporting components such as timezone selection, filters, dialogs, tabs, and notifications complete the application experience.

The result is a single calendar application where each view serves a specific scheduling need rather than trying to make one calendar layout handle everything.


How we organized the code

With several calendar workflows sharing the same data, we separated the application into smaller components instead of keeping the logic in a single page.

The calendar-specific functionality lives under a dedicated components/calendar structure, with separate components for the monthly view, scheduler, sprint planner, agenda, and shared event types. The main application handles shared state such as selected dates, events, filtering, and persistence, while individual views focus on rendering and handling their own interactions.

The project also keeps reusable UI primitives and ShadcnSpace components separate from the calendar-specific application logic. This makes it possible to update or replace a calendar view without rewriting the rest of the application.

Code Structure

This separation becomes especially useful when adding features such as a new calendar view or changing how events are stored, because the change can stay within the part of the application responsible for it.


Project setup

We’ll start with a Next.js project and initialize shadcn/ui with Base UI. Base UI is now the default component library for new shadcn/ui projects, but specifying it explicitly makes the setup clear and reproducible.

1. Initialize shadcn/ui with Base UI

This sets up shadcn/ui for a Next.js project using Base UI.

pnpm dlx shadcn@latest init -t next --base base

2. Install the additional libraries

The calendar application uses date-fns for date calculations, react-day-picker for calendar interactions, lucide-react for icons, motion for animations, and a few utility packages used throughout the UI.

pnpm add lucide-react date-fns clsx tailwind-merge next-themes react-day-picker motion

We can then add the standard shadcn/ui components required by the application, such as calendar, button, dialog, sheet, popover, command, avatar, input, textarea, label, progress, and toast.

pnpm dlx shadcn@latest add calendar button badge dialog sheet popover command avatar input textarea label progress toast

This gives us the base UI foundation and the supporting libraries needed before we start installing the ShadcnSpace components used in the calendar application.


Install the ShadcnSpace Components

With the Next.js and shadcn/ui setup in place, the next step is to add the ShadcnSpace components used by the calendar application.

ShadcnSpace components can be installed directly into your project using the official shadcn CLI. The CLI adds the component source code to your project and handles the required dependencies, so you can work with the code directly in your application.

New to the ShadcnSpace CLI?
Before continuing, see the ShadcnSpace CLI installation guide for the registry setup, CLI versions, authentication, and installation workflow.

For this calendar application, we use several ShadcnSpace components for different parts of the interface:

ComponentPurpose
calendar-08Monthly calendar
calendar-16Date and time scheduling
calendar-09Multi-day range planning
card-24Event and RSVP cards
combobox-04Timezone selection
sheet-04Filter drawer
dialog-01Event creation
tabs-08Calendar view switching

These are the same components used in the open-source calendar application.

Once the ShadcnSpace registry is configured, you can install the calendar components with:

npx shadcn@latest add @shadcn-space/calendar-08
npx shadcn@latest add @shadcn-space/calendar-16
npx shadcn@latest add @shadcn-space/calendar-09

And the supporting components:

npx shadcn@latest add @shadcn-space/card-24
npx shadcn@latest add @shadcn-space/combobox-04
npx shadcn@latest add @shadcn-space/sheet-04
npx shadcn@latest add @shadcn-space/dialog-01
npx shadcn@latest add @shadcn-space/tabs-08

You can also install multiple components in a single command when setting up a new feature. The ShadcnSpace documentation covers this workflow as well.

After the components are installed, we can start connecting them to the shared calendar data model.


Define the calendar event data model

Before building the calendar views, we need a consistent way to represent events. Instead of keeping separate data for the monthly calendar, scheduler, and agenda, the application uses a shared CalendarEvent type.

The model includes the information needed across the different views, such as the event title, category, host, date, time, location, attendees, meeting URL, status, and RSVP state.

A simplified version looks like this:

export interface CalendarEvent {
  id: string;
  title: string;
  category: "design" | "engineering" | "sprint" | "one-on-one" | "all-hands";
  host: string;
  date: Date;
  startTime: string;
  endTime: string;
  location: string;
  callUrl: string;
  attendeesCount: number;
  attendees: { name: string; avatar?: string }[];
  description: string;
  status: "confirmed" | "pending" | "declined";
  rsvpStatus?: "going" | "maybe" | "declined" | null;
}

We also keep the event categories in one place so the same category information can be reused for filters, labels, colors, and event cards. The project defines categories such as Design & UX, Engineering, Sprint Planning, 1:1 Sync, and All Hands.

Keeping this model shared means a newly created appointment can flow through the rest of the application without requiring a separate data structure for each calendar view.


Build the monthly calendar view

The monthly view is the main place where users browse their schedule. We started with the ShadcnSpace calendar-08 block and extended it so it could work with the application’s event data, selected date, month navigation, and event actions.

The original block was designed as a reusable calendar UI. For this application, we added props that allow a parent component to control its state and connect it to real event data.

The updated component now supports:

  • Controlled date selection with selected and onSelect
  • Controlled month navigation with month and onMonthChange
  • Single, multiple, and range modes
  • Custom calendar modifiers and class names
  • Optional card and footer rendering
  • Dynamic events through the events prop
  • An add-event action through onAddEvent
  • Event inspection through onEventClick
  • Internal date state when the component is used without a controlled value

This makes calendar-08 reusable beyond its original standalone use case.

Updated calendar-08 component

Here is the updated component used in the application:

"use client"
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { PlusIcon } from 'lucide-react'
import { cn } from '@/lib/utils'

const formatTimeRange = (from: Date, to: Date): string => {
  if (isNaN(from.getTime()) || isNaN(to.getTime())) return ''

  const timeFmt = new Intl.DateTimeFormat('en-US', {
    hour: 'numeric',
    minute: '2-digit',
    hour12: true
  })

  const dateFmt = new Intl.DateTimeFormat('en-US', {
    month: 'short',
    day: 'numeric'
  })

  if (from.toDateString() === to.toDateString()) {
    return `${timeFmt.format(from)} - ${timeFmt.format(to)}`
  }

  return `${dateFmt.format(from)} - ${dateFmt.format(to)}`
}

const colorMap = {
  blue: {
    dot: 'bg-blue-500',
    bg: 'bg-blue-500/10',
    time: 'text-blue-500'
  },
  teal: {
    dot: 'bg-teal-400',
    bg: 'bg-teal-400/10',
    time: 'text-teal-400'
  },
  orange: {
    dot: 'bg-orange-400',
    bg: 'bg-orange-400/10',
    time: 'text-orange-400'
  }
} as const

export interface Calendar08Event {
  id?: string
  title: string
  from: string
  to: string
  color: 'blue' | 'teal' | 'orange'
  rawEvent?: any
  [key: string]: any
}

const defaultEvents: Calendar08Event[] = [
  {
    title: 'Product Standup',
    from: '2026-05-28T08:30:00',
    to: '2026-05-28T09:00:00',
    color: 'blue'
  },
  {
    title: 'UX Walkthrough',
    from: '2026-05-28T11:00:00',
    to: '2026-05-28T12:00:00',
    color: 'teal'
  },
  {
    title: 'Stakeholder Demo',
    from: '2026-05-28T15:00:00',
    to: '2026-05-28T16:30:00',
    color: 'orange'
  }
]

export interface Calendar08Props {
  selected?: Date
  onSelect?: (date: Date | undefined) => void
  month?: Date
  onMonthChange?: (date: Date) => void
  mode?: 'single' | 'multiple' | 'range'
&nbsp; modifiers?: React.ComponentProps<typeof Calendar>['modifiers']
&nbsp; modifiersClassNames?: React.ComponentProps<typeof Calendar>['modifiersClassNames']
&nbsp; className?: string
&nbsp; classNames?: React.ComponentProps<typeof Calendar>['classNames']
&nbsp; cardClassName?: string
&nbsp; showCard?: boolean
&nbsp; showFooter?: boolean
&nbsp; events?: Calendar08Event[]
&nbsp; onAddEvent?: () => void
&nbsp; onEventClick?: (event: Calendar08Event) => void
}

export function Calendar08({
&nbsp; selected,
&nbsp; onSelect,
&nbsp; month,
&nbsp; onMonthChange,
&nbsp; mode = 'single',
&nbsp; modifiers,
&nbsp; modifiersClassNames,
&nbsp; className,
&nbsp; classNames,
&nbsp; cardClassName,
&nbsp; showCard = true,
&nbsp; showFooter = true,
&nbsp; events = defaultEvents,
&nbsp; onAddEvent,
&nbsp; onEventClick,
&nbsp; ...calendarProps
}: Calendar08Props) {
&nbsp; const [internalDate, setInternalDate] = React.useState<Date | undefined>(
&nbsp; &nbsp; new Date()
&nbsp; )

&nbsp; const activeDate = selected !== undefined ? selected : internalDate
&nbsp; const handleSelect = onSelect || setInternalDate

&nbsp; const calendarElement = (
&nbsp; &nbsp; <Calendar
&nbsp; &nbsp; &nbsp; mode={mode as any}
&nbsp; &nbsp; &nbsp; selected={activeDate as any}
&nbsp; &nbsp; &nbsp; onSelect={handleSelect as any}
&nbsp; &nbsp; &nbsp; month={month}
&nbsp; &nbsp; &nbsp; onMonthChange={onMonthChange}
&nbsp; &nbsp; &nbsp; modifiers={modifiers}
&nbsp; &nbsp; &nbsp; modifiersClassNames={modifiersClassNames}
&nbsp; &nbsp; &nbsp; className={cn('w-full bg-transparent p-0', className)}
&nbsp; &nbsp; &nbsp; classNames={classNames}
&nbsp; &nbsp; &nbsp; {...(calendarProps as any)}
&nbsp; &nbsp; />
&nbsp; )

&nbsp; if (!showCard) {
&nbsp; &nbsp; return calendarElement
&nbsp; }

&nbsp; return (
&nbsp; &nbsp; <Card className={cn('min-w-2xs pt-4', cardClassName)}>
&nbsp; &nbsp; &nbsp; <CardContent className="px-4">
&nbsp; &nbsp; &nbsp; &nbsp; {calendarElement}
&nbsp; &nbsp; &nbsp; </CardContent>

&nbsp; &nbsp; &nbsp; {showFooter && (
&nbsp; &nbsp; &nbsp; &nbsp; <CardFooter className="flex flex-col items-start gap-3 border-t bg-card">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex w-full items-center justify-between px-1">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="text-sm font-medium">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {activeDate?.toLocaleDateString('en-US', {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; day: 'numeric',
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; month: 'long',
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; year: 'numeric'
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; })}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <Button
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; variant="ghost"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; size="icon"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className="size-6 cursor-pointer"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; title="Add Event"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onClick={onAddEvent}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <PlusIcon />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="sr-only">Add Event</span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </Button>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex w-full flex-col gap-2">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {events.map((event, idx) => {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; const c =
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; (event.color &&
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; colorMap[event.color as keyof typeof colorMap]) ||
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; colorMap.blue

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; let timeStr = ''

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; try {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; const fromDate = new Date(event.from)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; const toDate = new Date(event.to)

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; !isNaN(fromDate.getTime()) &&
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; !isNaN(toDate.getTime())
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; timeStr = formatTimeRange(fromDate, toDate)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; } catch {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; timeStr = ''
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; key={event.id || `${event.title}-${idx}`}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onClick={() => onEventClick?.(event)}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={cn(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 'flex items-center gap-3 rounded-lg px-3 py-2 transition-opacity hover:opacity-80',
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; c.bg,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onEventClick &&
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 'cursor-pointer hover:ring-1 hover:ring-primary/40'
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; title={onEventClick ? 'Click to view details' : undefined}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={`size-1.5 shrink-0 rounded-full ${c.dot}`}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="flex-1 truncate text-sm font-medium">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {event.title}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={`shrink-0 text-xs font-medium ${c.time}`}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {timeStr || event.from}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; })}

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {events.length === 0 && (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="w-full py-3 text-center text-xs text-muted-foreground">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; No scheduled events for this day
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; </CardFooter>
&nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; </Card>
&nbsp; )
}

export default Calendar08

Connecting the block to the application

The parent MonthView then controls the component through these new props:

<Calendar08
&nbsp; showCard={true}
&nbsp; mode="single"
&nbsp; selected={selectedDate}
&nbsp; onSelect={setSelectedDate}
&nbsp; month={currentMonth}
&nbsp; onMonthChange={setCurrentMonth}
&nbsp; events={calendar08Events}
&nbsp; onAddEvent={onOpenNewEventModal}
&nbsp; onEventClick={(evt) => {
&nbsp; &nbsp; const fullEvent =
&nbsp; &nbsp; &nbsp; evt.rawEvent ||
&nbsp; &nbsp; &nbsp; filteredEvents.find((e) => e.id === evt.id)

&nbsp; &nbsp; setInspectingEvent(fullEvent)
&nbsp; }}
/>

The MonthView converts the application’s CalendarEvent objects into the simpler event format expected by calendar-08, then passes the resulting array through the events prop.

This gives us a useful separation of responsibilities:

calendar-08 handles the calendar UI and event presentation.

MonthView handles application state, event filtering, and application-specific actions.

Monthly view GIF

This is a good example of how a reusable ShadcnSpace block can be extended for a specific application without moving the entire application logic into the component.


Add date and time scheduling

Selecting a date is easy. Scheduling a time range that can actually be used by the rest of an application is where things get more interesting.

For our calendar application, we wanted one reusable component that could handle the complete date-and-time selection workflow without forcing every parent component to manage its own picker logic.

That led us to extend ShadcnSpace’s calendar-16.

From a picker to a reusable scheduling component

We extended calendar-16 so it can work in two ways:

  • Standalone: the component manages its own date and time state.
  • Controlled: a parent component can provide the date and time and receive changes through callbacks.

We also added a few scheduling-specific features:

  • Start and end time inputs
  • 1h, 2h, and 4h duration presets
  • An All Day option
  • Automatic duration calculation
  • Invalid time-range detection
  • Disabled dates
  • A single onRangeChange callback containing the complete selection

The result is a component that can be dropped into a simple form or connected to a larger scheduling system.

Part 1: Update the ShadcnSpace calendar-16

The main change is the new prop interface. Instead of keeping the selected date and times hidden inside the component, we allow the parent to control them when needed.

export interface CalendarWithTimeRangeProps {
&nbsp; date?: Date;
&nbsp; onDateChange?: (date: Date | undefined) => void;
&nbsp; startTime?: string;
&nbsp; onStartTimeChange?: (time: string) => void;
&nbsp; endTime?: string;
&nbsp; onEndTimeChange?: (time: string) => void;
&nbsp; onRangeChange?: (range: {
&nbsp; &nbsp; date: Date | undefined;
&nbsp; &nbsp; startTime: string;
&nbsp; &nbsp; endTime: string;
&nbsp; }) => void;
&nbsp; className?: string;
&nbsp; disabled?: (date: Date) => boolean;
}

The component then falls back to its own internal state when those values are not supplied:

const date =
  controlledDate !== undefined
    ? controlledDate
    : internalDate;

const startTime =
  controlledStartTime !== undefined
    ? controlledStartTime
    : internalStartTime;

const endTime =
  controlledEndTime !== undefined
    ? controlledEndTime
    : internalEndTime;

That small change makes the component much more flexible. A parent can now treat it as a reusable input instead of having to rebuild the date and time state around it.

Add duration presets

One of the useful additions is the duration preset system. Instead of manually changing the end time for common meeting lengths, users can select 1 hour, 2 hours, or 4 hours.

const PRESETS = &#91;
&nbsp; { label: "1h", value: 1 },
&nbsp; { label: "2h", value: 2 },
&nbsp; { label: "4h", value: 4 },
];

Selecting a preset calculates the new end time from the current start time:

onClick={() => {
&nbsp; handleEndTimeChange(
&nbsp; &nbsp; addHoursToTime(startTime, preset.value)
&nbsp; );
}}

The component also provides an All Day option and calculates the current duration so the active preset can be highlighted.

Custom duration Presets

Catch invalid time ranges before submission

A scheduling component should not allow a range where the end time is earlier than the start time.

We handle that directly inside the component:

const date =
&nbsp; controlledDate !== undefined
&nbsp; &nbsp; ? controlledDate
&nbsp; &nbsp; : internalDate;

const startTime =
&nbsp; controlledStartTime !== undefined
&nbsp; &nbsp; ? controlledStartTime
&nbsp; &nbsp; : internalStartTime;

const endTime =
&nbsp; controlledEndTime !== undefined
&nbsp; &nbsp; ? controlledEndTime
&nbsp; &nbsp; : internalEndTime;

When the range is invalid, the end-time field is highlighted, and the user sees:

End time must be after start time

This keeps invalid input from propagating into the rest of the application.

The complete updated component

For the open-source implementation, we include the completely updated calendar-16 source so you can see how the controlled state, presets, validation, and callbacks are implemented together.

"use client";

import { useId, useState } from "react";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
&nbsp; InputGroup,
&nbsp; InputGroupAddon,
&nbsp; InputGroupInput,
} from "@/components/ui/input-group";
import {
&nbsp; Popover,
&nbsp; PopoverContent,
&nbsp; PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { CalendarIcon, ClockIcon, ChevronDownIcon } from "lucide-react";

export const formatTime12h = (timeStr: string) => {
&nbsp; if (!timeStr) return "";
&nbsp; const parts = timeStr.split(":");
&nbsp; const hours = parseInt(parts&#91;0], 10);
&nbsp; const minutes = parts&#91;1] || "00";
&nbsp; if (isNaN(hours)) return timeStr;
&nbsp; const ampm = hours >= 12 ? "PM" : "AM";
&nbsp; const displayHours = hours % 12 === 0 ? 12 : hours % 12;
&nbsp; return `${displayHours}:${minutes} ${ampm}`;
};

const addHoursToTime = (timeStr: string, hoursToAdd: number): string => {
&nbsp; if (!timeStr) return "";
&nbsp; const &#91;h, m] = timeStr.split(":").map(Number);
&nbsp; if (isNaN(h) || isNaN(m)) return timeStr;
&nbsp; const newHour = (h + hoursToAdd) % 24;
&nbsp; const pad = (n: number) => String(n).padStart(2, "0");
&nbsp; return `${pad(newHour)}:${pad(m)}`;
};

const getDurationHours = (start: string, end: string): number | null => {
&nbsp; if (!start || !end) return null;
&nbsp; const &#91;sh, sm] = start.split(":").map(Number);
&nbsp; const &#91;eh, em] = end.split(":").map(Number);
&nbsp; if (isNaN(sh) || isNaN(sm) || isNaN(eh) || isNaN(em)) return null;
&nbsp; const startMin = sh * 60 + sm;
&nbsp; const endMin = eh * 60 + em;
&nbsp; const diffMin = endMin - startMin;
&nbsp; if (diffMin <= 0) return null;
&nbsp; return diffMin / 60;
};

const PRESETS = &#91;
&nbsp; { label: "1h", value: 1 },
&nbsp; { label: "2h", value: 2 },
&nbsp; { label: "4h", value: 4 },
];

export interface CalendarWithTimeRangeProps {
&nbsp; date?: Date;
&nbsp; onDateChange?: (date: Date | undefined) => void;
&nbsp; startTime?: string;
&nbsp; onStartTimeChange?: (time: string) => void;
&nbsp; endTime?: string;
&nbsp; onEndTimeChange?: (time: string) => void;
&nbsp; onRangeChange?: (range: { date: Date | undefined; startTime: string; endTime: string }) => void;
&nbsp; className?: string;
&nbsp; disabled?: (date: Date) => boolean;
}

export const CalendarWithTimeRange = ({
&nbsp; date: controlledDate,
&nbsp; onDateChange,
&nbsp; startTime: controlledStartTime,
&nbsp; onStartTimeChange,
&nbsp; endTime: controlledEndTime,
&nbsp; onEndTimeChange,
&nbsp; onRangeChange,
&nbsp; className,
&nbsp; disabled,
}: CalendarWithTimeRangeProps = {}) => {
&nbsp; const id = useId();
&nbsp; const &#91;internalDate, setInternalDate] = useState<Date | undefined>(new Date());
&nbsp; const &#91;internalStartTime, setInternalStartTime] = useState("10:30");
&nbsp; const &#91;internalEndTime, setInternalEndTime] = useState("12:30");

&nbsp; const date = controlledDate !== undefined ? controlledDate: internalDate;
&nbsp; const startTime = controlledStartTime !== undefined ? controlledStartTime: internalStartTime;
&nbsp; const endTime = controlledEndTime !== undefined ? controlledEndTime: internalEndTime;

&nbsp; const handleDateChange = (newDate: Date | undefined) => {
&nbsp; &nbsp; if (controlledDate === undefined) {
&nbsp; &nbsp; &nbsp; setInternalDate(newDate);
&nbsp; &nbsp; }
&nbsp; &nbsp; onDateChange?.(newDate);
&nbsp; &nbsp; onRangeChange?.({ date: newDate, startTime, endTime });
&nbsp; };

&nbsp; const handleStartTimeChange = (newStartTime: string) => {
&nbsp; &nbsp; if (controlledStartTime === undefined) {
&nbsp; &nbsp; &nbsp; setInternalStartTime(newStartTime);
&nbsp; &nbsp; }
&nbsp; &nbsp; onStartTimeChange?.(newStartTime);
&nbsp; &nbsp; onRangeChange?.({ date, startTime: newStartTime, endTime });
&nbsp; };

&nbsp; const handleEndTimeChange = (newEndTime: string) => {
&nbsp; &nbsp; if (controlledEndTime === undefined) {
&nbsp; &nbsp; &nbsp; setInternalEndTime(newEndTime);
&nbsp; &nbsp; }
&nbsp; &nbsp; onEndTimeChange?.(newEndTime);
&nbsp; &nbsp; onRangeChange?.({ date, startTime, endTime: newEndTime });
&nbsp; };

&nbsp; const isInvalid = !!(startTime && endTime && startTime > endTime);
&nbsp; const currentDuration = getDurationHours(startTime, endTime);
&nbsp; const isAllDayActive = startTime === "09:00" && endTime === "17:00";

&nbsp; return (
&nbsp; &nbsp; <Popover>
&nbsp; &nbsp; &nbsp; <PopoverTrigger
&nbsp; &nbsp; &nbsp; &nbsp; render={
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <Button
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={cn(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "group/pick-date w-full sm:w-fit min-w-0 sm:min-w-80 max-w-full justify-between text-left font-normal border-input hover:bg-accent hover:text-accent-foreground transition-all duration-200 cursor-pointer",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; !date && "text-muted-foreground",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; id={id}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; variant="outline"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; <div className="flex items-center gap-2.5 truncate">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <CalendarIcon
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; aria-hidden="true"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors h-4 w-4"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {date ? (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-foreground font-medium text-sm">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {format(date, "MMM d, yyyy")}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ) : (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-muted-foreground text-sm">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Pick a date and time
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {date && (startTime || endTime) && (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-muted-foreground/30 select-none">|</span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex items-center gap-1.5 text-muted-foreground/90">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <ClockIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-xs font-normal">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {startTime ? formatTime12h(startTime) : "--"}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {" - "}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {endTime ? formatTime12h(endTime) : "--"}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; <ChevronDownIcon className="h-4 w-4 text-muted-foreground/60 group-hover/pick-date:text-foreground shrink-0 transition-colors ml-2" />
&nbsp; &nbsp; &nbsp; </PopoverTrigger>
&nbsp; &nbsp; &nbsp; <PopoverContent
&nbsp; &nbsp; &nbsp; &nbsp; align="start"
&nbsp; &nbsp; &nbsp; &nbsp; className="w-auto p-0 flex flex-col sm:flex-row"
&nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; {/* Left Side: Calendar */}
&nbsp; &nbsp; &nbsp; &nbsp; <div className="p-2.5">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <Calendar
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; mode="single"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; selected={date}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onSelect={handleDateChange}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; disabled={disabled}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className="p-0"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; &nbsp; {/* Separator for mobile */}
&nbsp; &nbsp; &nbsp; &nbsp; <Separator orientation="horizontal" className="sm:hidden" />

&nbsp; &nbsp; &nbsp; &nbsp; {/* Separator for desktop */}
&nbsp; &nbsp; &nbsp; &nbsp; <Separator orientation="vertical" className="hidden sm:block" />

&nbsp; &nbsp; &nbsp; &nbsp; {/* Right Side: Time Range & Presets */}
&nbsp; &nbsp; &nbsp; &nbsp; <div className="p-4 flex flex-col gap-4 w-full sm:max-w-70 justify-start">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {/* Time range labels & inputs */}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex flex-col gap-1.5">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-&#91;10px] font-semibold uppercase tracking-wider text-muted-foreground">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Time Range
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="grid grid-cols-2 gap-2">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex flex-col gap-1">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-&#91;10px] text-muted-foreground font-medium">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Start
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroup>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroupInput
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; id="time-from"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; type="time"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; value={startTime}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onChange={(e) => handleStartTimeChange(e.target.value)}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className="appearance-none &#91;&::-webkit-calendar-picker-indicator]:hidden &#91;&::-webkit-calendar-picker-indicator]:appearance-none text-xs"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroupAddon>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <ClockIcon className="text-muted-foreground/75" />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </InputGroupAddon>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </InputGroup>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex flex-col gap-1">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-&#91;10px] text-muted-foreground font-medium">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; End
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroup className={cn(isInvalid && "border-destructive")}>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroupInput
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; id="time-to"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; type="time"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; value={endTime}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onChange={(e) => handleEndTimeChange(e.target.value)}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; aria-invalid={isInvalid}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className="appearance-none &#91;&::-webkit-calendar-picker-indicator]:hidden &#91;&::-webkit-calendar-picker-indicator]:appearance-none text-xs"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroupAddon>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <ClockIcon
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={cn(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "text-muted-foreground/75",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; isInvalid && "text-destructive",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </InputGroupAddon>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </InputGroup>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {/* Preset Buttons */}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex flex-col gap-1.5">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-&#91;10px] font-semibold uppercase tracking-wider text-muted-foreground">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Presets
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="grid grid-cols-2 gap-2">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {PRESETS.map((preset) => {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; const isActive = currentDuration === preset.value;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <Button
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; key={preset.label}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; variant={isActive ? "default" : "outline"}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; size="sm"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={cn(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "h-8 text-xs font-normal transition-all w-full cursor-pointer",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; isActive
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ? "shadow-sm"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : "hover:bg-accent hover:text-accent-foreground text-muted-foreground/80",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onClick={() => {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; handleEndTimeChange(addHoursToTime(startTime, preset.value));
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {preset.label}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </Button>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; );
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; })}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <Button
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; variant={isAllDayActive ? "default" : "outline"}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; size="sm"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={cn(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "h-8 text-xs font-normal transition-all w-full cursor-pointer",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; isAllDayActive
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ? "shadow-sm"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : "hover:bg-accent hover:text-accent-foreground text-muted-foreground/80",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onClick={() => {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; handleStartTimeChange("09:00");
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; handleEndTimeChange("17:00");
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; All Day
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </Button>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {isInvalid && (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <p className="text-xs text-destructive font-medium mt-1 leading-normal text-center animate-in fade-in duration-200">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; End time must be after start time
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </p>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; </PopoverContent>
&nbsp; &nbsp; </Popover>
&nbsp; );
};

export default CalendarWithTimeRange;

Part 2: Use it inside the scheduler

With the reusable component extended, the main SchedulerView can focus on the application logic instead of rebuilding date-and-time picker behavior.

The scheduler handles the booking-specific state:

const &#91;selectedSlot, setSelectedSlot] =
&nbsp; React.useState<string | null>("10:00 AM");

const &#91;meetingTitle, setMeetingTitle] =
&nbsp; React.useState("Strategy & Architecture Review");

const &#91;hostName, setHostName] =
&nbsp; React.useState("Alex Rivera (Lead Architect)");

When the user confirms a slot, the application calculates the appointment end time, creates a CalendarEvent, and sends it back to the main calendar state.

const parsedStart = parse(
&nbsp; selectedSlot,
&nbsp; "hh:mm a",
&nbsp; bookingDate
);

const parsedEnd = addMinutes(parsedStart, 45);

const endTimeStr = format(
&nbsp; parsedEnd,
&nbsp; "hh:mm a"
);

The important part is what happens next: the new appointment becomes part of the same event collection used by the Monthly Calendar and Agenda views.

onBookAppointment(newAppointment);
setLastBookedEvent(newAppointment);

The user then gets a confirmation state with direct links back to the Agenda Feed and Monthly Grid.

Scheduler View GIF

This gives us a clean separation:

calendar-16 handles date and time range selection.

SchedulerView handles the booking workflow and application state.

That distinction is what makes the component reusable beyond this particular calendar application.


Plan multi-day work with a date range

Some calendar tasks are about finding a single meeting time. Others are about planning a period of work.

A sprint, vacation, project phase, or team leave period needs a start date, an end date, and some way to understand what that range means. In our application, we turn date-range selection into a small planning workflow that calculates working days, estimates capacity, and lets the user lock the plan into the calendar.

Select a continuous range

The left side of the planner uses a two-month calendar in range mode. The user selects a start date and an end date, and the selected range is kept in a single DateRange state.

<Calendar
&nbsp; mode="range"
&nbsp; defaultMonth={dateRange?.from}
&nbsp; selected={dateRange}
&nbsp; onSelect={(range) => {
&nbsp; &nbsp; setDateRange(range)
&nbsp; &nbsp; setLockedSprintEvent(null)
&nbsp; }}
&nbsp; numberOfMonths={2}
/>

Displaying two months at once is useful when a sprint or vacation period crosses a month boundary. The selected range is also cleared from the success state whenever the user changes it, so the UI always reflects the current plan.

Turn dates into planning metrics

Selecting a range is only useful when the application can interpret it. Here, we calculate both the total duration and the number of working days.

const { totalDays, workingDays, estimatedCapacity } =
&nbsp; React.useMemo(() => {
&nbsp; &nbsp; if (!dateRange?.from || !dateRange?.to) {
&nbsp; &nbsp; &nbsp; return {
&nbsp; &nbsp; &nbsp; &nbsp; totalDays: 0,
&nbsp; &nbsp; &nbsp; &nbsp; workingDays: 0,
&nbsp; &nbsp; &nbsp; &nbsp; estimatedCapacity: 0,
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

&nbsp; &nbsp; const total =
&nbsp; &nbsp; &nbsp; differenceInDays(
&nbsp; &nbsp; &nbsp; &nbsp; dateRange.to,
&nbsp; &nbsp; &nbsp; &nbsp; dateRange.from
&nbsp; &nbsp; &nbsp; ) + 1

&nbsp; &nbsp; const days = eachDayOfInterval({
&nbsp; &nbsp; &nbsp; start: dateRange.from,
&nbsp; &nbsp; &nbsp; end: dateRange.to,
&nbsp; &nbsp; })

&nbsp; &nbsp; const work = days.filter(
&nbsp; &nbsp; &nbsp; (day) => !isWeekend(day)
&nbsp; &nbsp; ).length

&nbsp; &nbsp; const capacity = Math.min(
&nbsp; &nbsp; &nbsp; 100,
&nbsp; &nbsp; &nbsp; Math.max(65, Math.round(85 + (work % 10)))
&nbsp; &nbsp; )

&nbsp; &nbsp; return {
&nbsp; &nbsp; &nbsp; totalDays: total,
&nbsp; &nbsp; &nbsp; workingDays: work,
&nbsp; &nbsp; &nbsp; estimatedCapacity: capacity,
&nbsp; &nbsp; }
&nbsp; }, &#91;dateRange])

The interface then turns those calculations into useful information such as the total duration, working days, committed story points, and estimated team capacity.

Turn dates into planning metrics

Add sprint details

Once the date range is selected, the user can give the plan a name and define the expected story points.

<Input
&nbsp; id="sprint-name-input"
&nbsp; value={sprintName}
&nbsp; onChange={(e) => setSprintName(e.target.value)}
&nbsp; placeholder="e.g. Sprint 44: Search & Telemetry"
/>

<Input
&nbsp; id="sprint-points-input"
&nbsp; value={targetPoints}
&nbsp; onChange={(e) => setTargetPoints(e.target.value)}
&nbsp; type="number"
/>

This keeps the date selection separate from the information that describes what the selected period represents.

Lock the plan into the calendar

The final step converts the selected range into a CalendarEvent. That means the sprint becomes part of the same event collection used by the rest of the application.

const newSprintEvent: CalendarEvent = {
&nbsp; id: `sprint-${Date.now()}`,
&nbsp; title:
&nbsp; &nbsp; sprintName.trim() ||
&nbsp; &nbsp; `Sprint Schedule (${totalDays} Days)`,
&nbsp; category: "sprint",
&nbsp; host: "Engineering Team",
&nbsp; date: dateRange.from,
&nbsp; startTime: "09:00 AM",
&nbsp; endTime: "06:00 PM",
&nbsp; location: "Linear / Jira HQ · Main Sprint Board",
&nbsp; description:
&nbsp; &nbsp; `Committed sprint from ${format(
&nbsp; &nbsp; &nbsp; dateRange.from,
&nbsp; &nbsp; &nbsp; "MMM d"
&nbsp; &nbsp; )} to ${format(
&nbsp; &nbsp; &nbsp; dateRange.to,
&nbsp; &nbsp; &nbsp; "MMM d, yyyy"
&nbsp; &nbsp; )}. Total: ${workingDays} working days, ${targetPoints} story points committed.`,
&nbsp; status: "confirmed",
&nbsp; rsvpStatus: "going",
}

After the event is created, the application stores it, shows a success state, and provides a shortcut back to the Monthly Calendar.

Sprint View Gif

Create an agenda and RSVP experience

A calendar grid is useful for planning, but when users want to focus on individual events, a card-based agenda is often easier to work with.

For our application, we use card-24 to turn each calendar event into an interactive card. Instead of only displaying event information, the updated card can show attendees, join actions, RSVP controls, and event cancellation. This lets users manage an event directly from the agenda.

Create an Agenda and RSVP Experience

Part 1: Update the card-24 component

The original card was primarily focused on presenting event information. We extended it with props for event data and actions so it can be reused inside a real scheduling application.

The updated component supports:

  • Event category and category styling
  • Title and description
  • Event image
  • Date and time
  • Location
  • Meeting URL
  • Attendee avatars and attendee count
  • Custom CTA label
  • RSVP state
  • RSVP change callback
  • Event deletion callback
  • Custom class names
  • Animated entrance and hover interactions

The main API is built around the event actions:

export interface EventCardProps {
&nbsp; category?: string;
&nbsp; categoryBadgeClass?: string;
&nbsp; title?: string;
&nbsp; description?: string;
&nbsp; image?: string;
&nbsp; month?: string;
&nbsp; day?: string;
&nbsp; time?: string;
&nbsp; location?: string;
&nbsp; callUrl?: string;
&nbsp; attendees?: {
&nbsp; &nbsp; name: string;
&nbsp; &nbsp; avatar?: string;
&nbsp; }&#91;];
&nbsp; attendeeCount?: number;
&nbsp; ctaLabel?: string;
&nbsp; rsvpStatus?: "going" | "maybe" | "declined" | null;
&nbsp; onRsvpToggle?: (
&nbsp; &nbsp; status: "going" | "maybe" | "declined"
&nbsp; ) => void;
&nbsp; onDelete?: () => void;
&nbsp; className?: string;
}

The important change is that the card no longer owns the application’s RSVP or event state. It receives the current state through rsvpStatus and notifies the parent through onRsvpToggle. The same pattern is used for event deletion with onDelete.

Interactive RSVP actions

The card provides three RSVP actions:

{onRsvpToggle && (
&nbsp; <div className="grid grid-cols-3 gap-1.5 pt-1">
&nbsp; &nbsp; <Button
&nbsp; &nbsp; &nbsp; size="sm"
&nbsp; &nbsp; &nbsp; variant={
&nbsp; &nbsp; &nbsp; &nbsp; rsvpStatus === "going"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ? "default"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : "outline"
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; onClick={() =>
&nbsp; &nbsp; &nbsp; &nbsp; onRsvpToggle("going")
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; {rsvpStatus === "going" && (
&nbsp; &nbsp; &nbsp; &nbsp; <Check className="size-3 mr-1" />
&nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; Going
&nbsp; &nbsp; </Button>

&nbsp; &nbsp; <Button
&nbsp; &nbsp; &nbsp; size="sm"
&nbsp; &nbsp; &nbsp; variant={
&nbsp; &nbsp; &nbsp; &nbsp; rsvpStatus === "maybe"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ? "default"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : "outline"
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; onClick={() =>
&nbsp; &nbsp; &nbsp; &nbsp; onRsvpToggle("maybe")
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; Maybe
&nbsp; &nbsp; </Button>

&nbsp; &nbsp; <Button
&nbsp; &nbsp; &nbsp; size="sm"
&nbsp; &nbsp; &nbsp; variant={
&nbsp; &nbsp; &nbsp; &nbsp; rsvpStatus === "declined"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ? "default"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : "outline"
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; onClick={() =>
&nbsp; &nbsp; &nbsp; &nbsp; onRsvpToggle("declined")
&nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; Decline
&nbsp; &nbsp; </Button>
&nbsp; </div>
)}

The selected RSVP state changes the visual treatment of the button, while the actual event state remains with the parent application.

Add attendees and meeting actions

The footer combines attendee avatars with the actions related to the event.

<div className="flex items-center gap-2">
&nbsp; <div className="flex -space-x-2">
&nbsp; &nbsp; {attendees.map((attendee, index) => (
&nbsp; &nbsp; &nbsp; <Avatar key={index}>
&nbsp; &nbsp; &nbsp; &nbsp; <AvatarImage
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; src={attendee.avatar}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; alt={attendee.name}
&nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; <AvatarFallback>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {attendee.name&#91;0]}
&nbsp; &nbsp; &nbsp; &nbsp; </AvatarFallback>
&nbsp; &nbsp; &nbsp; </Avatar>
&nbsp; &nbsp; ))}
&nbsp; </div>

&nbsp; <span className="text-xs text-muted-foreground">
&nbsp; &nbsp; +{attendeeCount} going
&nbsp; </span>
</div>

When a meeting URL is available, the card also exposes a direct Join action. When onDelete is provided, users can cancel the event without leaving the agenda.

{onDelete && (
&nbsp; <Button
&nbsp; &nbsp; size="icon"
&nbsp; &nbsp; variant="ghost"
&nbsp; &nbsp; onClick={onDelete}
&nbsp; &nbsp; title="Cancel event"
&nbsp; >
&nbsp; &nbsp; <Trash2 className="size-3.5" />
&nbsp; </Button>
)}

{callUrl && (
&nbsp; <Button
&nbsp; &nbsp; size="sm"
&nbsp; &nbsp; variant="outline"
&nbsp; &nbsp; render={
&nbsp; &nbsp; &nbsp; <a
&nbsp; &nbsp; &nbsp; &nbsp; href={callUrl}
&nbsp; &nbsp; &nbsp; &nbsp; target="_blank"
&nbsp; &nbsp; &nbsp; &nbsp; rel="noreferrer"
&nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; }
&nbsp; >
&nbsp; &nbsp; <Video className="size-3.5" />
&nbsp; &nbsp; Join
&nbsp; </Button>
)}

This gives the event card both information and actions without moving the user into another screen.

Animation without changing the event logic

The updated card also uses Motion for entrance and interaction effects. The main card animates into view, while attendee avatars and content appear progressively.

The animation remains separate from the event logic, so adding motion does not change how RSVP or event state is managed.

Full updated card-24 component

For the open-source example, this is where we should include the complete updated card-24 source rather than only selected snippets.

"use client";

import { useId, useState } from "react";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
&nbsp; InputGroup,
&nbsp; InputGroupAddon,
&nbsp; InputGroupInput,
} from "@/components/ui/input-group";
import {
&nbsp; Popover,
&nbsp; PopoverContent,
&nbsp; PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { CalendarIcon, ClockIcon, ChevronDownIcon } from "lucide-react";

export const formatTime12h = (timeStr: string) => {
&nbsp; if (!timeStr) return "";
&nbsp; const parts = timeStr.split(":");
&nbsp; const hours = parseInt(parts&#91;0], 10);
&nbsp; const minutes = parts&#91;1] || "00";
&nbsp; if (isNaN(hours)) return timeStr;
&nbsp; const ampm = hours >= 12 ? "PM" : "AM";
&nbsp; const displayHours = hours % 12 === 0 ? 12 : hours % 12;
&nbsp; return `${displayHours}:${minutes} ${ampm}`;
};

const addHoursToTime = (timeStr: string, hoursToAdd: number): string => {
&nbsp; if (!timeStr) return "";
&nbsp; const &#91;h, m] = timeStr.split(":").map(Number);
&nbsp; if (isNaN(h) || isNaN(m)) return timeStr;
&nbsp; const newHour = (h + hoursToAdd) % 24;
&nbsp; const pad = (n: number) => String(n).padStart(2, "0");
&nbsp; return `${pad(newHour)}:${pad(m)}`;
};

const getDurationHours = (start: string, end: string): number | null => {
&nbsp; if (!start || !end) return null;
&nbsp; const &#91;sh, sm] = start.split(":").map(Number);
&nbsp; const &#91;eh, em] = end.split(":").map(Number);
&nbsp; if (isNaN(sh) || isNaN(sm) || isNaN(eh) || isNaN(em)) return null;
&nbsp; const startMin = sh * 60 + sm;
&nbsp; const endMin = eh * 60 + em;
&nbsp; const diffMin = endMin - startMin;
&nbsp; if (diffMin <= 0) return null;
&nbsp; return diffMin / 60;
};

const PRESETS = &#91;
&nbsp; { label: "1h", value: 1 },
&nbsp; { label: "2h", value: 2 },
&nbsp; { label: "4h", value: 4 },
];

export interface CalendarWithTimeRangeProps {
&nbsp; date?: Date;
&nbsp; onDateChange?: (date: Date | undefined) => void;
&nbsp; startTime?: string;
&nbsp; onStartTimeChange?: (time: string) => void;
&nbsp; endTime?: string;
&nbsp; onEndTimeChange?: (time: string) => void;
&nbsp; onRangeChange?: (range: { date: Date | undefined; startTime: string; endTime: string }) => void;
&nbsp; className?: string;
&nbsp; disabled?: (date: Date) => boolean;
}

export const CalendarWithTimeRange = ({
&nbsp; date: controlledDate,
&nbsp; onDateChange,
&nbsp; startTime: controlledStartTime,
&nbsp; onStartTimeChange,
&nbsp; endTime: controlledEndTime,
&nbsp; onEndTimeChange,
&nbsp; onRangeChange,
&nbsp; className,
&nbsp; disabled,
}: CalendarWithTimeRangeProps = {}) => {
&nbsp; const id = useId();
&nbsp; const &#91;internalDate, setInternalDate] = useState<Date | undefined>(new Date());
&nbsp; const &#91;internalStartTime, setInternalStartTime] = useState("10:30");
&nbsp; const &#91;internalEndTime, setInternalEndTime] = useState("12:30");

&nbsp; const date = controlledDate !== undefined ? controlledDate : internalDate;
&nbsp; const startTime = controlledStartTime !== undefined ? controlledStartTime : internalStartTime;
&nbsp; const endTime = controlledEndTime !== undefined ? controlledEndTime : internalEndTime;

&nbsp; const handleDateChange = (newDate: Date | undefined) => {
&nbsp; &nbsp; if (controlledDate === undefined) {
&nbsp; &nbsp; &nbsp; setInternalDate(newDate);
&nbsp; &nbsp; }
&nbsp; &nbsp; onDateChange?.(newDate);
&nbsp; &nbsp; onRangeChange?.({ date: newDate, startTime, endTime });
&nbsp; };

&nbsp; const handleStartTimeChange = (newStartTime: string) => {
&nbsp; &nbsp; if (controlledStartTime === undefined) {
&nbsp; &nbsp; &nbsp; setInternalStartTime(newStartTime);
&nbsp; &nbsp; }
&nbsp; &nbsp; onStartTimeChange?.(newStartTime);
&nbsp; &nbsp; onRangeChange?.({ date, startTime: newStartTime, endTime });
&nbsp; };

&nbsp; const handleEndTimeChange = (newEndTime: string) => {
&nbsp; &nbsp; if (controlledEndTime === undefined) {
&nbsp; &nbsp; &nbsp; setInternalEndTime(newEndTime);
&nbsp; &nbsp; }
&nbsp; &nbsp; onEndTimeChange?.(newEndTime);
&nbsp; &nbsp; onRangeChange?.({ date, startTime, endTime: newEndTime });
&nbsp; };

&nbsp; const isInvalid = !!(startTime && endTime && startTime > endTime);
&nbsp; const currentDuration = getDurationHours(startTime, endTime);
&nbsp; const isAllDayActive = startTime === "09:00" && endTime === "17:00";

&nbsp; return (
&nbsp; &nbsp; <Popover>
&nbsp; &nbsp; &nbsp; <PopoverTrigger
&nbsp; &nbsp; &nbsp; &nbsp; render={
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <Button
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={cn(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "group/pick-date w-full sm:w-fit min-w-0 sm:min-w-80 max-w-full justify-between text-left font-normal border-input hover:bg-accent hover:text-accent-foreground transition-all duration-200 cursor-pointer",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; !date && "text-muted-foreground",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; id={id}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; variant="outline"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; <div className="flex items-center gap-2.5 truncate">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <CalendarIcon
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; aria-hidden="true"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors h-4 w-4"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {date ? (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-foreground font-medium text-sm">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {format(date, "MMM d, yyyy")}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ) : (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-muted-foreground text-sm">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Pick a date and time
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {date && (startTime || endTime) && (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-muted-foreground/30 select-none">|</span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex items-center gap-1.5 text-muted-foreground/90">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <ClockIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-xs font-normal">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {startTime ? formatTime12h(startTime) : "--"}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {" - "}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {endTime ? formatTime12h(endTime) : "--"}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; <ChevronDownIcon className="h-4 w-4 text-muted-foreground/60 group-hover/pick-date:text-foreground shrink-0 transition-colors ml-2" />
&nbsp; &nbsp; &nbsp; </PopoverTrigger>
&nbsp; &nbsp; &nbsp; <PopoverContent
&nbsp; &nbsp; &nbsp; &nbsp; align="start"
&nbsp; &nbsp; &nbsp; &nbsp; className="w-auto p-0 flex flex-col sm:flex-row"
&nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; {/* Left Side: Calendar */}
&nbsp; &nbsp; &nbsp; &nbsp; <div className="p-2.5">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <Calendar
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; mode="single"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; selected={date}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onSelect={handleDateChange}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; disabled={disabled}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className="p-0"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; &nbsp; {/* Separator for mobile */}
&nbsp; &nbsp; &nbsp; &nbsp; <Separator orientation="horizontal" className="sm:hidden" />

&nbsp; &nbsp; &nbsp; &nbsp; {/* Separator for desktop */}
&nbsp; &nbsp; &nbsp; &nbsp; <Separator orientation="vertical" className="hidden sm:block" />

&nbsp; &nbsp; &nbsp; &nbsp; {/* Right Side: Time Range & Presets */}
&nbsp; &nbsp; &nbsp; &nbsp; <div className="p-4 flex flex-col gap-4 w-full sm:max-w-70 justify-start">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {/* Time range labels & inputs */}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex flex-col gap-1.5">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-&#91;10px] font-semibold uppercase tracking-wider text-muted-foreground">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Time Range
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="grid grid-cols-2 gap-2">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex flex-col gap-1">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-&#91;10px] text-muted-foreground font-medium">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Start
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroup>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroupInput
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; id="time-from"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; type="time"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; value={startTime}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onChange={(e) => handleStartTimeChange(e.target.value)}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className="appearance-none &#91;&::-webkit-calendar-picker-indicator]:hidden &#91;&::-webkit-calendar-picker-indicator]:appearance-none text-xs"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroupAddon>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <ClockIcon className="text-muted-foreground/75" />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </InputGroupAddon>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </InputGroup>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex flex-col gap-1">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-&#91;10px] text-muted-foreground font-medium">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; End
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroup className={cn(isInvalid && "border-destructive")}>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroupInput
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; id="time-to"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; type="time"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; value={endTime}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onChange={(e) => handleEndTimeChange(e.target.value)}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; aria-invalid={isInvalid}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className="appearance-none &#91;&::-webkit-calendar-picker-indicator]:hidden &#91;&::-webkit-calendar-picker-indicator]:appearance-none text-xs"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <InputGroupAddon>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <ClockIcon
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={cn(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "text-muted-foreground/75",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; isInvalid && "text-destructive",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </InputGroupAddon>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </InputGroup>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {/* Preset Buttons */}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="flex flex-col gap-1.5">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <span className="text-&#91;10px] font-semibold uppercase tracking-wider text-muted-foreground">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Presets
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </span>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <div className="grid grid-cols-2 gap-2">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {PRESETS.map((preset) => {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; const isActive = currentDuration === preset.value;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <Button
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; key={preset.label}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; variant={isActive ? "default" : "outline"}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; size="sm"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={cn(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "h-8 text-xs font-normal transition-all w-full cursor-pointer",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; isActive
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ? "shadow-sm"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : "hover:bg-accent hover:text-accent-foreground text-muted-foreground/80",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onClick={() => {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; handleEndTimeChange(addHoursToTime(startTime, preset.value));
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {preset.label}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </Button>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; );
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; })}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <Button
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; variant={isAllDayActive ? "default" : "outline"}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; size="sm"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; className={cn(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "h-8 text-xs font-normal transition-all w-full cursor-pointer",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; isAllDayActive
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ? "shadow-sm"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : "hover:bg-accent hover:text-accent-foreground text-muted-foreground/80",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onClick={() => {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; handleStartTimeChange("09:00");
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; handleEndTimeChange("17:00");
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; All Day
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </Button>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {isInvalid && (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <p className="text-xs text-destructive font-medium mt-1 leading-normal text-center animate-in fade-in duration-200">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; End time must be after start time
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </p>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )}
&nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; </PopoverContent>
&nbsp; &nbsp; </Popover>
&nbsp; );
};

export default CalendarWithTimeRange;

This makes the modification reproducible for developers who want to understand how the reusable component was extended.


Part 2: Connect card-24 to the agenda

The application-level AgendaView is responsible for mapping our shared CalendarEvent objects into the props expected by card-24.

<EventCard
&nbsp; key={evt.id}
&nbsp; category={catObj?.name || "Meeting"}
&nbsp; categoryBadgeClass={cn(
&nbsp; &nbsp; catObj?.bgLight,
&nbsp; &nbsp; catObj?.text
&nbsp; )}
&nbsp; title={evt.title}
&nbsp; description={evt.description}
&nbsp; image={evt.image}
&nbsp; month={format(
&nbsp; &nbsp; evt.date,
&nbsp; &nbsp; "MMM"
&nbsp; ).toUpperCase()}
&nbsp; day={format(evt.date, "d")}
&nbsp; time={`${evt.startTime} - ${evt.endTime}`}
&nbsp; location={evt.location}
&nbsp; callUrl={evt.callUrl}
&nbsp; attendees={evt.attendees}
&nbsp; attendeeCount={evt.attendeesCount}
&nbsp; rsvpStatus={evt.rsvpStatus}
&nbsp; onRsvpToggle={(status) =>
&nbsp; &nbsp; onToggleRsvp(evt.id, status)
&nbsp; }
&nbsp; onDelete={
&nbsp; &nbsp; onDeleteEvent
&nbsp; &nbsp; &nbsp; ? () => onDeleteEvent(evt.id)
&nbsp; &nbsp; &nbsp; : undefined
&nbsp; }
/>

This is where the reusable component connects to the application’s actual event state. The card does not need to know where an event comes from or how it is stored. It only receives the data it needs and reports user actions back to the parent.

Keep RSVP state in the shared event model

When a user clicks Going, Maybe, or Decline, the AgendaView passes the event ID and new RSVP state back to the application.

onRsvpToggle={(status) =>
&nbsp; onToggleRsvp(evt.id, status)
}

That shared event state can then be reflected anywhere else in the application where the same event appears.

RSVP View GIf

This gives the calendar application two complementary ways to work with events:

Monthly Calendar: Find events by date.

Agenda: Focus on an event and take action.

The reusable card-24 component handles the presentation and interaction, while the application remains responsible for the actual event state and business logic.


Add global timezone selection

Scheduling becomes harder when the people using the application are working in different time zones. Showing the selected timezone in the calendar interface gives users context when choosing dates and booking appointments.

For this application, we use the ShadcnSpace combobox-04 component as a searchable timezone selector. It combines a popover, search input, and selectable timezone list so users can quickly find the timezone they want to use.

Add Global Timezone Selection

Connect the timezone to application state

The component accepts a value and onValueChange, which allows the parent application to keep the selected timezone in a shared state.

const &#91;selectedTimezone, setSelectedTimezone] =
&nbsp; React.useState("Asia/Kolkata");

<TimezoneCombobox
&nbsp; value={selectedTimezone}
&nbsp; onValueChange={setSelectedTimezone}
/>

The timezone selector itself keeps the UI interaction local, while the application decides what to do with the selected value.

Make the timezone searchable

A calendar application can have a long list of time zones, so displaying every option at once is not practical. The component uses a searchable command list to filter the available options.

<Command>
&nbsp; <CommandInput placeholder="Search timezone..." />

&nbsp; <CommandList>
&nbsp; &nbsp; <CommandEmpty>No timezone found.</CommandEmpty>

&nbsp; &nbsp; <CommandGroup>
&nbsp; &nbsp; &nbsp; {timezones.map((timezone) => (
&nbsp; &nbsp; &nbsp; &nbsp; <CommandItem
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; key={timezone.value}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; value={timezone.label}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onSelect={() => {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; onValueChange(timezone.value);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; setOpen(false);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }}
&nbsp; &nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {timezone.label}
&nbsp; &nbsp; &nbsp; &nbsp; </CommandItem>
&nbsp; &nbsp; &nbsp; ))}
&nbsp; &nbsp; </CommandGroup>
&nbsp; </CommandList>
</Command>

This keeps the interaction compact while still making a large timezone list easy to navigate.

Use the selected timezone in scheduling

The selected timezone is also passed into the scheduling workflow. When an appointment is created, the application includes the active timezone in the event description so the booking context remains visible to the user.

description: `Confirmed 45-min appointment on ${
  format(bookingDate, "EEEE, MMMM d, yyyy")
} in ${selectedTimezone.replace("_", " ")}.`,
time zone view

This keeps timezone selection at the application level while allowing the individual calendar and scheduling components to remain reusable.


Handle notifications without modifying generated components

Interactive applications need feedback when something happens. In our calendar application, booking an appointment and locking a sprint both trigger notifications.

Rather than changing the generated toast component directly, we created an AppToaster wrapper. This keeps the underlying component untouched while allowing the application to control its positioning and reuse the same toast API.

The wrapper exposes a simple position prop:

<AppToaster position="top-right" />

The application can then use the same toast functions from the wrapper:

toast.add({
&nbsp; title: "Appointment Successfully Reserved!",
&nbsp; description: "Your appointment has been scheduled.",
&nbsp; type: "success",
});

The important idea is to extend the generated UI through a wrapper instead of modifying the generated component itself. This keeps application-specific behavior separate from the reusable UI source and makes future component updates easier to manage.


Persist calendar state in the browser

A calendar demo feels much more realistic when changes survive a page refresh. For the open-source application, we use localStorage to persist the current event collection and provide a reset action for restoring the original demo data.

When the application loads, it checks for saved events and converts the stored date strings back into Date objects:

React.useEffect(() => {
&nbsp; try {
&nbsp; &nbsp; const saved =
&nbsp; &nbsp; &nbsp; localStorage.getItem("orbitcal_events");

&nbsp; &nbsp; if (saved) {
&nbsp; &nbsp; &nbsp; setEvents(
&nbsp; &nbsp; &nbsp; &nbsp; JSON.parse(saved).map(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; (event: CalendarEvent) => ({
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ...event,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; date: new Date(event.date),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; })
&nbsp; &nbsp; &nbsp; &nbsp; )
&nbsp; &nbsp; &nbsp; );
&nbsp; &nbsp; }
&nbsp; } catch {}
}, &#91;]);

A reset action restores the original demo state:

const handleResetDemoData = () => {
&nbsp; localStorage.removeItem("orbitcal_events");

&nbsp; setEvents(INITIAL_EVENTS);

&nbsp; toast.add({
&nbsp; &nbsp; title: "Demo Data Restored",
&nbsp; &nbsp; description:
&nbsp; &nbsp; &nbsp; "Sample meetings, sprint schedules, and categories have been reset.",
&nbsp; &nbsp; type: "success",
&nbsp; });
};

This is particularly useful for an open-source demo because developers can experiment with the application, refresh the page, and reset everything back to the original sample data when needed.

Persist Calendar State in the Browser

Production considerations for calendar applications

A calendar can look correct in the browser while still having problems once real scheduling data and users are involved. A few implementation details are especially important when moving from a demo to a production application.

Store time consistently

Calendar events should have a consistent time representation when they are stored and then be formatted for the user’s timezone when displayed. This becomes particularly important when an appointment is created in one timezone and viewed in another.

In this project, the active timezone is kept as part of the scheduling context, and the scheduler uses it when creating the appointment details.

Keep generated UI components separate from application logic

When extending shadcn or ShadcnSpace components, avoid putting application-specific state directly into generated core files.

For example, instead of changing the original toast implementation, the project adds an AppToaster wrapper to control positioning and expose the same toast functionality.

This keeps reusable UI code separate from application behavior.

Keep calendar state shared

The Monthly Calendar, Scheduler, Sprint Planner, and Agenda all work with the same CalendarEvent structure. That means a new appointment created by the scheduler can appear in the other views without maintaining separate event collections.

Validate scheduling input

User-selected dates and times should be validated before creating an event. Our calendar-16 implementation, for example, checks that the end time occurs after the start time and exposes that state to the UI.

Test the less obvious cases

Calendar interfaces often fail around cases that are easy to overlook:

  • Date ranges crossing into another month
  • Empty days and months
  • Past dates in booking workflows
  • Invalid time ranges
  • Events with no attendees
  • Events without meeting links
  • Different timezone selections
  • Refreshing the application after creating an event

The open-source implementation gives you a practical starting point for testing these interactions rather than treating the calendar as a purely visual component.


Common mistakes when building a calendar application

Calendar interfaces often look simple until different features start sharing the same dates, events, and scheduling state. A few implementation decisions can create problems later.

Treating the calendar as only a date picker

A date picker solves date selection, but a complete scheduling application also needs event data, time ranges, booking actions, event details, and different ways to view the schedule.

Designing the calendar around the actual workflows first makes it easier to decide which components and states are needed.

Creating separate event data for every view

The Month View, Scheduler, Sprint Planner, and Agenda all work with the same CalendarEvent structure in this project.

Maintaining separate event objects for each view can lead to inconsistencies. A booking created in one view may not appear correctly in another.

Mixing application logic into reusable components

Our updated ShadcnSpace components expose props and callbacks for application integration, while the main views handle application-specific behavior.

For example, calendar-16 manages date and time range selection, while SchedulerView creates the actual CalendarEvent and adds it to the application.

Ignoring invalid or empty states

Scheduling interfaces should account for cases such as:

  • No events for the selected date
  • No events for the current month
  • An incomplete date range
  • An end time before the start time
  • Past dates that cannot be booked

These states are handled directly in the application and its reusable components.

The main lesson is to treat calendar development as an application state problem as much as a UI problem.


Explore the open-source calendar application

The complete shadcn calendar application is available as an open-source project so you can inspect how the individual pieces work together.

The repository includes the calendar views, shared event model, reusable component updates, scheduling logic, state persistence, and the supporting UI used throughout the application.

You can use the repository to:

  • Run the application locally
  • Explore the component structure
  • See how the ShadcnSpace components were extended
  • Experiment with the scheduling workflows
  • Adapt the implementation for your own project

The goal of the repository is to provide a complete reference implementation rather than isolated component examples.


Explore more calendar blocks in ShadcnSpace

The open-source application demonstrates one way to combine calendar and scheduling interfaces, but the same components can be adapted to many other use cases.

ShadcnSpace also provides additional Calendar blocks for developers who want reusable interfaces instead of assembling every calendar experience from scratch.

Explore More Calendar Blocks in ShadcnSpace

For production projects, you can explore the ShadcnSpace Pro Calendar collection and use the blocks as a starting point for your own application.

Here, we should add the actual URLs for your Pro Calendar application blocks rather than generic links, so each featured block can take the reader directly to its documentation or preview.


Frequently Asked Questions

1. How do I build a calendar application with shadcn/ui?

Start with the calendar component and a shared event data model, then build the scheduling workflows around it. This project combines monthly scheduling, appointment booking, range planning, and agenda management.

2. Can shadcn/ui be used for a complete calendar application?

Yes. The calendar component provides the UI foundation, while application logic handles events, scheduling, persistence, and other workflows.

3. How do I add event scheduling to a shadcn calendar?

Use a shared event model and connect the calendar to a booking workflow. In this project, the scheduler creates a CalendarEvent that can then be displayed by the other calendar views.

4. How should I handle date and time ranges?

Keep the date, start time, and end time as explicit values. The updated calendar-16 component also provides range callbacks, duration presets, and validation for invalid ranges.

5. How do I handle multiple calendar views?

Use the same event data across each view and let each component focus on a particular interaction, such as monthly browsing, scheduling, range planning, or agenda management.


Conclusion

A calendar application is not just a grid of dates. Once you add scheduling, event management, range planning, RSVP actions, time zones, and persistence, it becomes a collection of connected workflows.

In this project, we used reusable ShadcnSpace components as the UI foundation, extended them where the application needed additional behavior, and kept the application-specific logic in the surrounding views.

The result is an open-source Calendar & Scheduling application that shows how reusable shadcn/ui components can be combined into a larger real-world interface.

block to block redirection

Summarize with AI

Share Instantly