rapidUI

Full Calendar

Month, week and day calendar for apps that show and schedule events, with a time grid and all-day events.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/full-calendar.json

Installs to @/components/ui/full-calendar.

Manual installation

Install the dependencies:

pnpm add date-fns@^4.4.0 lucide-react@^1.48.0

Add the components it builds on: button, popover, toggle-group.

Copy the source into your project:

components/ui/full-calendar.tsx
'use client'

import {
	addDays,
	addMonths,
	addWeeks,
	differenceInMinutes,
	eachDayOfInterval,
	endOfDay,
	endOfMonth,
	endOfWeek,
	format,
	getISOWeek,
	isSameDay,
	isSameMonth,
	isToday,
	type Locale,
	startOfDay,
	startOfMonth,
	startOfWeek,
} from 'date-fns'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'

export type FullCalendarView = 'month' | 'week' | 'day'

export type CalendarEventColor =
	| 'default'
	| 'primary'
	| 'destructive'
	| 'chart-1'
	| 'chart-2'
	| 'chart-3'
	| 'chart-4'
	| 'chart-5'

export interface CalendarEvent {
	id: string
	title: string
	start: Date
	end: Date
	/** Shown in the all-day row in week and day view, without a time */
	allDay?: boolean
	color?: CalendarEventColor
}

type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6

export interface FullCalendarLabels {
	today: string
	previous: string
	next: string
	views: Record<FullCalendarView, string>
	allDay: string
	week: string
	/** "2 more" in month view */
	more: (count: number) => string
	/** Name of the button that creates an event on a day or at a time */
	addEvent: (when: string) => string
}

const defaultLabels: FullCalendarLabels = {
	today: 'Today',
	previous: 'Previous',
	next: 'Next',
	views: { month: 'Month', week: 'Week', day: 'Day' },
	allDay: 'All day',
	week: 'Week',
	more: (count) => `${count} more`,
	addEvent: (when) => `Add event, ${when}`,
}

export interface FullCalendarProps {
	events: CalendarEvent[]
	/** The date shown (controlled). Any date in the month, week or day to show. */
	date?: Date
	defaultDate?: Date
	onDateChange?: (date: Date) => void
	view?: FullCalendarView
	defaultView?: FullCalendarView
	onViewChange?: (view: FullCalendarView) => void
	/** Views offered in the view switcher */
	views?: FullCalendarView[]
	/** Monday by default */
	weekStartsOn?: WeekDay
	/** date-fns locale for month and weekday names */
	locale?: Locale
	/** First and last hour of the time grid in week and day view */
	startHour?: number
	endHour?: number
	/** Events shown per day in month view before "N more" */
	maxEventsPerDay?: number
	onEventClick?: (event: CalendarEvent) => void
	/** A free day (month view) or hour (week and day view) was chosen, e.g. to create an event there */
	onSlotClick?: (slot: { start: Date; end: Date; allDay: boolean }) => void
	labels?: Partial<FullCalendarLabels>
	className?: string
}

const HOUR_HEIGHT = 48

const eventColors: Record<CalendarEventColor, string> = {
	default: 'border-l-muted-foreground bg-muted',
	primary: 'border-l-primary bg-primary/10',
	destructive: 'border-l-destructive bg-destructive/10',
	'chart-1': 'border-l-chart-1 bg-chart-1/15',
	'chart-2': 'border-l-chart-2 bg-chart-2/15',
	'chart-3': 'border-l-chart-3 bg-chart-3/15',
	'chart-4': 'border-l-chart-4 bg-chart-4/15',
	'chart-5': 'border-l-chart-5 bg-chart-5/15',
}

function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (value: T) => void) {
	const [inner, setInner] = React.useState(defaultValue)
	const current = value ?? inner
	const set = React.useCallback(
		(next: T) => {
			if (value === undefined) setInner(next)
			onChange?.(next)
		},
		[value, onChange],
	)
	return [current, set] as const
}

const timeRange = (event: CalendarEvent, locale?: Locale) =>
	event.allDay ? '' : `${format(event.start, 'HH:mm', { locale })}–${format(event.end, 'HH:mm', { locale })}`

/** Events that touch the given day */
const eventsOn = (events: CalendarEvent[], day: Date) =>
	events.filter((e) => e.start <= endOfDay(day) && e.end > startOfDay(day))

/** The toolbar heading: the month, the week with its date range, or the day */
function viewTitle(
	view: FullCalendarView,
	date: Date,
	{ weekStartsOn, locale, weekLabel }: { weekStartsOn: WeekDay; locale?: Locale; weekLabel: string },
) {
	if (view === 'month') return format(date, 'LLLL yyyy', { locale })
	if (view === 'day') return format(date, 'EEEE d MMMM yyyy', { locale })
	const from = format(startOfWeek(date, { weekStartsOn }), 'd MMM', { locale })
	const to = format(endOfWeek(date, { weekStartsOn }), 'd MMM yyyy', { locale })
	return `${weekLabel} ${getISOWeek(date)} · ${from} – ${to}`
}

/** A full calendar for apps that show and schedule events: month, week and day views with a time grid. */
function FullCalendar({
	events,
	date: dateProp,
	defaultDate,
	onDateChange,
	view: viewProp,
	defaultView = 'month',
	onViewChange,
	views = ['month', 'week', 'day'],
	weekStartsOn = 1,
	locale,
	startHour = 7,
	endHour = 19,
	maxEventsPerDay = 3,
	onEventClick,
	onSlotClick,
	labels: labelsProp,
	className,
}: Readonly<FullCalendarProps>) {
	const labels = { ...defaultLabels, ...labelsProp, views: { ...defaultLabels.views, ...labelsProp?.views } }
	const [date, setDate] = useControllable(dateProp, defaultDate ?? new Date(), onDateChange)
	const [view, setView] = useControllable(viewProp, defaultView, onViewChange)
	const headingId = React.useId()

	const step = (direction: 1 | -1) => {
		if (view === 'month') setDate(addMonths(date, direction))
		else if (view === 'week') setDate(addWeeks(date, direction))
		else setDate(addDays(date, direction))
	}

	const weekStart = startOfWeek(date, { weekStartsOn })
	const title = viewTitle(view, date, { weekStartsOn, locale, weekLabel: labels.week })

	const props = { events, locale, labels, onEventClick, onSlotClick }

	return (
		<section data-slot="full-calendar" aria-labelledby={headingId} className={cn('flex flex-col gap-4', className)}>
			<div data-slot="full-calendar-toolbar" className="flex flex-wrap items-center gap-2">
				<div className="flex items-center gap-1">
					<Button variant="outline" onClick={() => setDate(new Date())}>
						{labels.today}
					</Button>
					<Button variant="ghost" size="icon" aria-label={labels.previous} onClick={() => step(-1)}>
						<ChevronLeftIcon aria-hidden="true" />
					</Button>
					<Button variant="ghost" size="icon" aria-label={labels.next} onClick={() => step(1)}>
						<ChevronRightIcon aria-hidden="true" />
					</Button>
				</div>
				<h2 id={headingId} aria-live="polite" className="mr-auto text-lg font-semibold first-letter:uppercase">
					{title}
				</h2>
				{views.length > 1 ? (
					<ToggleGroup
						variant="outline"
						spacing={0}
						value={[view]}
						onValueChange={(value: string[]) => {
							if (value[0]) setView(value[0] as FullCalendarView)
						}}
					>
						{views.map((v) => (
							<ToggleGroupItem key={v} value={v}>
								{labels.views[v]}
							</ToggleGroupItem>
						))}
					</ToggleGroup>
				) : null}
			</div>

			{view === 'month' ? (
				<MonthView {...props} date={date} weekStartsOn={weekStartsOn} maxEventsPerDay={maxEventsPerDay} />
			) : (
				<TimeGridView
					{...props}
					days={view === 'week' ? eachDayOfInterval({ start: weekStart, end: addDays(weekStart, 6) }) : [date]}
					startHour={startHour}
					endHour={endHour}
				/>
			)}
		</section>
	)
}

type ViewProps = {
	events: CalendarEvent[]
	locale?: Locale
	labels: FullCalendarLabels
	onEventClick?: (event: CalendarEvent) => void
	onSlotClick?: FullCalendarProps['onSlotClick']
}

function EventChip({
	event,
	locale,
	onEventClick,
	showTime = true,
}: Readonly<{
	event: CalendarEvent
	locale?: Locale
	onEventClick?: (event: CalendarEvent) => void
	showTime?: boolean
}>) {
	const time = showTime ? timeRange(event, locale) : ''
	return (
		<button
			type="button"
			data-slot="full-calendar-event"
			title={time ? `${time} ${event.title}` : event.title}
			onClick={() => onEventClick?.(event)}
			className={cn(
				'flex min-h-6 w-full min-w-0 items-center gap-1.5 rounded-sm border-l-3 px-1.5 text-left text-xs text-foreground outline-none transition-colors hover:brightness-95 focus-visible:ring-2 focus-visible:ring-ring dark:hover:brightness-110',
				eventColors[event.color ?? 'primary'],
			)}
		>
			{time ? (
				<span className="shrink-0 tabular-nums text-muted-foreground">{format(event.start, 'HH:mm', { locale })}</span>
			) : null}
			<span className="truncate font-medium">{event.title}</span>
		</button>
	)
}

function MonthView({
	date,
	events,
	locale,
	weekStartsOn,
	labels,
	onEventClick,
	onSlotClick,
	maxEventsPerDay,
}: Readonly<ViewProps & { date: Date; weekStartsOn: WeekDay; maxEventsPerDay: number }>) {
	const start = startOfWeek(startOfMonth(date), { weekStartsOn })
	const end = endOfWeek(endOfMonth(date), { weekStartsOn })
	const days = eachDayOfInterval({ start, end })
	const weeks = Array.from({ length: days.length / 7 }, (_, i) => days.slice(i * 7, i * 7 + 7))

	return (
		<div className="overflow-x-auto rounded-lg border">
			<table data-slot="full-calendar-month" className="w-full min-w-[640px] table-fixed border-collapse text-sm">
				<thead>
					<tr>
						{weeks[0].map((day) => (
							<th
								key={day.toISOString()}
								scope="col"
								className="border-b bg-muted/50 py-2 text-center text-xs font-medium text-muted-foreground"
							>
								<abbr title={format(day, 'EEEE', { locale })} className="no-underline">
									{format(day, 'EEE', { locale })}
								</abbr>
							</th>
						))}
					</tr>
				</thead>
				<tbody>
					{weeks.map((week) => (
						<tr key={week[0].toISOString()}>
							{week.map((day) => {
								const dayEvents = eventsOn(events, day).sort((a, b) => a.start.getTime() - b.start.getTime())
								const visible = dayEvents.slice(0, maxEventsPerDay)
								const hidden = dayEvents.length - visible.length
								const outside = !isSameMonth(day, date)
								const today = isToday(day)
								const dayLabel = format(day, 'EEEE d MMMM', { locale })
								return (
									<td
										key={day.toISOString()}
										aria-current={today ? 'date' : undefined}
										className={cn(
											'h-28 border-t border-r align-top last:border-r-0',
											outside && 'bg-muted/30 text-muted-foreground',
										)}
									>
										<div className="flex h-full flex-col gap-1 p-1">
											{onSlotClick ? (
												<button
													type="button"
													aria-label={labels.addEvent(dayLabel)}
													onClick={() => onSlotClick({ start: startOfDay(day), end: endOfDay(day), allDay: true })}
													className={cn(
														'grid size-7 place-items-center self-start rounded-full text-sm tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
														today && 'bg-primary font-semibold text-primary-foreground hover:bg-primary/90',
													)}
												>
													{format(day, 'd')}
												</button>
											) : (
												<span
													className={cn(
														'grid size-7 place-items-center rounded-full text-sm tabular-nums',
														today && 'bg-primary font-semibold text-primary-foreground',
													)}
												>
													{format(day, 'd')}
												</span>
											)}
											<ul className="flex min-w-0 flex-col gap-0.5">
												{visible.map((event) => (
													<li key={event.id} className="min-w-0">
														<EventChip event={event} locale={locale} onEventClick={onEventClick} />
													</li>
												))}
											</ul>
											{hidden > 0 ? (
												<Popover>
													<PopoverTrigger
														render={
															<button
																type="button"
																className="min-h-6 self-start rounded-sm px-1.5 text-xs font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
															/>
														}
													>
														{labels.more(hidden)}
													</PopoverTrigger>
													<PopoverContent align="start" className="w-64" aria-label={dayLabel}>
														<p className="text-sm font-medium first-letter:uppercase">{dayLabel}</p>
														<ul className="flex flex-col gap-1">
															{dayEvents.map((event) => (
																<li key={event.id}>
																	<EventChip event={event} locale={locale} onEventClick={onEventClick} />
																</li>
															))}
														</ul>
													</PopoverContent>
												</Popover>
											) : null}
										</div>
									</td>
								)
							})}
						</tr>
					))}
				</tbody>
			</table>
		</div>
	)
}

/** Side-by-side lanes for overlapping timed events */
function layoutDay(events: CalendarEvent[]) {
	const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime())
	const placed: { event: CalendarEvent; lane: number; lanes: number }[] = []
	let group: { event: CalendarEvent; lane: number; lanes: number }[] = []
	let groupEnd = 0
	const laneEnds: number[] = []
	const closeGroup = () => {
		const lanes = Math.max(1, ...group.map((g) => g.lane + 1))
		for (const g of group) g.lanes = lanes
		placed.push(...group)
		group = []
		laneEnds.length = 0
	}
	for (const event of sorted) {
		if (group.length && event.start.getTime() >= groupEnd) closeGroup()
		let lane = laneEnds.findIndex((end) => end <= event.start.getTime())
		if (lane === -1) lane = laneEnds.length
		laneEnds[lane] = event.end.getTime()
		group.push({ event, lane, lanes: 1 })
		groupEnd = Math.max(groupEnd, event.end.getTime())
	}
	if (group.length) closeGroup()
	return placed
}

function useNow() {
	const [now, setNow] = React.useState<Date | null>(null)
	React.useEffect(() => {
		setNow(new Date())
		const id = setInterval(() => setNow(new Date()), 60_000)
		return () => clearInterval(id)
	}, [])
	return now
}

function TimeGridView({
	days,
	events,
	locale,
	labels,
	onEventClick,
	onSlotClick,
	startHour,
	endHour,
}: Readonly<ViewProps & { days: Date[]; startHour: number; endHour: number }>) {
	const hours = Array.from({ length: endHour - startHour }, (_, i) => startHour + i)
	const now = useNow()
	const allDay = days.map((day) => eventsOn(events, day).filter((e) => e.allDay || !isSameDay(e.start, e.end)))
	const hasAllDay = allDay.some((list) => list.length > 0)
	const columns = `3.5rem repeat(${days.length}, minmax(0, 1fr))`

	return (
		<div data-slot="full-calendar-time-grid" className="overflow-x-auto rounded-lg border">
			<div className={cn(days.length > 1 && 'min-w-[720px]')}>
				{/* Day headers */}
				<div className="grid border-b bg-muted/50" style={{ gridTemplateColumns: columns }}>
					<div />
					{days.map((day) => {
						const today = isToday(day)
						const label = format(day, 'EEEE d MMMM', { locale })
						const content = (
							<>
								<span className="text-xs text-muted-foreground">{format(day, 'EEE', { locale })}</span>
								<span
									className={cn(
										'grid size-8 place-items-center rounded-full text-base font-medium tabular-nums',
										today && 'bg-primary text-primary-foreground',
									)}
								>
									{format(day, 'd')}
								</span>
							</>
						)
						return (
							<div
								key={day.toISOString()}
								aria-current={today ? 'date' : undefined}
								className="flex justify-center border-l py-1.5"
							>
								{onSlotClick ? (
									<button
										type="button"
										aria-label={labels.addEvent(label)}
										onClick={() => onSlotClick({ start: startOfDay(day), end: endOfDay(day), allDay: true })}
										className="flex flex-col items-center gap-0.5 rounded-md px-2 py-0.5 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
									>
										{content}
									</button>
								) : (
									<div className="flex flex-col items-center gap-0.5 px-2 py-0.5">{content}</div>
								)}
							</div>
						)
					})}
				</div>

				{/* All-day events */}
				{hasAllDay ? (
					<div className="grid border-b" style={{ gridTemplateColumns: columns }}>
						<div className="px-1 py-1.5 text-right text-xs text-muted-foreground">{labels.allDay}</div>
						{allDay.map((list, i) => (
							<ul key={days[i].toISOString()} className="flex min-w-0 flex-col gap-0.5 border-l p-1">
								{list.map((event) => (
									<li key={event.id} className="min-w-0">
										<EventChip event={event} locale={locale} onEventClick={onEventClick} showTime={false} />
									</li>
								))}
							</ul>
						))}
					</div>
				) : null}

				{/* Time grid */}
				<div className="max-h-[576px] overflow-y-auto">
					<div className="relative grid" style={{ gridTemplateColumns: columns }}>
						<div aria-hidden="true">
							{hours.map((hour) => (
								<div key={hour} className="relative text-right text-xs text-muted-foreground" style={{ height: HOUR_HEIGHT }}>
									<span className="absolute -top-2 right-2 tabular-nums">
										{hour > startHour ? `${String(hour).padStart(2, '0')}:00` : ''}
									</span>
								</div>
							))}
						</div>
						{days.map((day) => {
							const dayStart = new Date(day)
							dayStart.setHours(startHour, 0, 0, 0)
							const dayEnd = new Date(day)
							dayEnd.setHours(endHour, 0, 0, 0)
							const timed = eventsOn(events, day).filter(
								(e) => !e.allDay && isSameDay(e.start, e.end) && e.end > dayStart && e.start < dayEnd,
							)
							const showNow = now && isSameDay(now, day) && now >= dayStart && now < dayEnd
							return (
								<div key={day.toISOString()} className="relative border-l">
									{hours.map((hour) => {
										const slotStart = new Date(day)
										slotStart.setHours(hour, 0, 0, 0)
										const slotEnd = new Date(slotStart)
										slotEnd.setHours(hour + 1)
										return onSlotClick ? (
											<button
												key={hour}
												type="button"
												// Mouse and touch only: keyboard users create events from the day header
												tabIndex={-1}
												aria-label={labels.addEvent(format(slotStart, 'EEEE d MMMM HH:mm', { locale }))}
												onClick={() => onSlotClick({ start: slotStart, end: slotEnd, allDay: false })}
												className="block w-full border-b border-dashed last:border-b-0 hover:bg-muted/50"
												style={{ height: HOUR_HEIGHT }}
											/>
										) : (
											<div key={hour} className="border-b border-dashed last:border-b-0" style={{ height: HOUR_HEIGHT }} />
										)
									})}
									<ul className="absolute inset-0 pointer-events-none">
										{layoutDay(timed).map(({ event, lane, lanes }) => {
											// Clip the event to the visible hours
											const start = Math.max(event.start.getTime(), dayStart.getTime())
											const end = Math.min(event.end.getTime(), dayEnd.getTime())
											const top = (differenceInMinutes(start, dayStart) / 60) * HOUR_HEIGHT
											const height = Math.max((differenceInMinutes(end, start) / 60) * HOUR_HEIGHT, 24)
											return (
												<li
													key={event.id}
													className="pointer-events-auto absolute px-0.5"
													style={{ top, height, left: `${(lane / lanes) * 100}%`, width: `${100 / lanes}%` }}
												>
													<button
														type="button"
														data-slot="full-calendar-event"
														onClick={() => onEventClick?.(event)}
														className={cn(
															'flex size-full min-w-0 flex-col overflow-hidden rounded-sm border-l-3 px-1.5 py-1 text-left text-xs text-foreground outline-none transition-colors hover:brightness-95 focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring dark:hover:brightness-110',
															eventColors[event.color ?? 'primary'],
														)}
													>
														<span className="truncate font-medium">{event.title}</span>
														<span className="truncate tabular-nums text-muted-foreground">{timeRange(event, locale)}</span>
													</button>
												</li>
											)
										})}
									</ul>
									{showNow ? (
										<div
											aria-hidden="true"
											className="pointer-events-none absolute inset-x-0 z-10 flex items-center"
											style={{ top: (differenceInMinutes(now, dayStart) / 60) * HOUR_HEIGHT }}
										>
											<span className="-ml-1 size-2 rounded-full bg-destructive" />
											<span className="h-0.5 flex-1 bg-destructive" />
										</div>
									) : null}
								</div>
							)
						})}
					</div>
				</div>
			</div>
		</div>
	)
}

export { FullCalendar }

A calendar for apps that show and schedule events, such as meeting planners, booking systems and staff schedules. It has month, week and day views, and a time grid in which overlapping events sit side by side. Use Calendar instead when the user only needs to pick a date.

Features

  • Month, week and day views, with Today, Previous and Next navigation.
  • Timed and all-day events. In month view, extra events are collected in a "N more" popover.
  • A line that shows the current time in week and day view.
  • onSlotClick fires when the user chooses a free day or hour, and onEventClick fires when they choose an event. Use them to add and open events; the component doesn't store events itself.
  • Weeks start on Monday by default. Month and weekday names come from a date-fns locale, and every label can be translated.
  • Event colours use theme tokens, so events look right in light and dark mode and in every theme.

Usage

import { FullCalendar, type CalendarEvent } from "@/components/ui/full-calendar"

const events: CalendarEvent[] = [
  { id: "1", title: "Municipal council", start: new Date(2026, 3, 13, 18), end: new Date(2026, 3, 13, 21) },
  { id: "2", title: "Consultation deadline", start: new Date(2026, 3, 17), end: new Date(2026, 3, 17, 23, 59), allDay: true, color: "destructive" },
]

export function Meetings() {
  return (
    <FullCalendar
      events={events}
      onEventClick={(event) => openEvent(event)}
      onSlotClick={({ start, end, allDay }) => createEvent({ start, end, allDay })}
    />
  )
}

Examples

Week view

A home-help schedule. Visits that overlap are placed side by side.

Day view

Add and remove events

Click a day, an hour or a day header to create an event in a dialog; click an event to see it or remove it. The events live in the app's own state.

In Swedish

Pass a date-fns locale for month and weekday names and translate the labels with labels.

Props

PropTypeDefaultDescription
eventsCalendarEvent[]–{ id, title, start, end, allDay?, color? }
date / defaultDate / onDateChangeDatetodayThe date shown (controlled or uncontrolled)
view / defaultView / onViewChange'month' | 'week' | 'day''month'The current view
viewsFullCalendarView[]all threeViews offered in the switcher
weekStartsOn0–61 (Monday)First day of the week
localedate-fns Locale–Month and weekday names
startHour / endHournumber7 / 19Hours shown in week and day view
maxEventsPerDaynumber3Events per day in month view before "N more"
onEventClick(event) => void–An event was chosen
onSlotClick({ start, end, allDay }) => void–A free day or hour was chosen
labelsPartial<FullCalendarLabels>EnglishTexts for buttons, views and screen readers

Event color is one of default, primary, destructive and chart-1 to chart-5.

Accessibility

  • Month view is a table with column headers, and today is marked with aria-current="date".
  • Every event is a button, named by its time and title.
  • When onSlotClick is set, each day number (month view) and day header (week and day view) is a button that says what it does, so keyboard users can create events. The hour cells in the time grid respond to mouse and touch only, to keep the tab order short.
  • The title (month, week or day) is announced when it changes.
  • The colours are decoration. Always give an event a title that says what it is.
  • Calendar
  • Date Time Picker
  • Popover