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.jsonInstalls to @/components/ui/full-calendar.
Manual installation
Install the dependencies:
pnpm add date-fns@^4.4.0 lucide-react@^1.48.0Add the components it builds on: button, popover, toggle-group.
Copy the source into your project:
'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.
onSlotClickfires when the user chooses a free day or hour, andonEventClickfires 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
| Prop | Type | Default | Description |
|---|---|---|---|
events | CalendarEvent[] | – | { id, title, start, end, allDay?, color? } |
date / defaultDate / onDateChange | Date | today | The date shown (controlled or uncontrolled) |
view / defaultView / onViewChange | 'month' | 'week' | 'day' | 'month' | The current view |
views | FullCalendarView[] | all three | Views offered in the switcher |
weekStartsOn | 0–6 | 1 (Monday) | First day of the week |
locale | date-fns Locale | – | Month and weekday names |
startHour / endHour | number | 7 / 19 | Hours shown in week and day view |
maxEventsPerDay | number | 3 | Events 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 |
labels | Partial<FullCalendarLabels> | English | Texts 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
onSlotClickis 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.
Related Components
- Calendar
- Date Time Picker
- Popover