
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
event-calendaruiFull-featured event calendar primitive supporting month, week, day, work-week, and category/resource views, timed interval grids with collision clustering, all-day header, live current-time indicator, and customizable event templates.
Also available for Vue ->Calendar is the always-visible month grid. Date Picker is the form field with popover. Range Calendar manages multi-month spans, and Event Calendar is the multi-view scheduling engine.
| Component | Role | Use when |
|---|---|---|
| Calendar Embedded dashboard views or scheduling pages where the month grid stays continuously visible. | Grid Primitive | |
| RangeCalendar Contiguous multi-month booking ranges, check-in / check-out spans, and date windows. | Range Primitive | |
| DatePicker Forms, filter bars, and dialogs where space is tight and the calendar opens inside a popover. | Form Control | |
| EventCalendarcurrent Full calendar interfaces with month/week/day views, timed hourly intervals, and overlap collision lanes. | Scheduling Engine |
$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/event-calendar.json$npx shadcn@latest add https://uipkge.dev/r/react/event-calendar.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/event-calendar.json$bunx shadcn@latest add https://uipkge.dev/r/react/event-calendar.jsonnpx shadcn@latest add @uipkge-react/event-calendarInstalls to:components/ui/event-calendar/Multi-view scheduling dashboard demonstrating month, week intervals, day view, side-by-side collision handling, and event creation.
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'default''primary''secondary''success''warning''destructive''info''purple''rose' | default | optional |
size | 'sm''default''lg' | default | optional |
Type aliases from this item's source — use them to shape the data you pass in.
ClusterItemtype ClusterItem { event: CalendarEvent; startMinutes: number; endMinutes: number; col: number }
const clusters: ClusterItem[][] = []
let currentCluster: ClusterItem[] = []
let clusterEnd = -1
for (const item of timed) {
if (currentCluster.length === 0 || item.startMinutes < clusterEnd) {
currentCluster.push({ ...item, col: 0 })
clusterEnd = Math.max(clusterEnd, item.endMinutes)
} else {
clusters.push(currentCluster)
currentCluster = [{ ...item, col: 0 }]
clusterEnd = item.endMinutes
}
}
if (currentCluster.length > 0) {
clusters.push(currentCluster)
}
// For each cluster, assign columns
for (const cluster of clusters) {
const cols: number[] = [] // end time of each column
for (const item of cluster) {
let placedCol = -1
for (let c = 0; c < cols.length; c++) {
if (item.startMinutes >= cols[c]) {
placedCol = c
cols[c] = item.endMinutes
break
}
}
if (placedCol === -1) {
placedCol = cols.length
cols.push(item.endMinutes)
}
item.col = placedCol
}
const totalCols = Math.max(1, cols.length)
for (const item of cluster) {
const topPct = Math.max(0, Math.min(100, ((item.startMinutes - startDayMinutes) / totalMinutes) * 100))
const bottomPct = Math.max(0, Math.min(100, ((item.endMinutes - startDayMinutes) / totalMinutes) * 100))
const heightPct = Math.max(1.5, bottomPct - topPct)
const widthPct = 100 / totalCols
const leftPct = item.col * widthPct
positioned.push({
event: item.event,
top: topPct,
height: heightPct,
left: leftPct,
width: widthPct,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes,
})
}
}
return positioned
}CalendarCategoryinterface CalendarCategory {
id: string
name: string
color?: string
}CalendarEventinterface CalendarEvent {
id?: string
title: string
/** YYYY-MM-DD or YYYY-MM-DD HH:mm or ISO string or Date */
start: string | Date
/** YYYY-MM-DD or YYYY-MM-DD HH:mm or ISO string or Date */
end?: string | Date
allDay?: boolean
color?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'destructive'
| 'info'
| 'purple'
| 'rose'
| string
category?: string
description?: string
location?: string
[key: string]: any
}TimeClickPayloadinterface TimeClickPayload {
date: string
time: string
hour: number
minute: number
category?: string
}PositionedEventinterface PositionedEvent {
event: CalendarEvent
top: number
height: number
left: number
width: number
startMinutes: number
endMinutes: number
}CalendarDayCellinterface CalendarDayCell {
date: Date
dateKey: string
dayNumber: number
inMonth: boolean
isToday: boolean
events: CalendarEvent[]
allDayEvents: CalendarEvent[]
timedEvents: CalendarEvent[]
}'use client'
import * as React from 'react'
import { ChevronLeft, ChevronRight, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import type { CalendarView, CalendarCategory, CalendarEvent, EventCalendarProps, TimeClickPayload } from './types'
import {
parseDate,
formatDateKey,
formatTime,
formatHourLabel,
isSameDay,
isToday,
getMonthDays,
getWeekDays,
getWorkWeekDays,
calculateTimedEventPositions,
getCurrentTimePosition,
getEventMinutes,
} from './date-utils'
import { calendarEventVariants } from './event-calendar.variants'
export function EventCalendar({
value,
defaultValue,
view: controlledView,
defaultView = 'month',
events = [],
categories = [],
weekStartsOn = 0,
firstInterval = 0,
intervalCount = 24,
intervalMinutes = 60,
intervalHeight = 52,
timeFormat = '12h',
maxEventsPerDay = 3,
showNowIndicator = true,
showHeader = true,
className,
onDateChange,
onViewChange,
onEventClick,
onDateClick,
onTimeClick,
onMoreClick,
renderHeader,
renderEvent,
renderDayHeader,
renderAllDay,
renderInterval,
headerActions,
}: EventCalendarProps) {
// Controlled or uncontrolled active date
const [internalDate, setInternalDate] = React.useState<Date>(() => parseDate(value ?? defaultValue ?? new Date()))
const activeDate = value ? parseDate(value) : internalDate
const setDate = React.useCallback(
(newDate: Date) => {
setInternalDate(newDate)
onDateChange?.(newDate)
},
[onDateChange],
)
// Controlled or uncontrolled active view
const [internalView, setInternalView] = React.useState<CalendarView>(defaultView)
const activeView = controlledView ?? internalView
const setView = React.useCallback(
(newView: CalendarView) => {
setInternalView(newView)
onViewChange?.(newView)
},
[onViewChange],
)
// Normalized categories
const normalizedCategories = React.useMemo<CalendarCategory[]>(() => {
return categories.map((c, idx) => {
if (typeof c === 'string') {
return { id: c, name: c }
}
return { id: c.id || `cat-${idx}`, name: c.name || `Category ${idx + 1}`, color: c.color }
})
}, [categories])
// Current time position
const [nowPosition, setNowPosition] = React.useState<number | null>(null)
React.useEffect(() => {
if (!showNowIndicator) {
setNowPosition(null)
return
}
const update = () => {
setNowPosition(getCurrentTimePosition(firstInterval, intervalCount, intervalMinutes))
}
update()
const timer = setInterval(update, 30000)
return () => clearInterval(timer)
}, [showNowIndicator, firstInterval, intervalCount, intervalMinutes])
// Navigation handlers
const handlePrev = React.useCallback(() => {
const d = new Date(activeDate)
if (activeView === 'month') {
d.setMonth(d.getMonth() - 1)
} else if (activeView === 'week' || activeView === 'work-week') {
d.setDate(d.getDate() - 7)
} else if (activeView === 'day' || activeView === 'category') {
d.setDate(d.getDate() - 1)
}
setDate(d)
}, [activeDate, activeView, setDate])
const handleNext = React.useCallback(() => {
const d = new Date(activeDate)
if (activeView === 'month') {
d.setMonth(d.getMonth() + 1)
} else if (activeView === 'week' || activeView === 'work-week') {
d.setDate(d.getDate() + 7)
} else if (activeView === 'day' || activeView === 'category') {
d.setDate(d.getDate() + 1)
}
setDate(d)
}, [activeDate, activeView, setDate])
const handleToday = React.useCallback(() => {
setDate(new Date())
}, [setDate])
// Formatted Title
const formattedTitle = React.useMemo(() => {
const d = activeDate
if (activeView === 'month') {
return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
}
if (activeView === 'week') {
const days = getWeekDays(d, weekStartsOn)
const first = days[0]
const last = days[6]
if (first.getMonth() === last.getMonth()) {
return `${first.toLocaleDateString('en-US', { month: 'short' })} ${first.getDate()} – ${last.getDate()}, ${first.getFullYear()}`
}
return `${first.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} – ${last.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
}
if (activeView === 'work-week') {
const days = getWorkWeekDays(d)
const first = days[0]
const last = days[4]
return `${first.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} – ${last.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
}
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}, [activeDate, activeView, weekStartsOn])
// Month View Days
const monthDays = React.useMemo(() => getMonthDays(activeDate, weekStartsOn), [activeDate, weekStartsOn])
const getEventsForDay = React.useCallback(
(dateKey: string): CalendarEvent[] => {
return events.filter((e) => {
const startStr =
typeof e.start === 'string' && /^\d{4}-\d{2}-\d{2}/.test(e.start)
? e.start.slice(0, 10)
: formatDateKey(parseDate(e.start))
return startStr === dateKey
})
},
[events],
)
// Intervals for time grid
const intervals = React.useMemo(() => {
const list: { hour: number; label: string; time: string }[] = []
for (let i = 0; i < intervalCount; i++) {
const hour = (firstInterval + Math.floor((i * intervalMinutes) / 60)) % 24
const min = (i * intervalMinutes) % 60
const time = `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}`
const label = min === 0 ? formatHourLabel(hour, timeFormat) : ''
list.push({ hour, label, time })
}
return list
}, [firstInterval, intervalCount, intervalMinutes, timeFormat])
const weekDays = React.useMemo(() => getWeekDays(activeDate, weekStartsOn), [activeDate, weekStartsOn])
const workWeekDays = React.useMemo(() => getWorkWeekDays(activeDate), [activeDate])
const getAllDayEventsForDay = React.useCallback(
(dayDate: Date): CalendarEvent[] => {
const dayKey = formatDateKey(dayDate)
return events.filter((e) => {
if (!e.allDay) return false
const sKey =
typeof e.start === 'string' && /^\d{4}-\d{2}-\d{2}/.test(e.start)
? e.start.slice(0, 10)
: formatDateKey(parseDate(e.start))
return sKey === dayKey
})
},
[events],
)
const weekdayHeaderLabels = React.useMemo(() => {
if (weekStartsOn === 1) {
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
}
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
}, [weekStartsOn])
const getEventVariant = (event: CalendarEvent): any => {
if (
event.color === 'primary' ||
event.color === 'secondary' ||
event.color === 'success' ||
event.color === 'warning' ||
event.color === 'destructive' ||
event.color === 'info' ||
event.color === 'purple' ||
event.color === 'rose'
) {
return event.color
}
return 'default'
}
const handleEventCardClick = (event: CalendarEvent, e: React.MouseEvent) => {
e.stopPropagation()
onEventClick?.(event)
}
const handleTimeSlotClick = (dayDate: Date, hour: number, minute: number, category?: string) => {
const dateStr = formatDateKey(dayDate)
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
onTimeClick?.({
date: dateStr,
time: timeStr,
hour,
minute,
category,
})
}
return (
<div
data-slot="event-calendar"
className={cn(
'border-border bg-card text-foreground flex w-full flex-col overflow-hidden rounded-xl border shadow-xs',
className,
)}
>
{/* Built-in Header Toolbar */}
{showHeader &&
(renderHeader ? (
renderHeader({
currentDate: activeDate,
view: activeView,
title: formattedTitle,
prev: handlePrev,
next: handleNext,
today: handleToday,
setView,
})
) : (
<header className="border-border bg-card/60 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3 backdrop-blur-xs">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 px-2.5 text-xs font-medium" onClick={handleToday}>
Today
</Button>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label="Previous period"
onClick={handlePrev}
>
<ChevronLeft className="size-4" />
</Button>
<Button variant="ghost" size="icon" className="size-8" aria-label="Next period" onClick={handleNext}>
<ChevronRight className="size-4" />
</Button>
</div>
<h2 className="text-foreground ml-1 text-base font-semibold tracking-tight sm:text-lg">
{{ formattedTitle }.formattedTitle}
</h2>
</div>
<div className="flex items-center gap-1.5">
{headerActions}
<div className="border-border bg-muted/40 flex items-center rounded-lg border p-0.5">
<button
type="button"
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
activeView === 'month'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => setView('month')}
>
Month
</button>
<button
type="button"
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
activeView === 'week'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => setView('week')}
>
Week
</button>
<button
type="button"
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
activeView === 'work-week'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => setView('work-week')}
>
Work
</button>
<button
type="button"
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
activeView === 'day'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => setView('day')}
>
Day
</button>
{normalizedCategories.length > 0 && (
<button
type="button"
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
activeView === 'category'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => setView('category')}
>
Category
</button>
)}
</div>
</div>
</header>
))}
{/* VIEW 1: MONTH VIEW */}
{activeView === 'month' && (
<div className="flex flex-1 flex-col">
{/* Weekday headers */}
<div className="border-border bg-muted/20 text-muted-foreground grid grid-cols-7 border-b text-center text-xs font-medium">
{weekdayHeaderLabels.map((dayName, idx) => (
<div key={idx} className="border-border/40 border-r py-2 last:border-r-0">
{dayName}
</div>
))}
</div>
{/* 6-week month grid */}
<div className="divide-border/40 grid min-h-[580px] flex-1 grid-cols-7 grid-rows-6 divide-x divide-y">
{monthDays.map((cell) => {
const dayEvents = getEventsForDay(cell.dateKey)
return (
<div
key={cell.dateKey}
className={cn(
'group relative flex min-h-[96px] cursor-pointer flex-col p-1.5 transition-colors',
cell.inMonth
? 'bg-card hover:bg-muted/15'
: 'bg-muted/10 text-muted-foreground/50 hover:bg-muted/20',
)}
onClick={() => onDateClick?.(cell.dateKey)}
>
{/* Cell Header: Day Number */}
<div className="mb-1 flex items-center justify-between">
<span
className={cn(
'inline-flex min-w-[20px] items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium transition-colors',
cell.isToday
? 'bg-primary text-primary-foreground font-semibold shadow-xs'
: cell.inMonth
? 'text-foreground/90'
: 'text-muted-foreground/60',
)}
>
{cell.date.getDate()}
</span>
</div>
{/* Events in Day Cell */}
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
{dayEvents.map((evt, idx) => {
if (dayEvents.length <= maxEventsPerDay || idx < maxEventsPerDay - 1) {
return renderEvent ? (
<React.Fragment key={evt.id || idx}>
{renderEvent({ event: evt, view: 'month', isAllDay: Boolean(evt.allDay) })}
</React.Fragment>
) : (
<div
key={evt.id || idx}
data-slot="event-card"
className={cn(
calendarEventVariants({ variant: getEventVariant(evt), size: 'sm' }),
'w-full truncate',
)}
onClick={(e) => handleEventCardClick(evt, e)}
>
<div className="flex items-center gap-1 truncate font-medium">
{!evt.allDay && (
<span className="shrink-0 font-mono text-[10px] opacity-75">
{formatTime(getEventMinutes(evt.start, 540), timeFormat)}
</span>
)}
<span className="truncate">{evt.title}</span>
</div>
</div>
)
}
return null
})}
{/* +N more button with popover */}
{dayEvents.length > maxEventsPerDay && (
<div className="mt-auto pt-0.5">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="text-primary hover:text-primary/80 hover:bg-primary/10 flex items-center gap-0.5 rounded-sm px-1 py-0.5 text-[11px] font-semibold transition-colors hover:underline"
onClick={(e) => {
e.stopPropagation()
onMoreClick?.({ date: cell.dateKey, events: dayEvents })
}}
>
+{dayEvents.length - (maxEventsPerDay - 1)} more
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2 shadow-lg" align="start">
<div className="border-border mb-1.5 flex items-center justify-between border-b pb-1.5 text-xs font-semibold">
<span>
{cell.date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
weekday: 'short',
})}
</span>
<span className="text-muted-foreground text-[11px] font-normal">
{dayEvents.length} events
</span>
</div>
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
{dayEvents.map((evt, idx) => (
<div
key={evt.id || idx}
data-slot="event-card"
className={cn(
calendarEventVariants({ variant: getEventVariant(evt), size: 'sm' }),
'w-full',
)}
onClick={(e) => handleEventCardClick(evt, e)}
>
<div className="flex items-center gap-1 truncate font-medium">
{!evt.allDay && (
<span className="shrink-0 font-mono text-[10px] opacity-75">
{formatTime(getEventMinutes(evt.start, 540), timeFormat)}
</span>
)}
<span className="truncate">{evt.title}</span>
</div>
</div>
))}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)}
{/* VIEW 2 & 3 & 4: WEEK, WORK-WEEK, DAY VIEWS */}
{(activeView === 'week' || activeView === 'work-week' || activeView === 'day') && (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Top Columns Header (Sticky) */}
<div className="border-border bg-muted/20 flex border-b select-none">
<div className="border-border/50 text-muted-foreground w-16 shrink-0 border-r py-2.5 text-center text-xs font-medium">
<Clock className="mx-auto size-3.5 opacity-60" />
</div>
<div
className={cn(
'divide-border/50 grid flex-1 divide-x',
activeView === 'week' ? 'grid-cols-7' : activeView === 'work-week' ? 'grid-cols-5' : 'grid-cols-1',
)}
>
{(activeView === 'week' ? weekDays : activeView === 'work-week' ? workWeekDays : [activeDate]).map(
(d) => (
<div
key={formatDateKey(d)}
className={cn(
'hover:bg-muted/30 flex cursor-pointer flex-col items-center justify-center py-2 transition-colors',
isToday(d) && 'bg-primary/5',
)}
onClick={() => onDateClick?.(formatDateKey(d))}
>
{renderDayHeader ? (
renderDayHeader({
date: d,
dateKey: formatDateKey(d),
isToday: isToday(d),
view: activeView,
})
) : (
<>
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
{d.toLocaleDateString('en-US', { weekday: activeView === 'day' ? 'long' : 'short' })}
</span>
<span
className={cn(
'mt-0.5 inline-flex size-7 items-center justify-center rounded-full text-xs font-semibold transition-all',
isToday(d) ? 'bg-primary text-primary-foreground shadow-xs' : 'text-foreground',
)}
>
{d.getDate()}
</span>
</>
)}
</div>
),
)}
</div>
</div>
{/* Pinned All-Day Section */}
<div className="border-border bg-muted/10 flex border-b text-xs">
<div className="border-border/50 text-muted-foreground w-16 shrink-0 border-r p-2 text-right text-[10px] font-semibold tracking-wider uppercase">
All-day
</div>
<div
className={cn(
'divide-border/50 grid flex-1 divide-x',
activeView === 'week' ? 'grid-cols-7' : activeView === 'work-week' ? 'grid-cols-5' : 'grid-cols-1',
)}
>
{(activeView === 'week' ? weekDays : activeView === 'work-week' ? workWeekDays : [activeDate]).map(
(d) => {
const dayAllDay = getAllDayEventsForDay(d)
return (
<div key={formatDateKey(d)} className="flex min-h-[32px] flex-col gap-1 p-1">
{renderAllDay
? renderAllDay({ date: d, dateKey: formatDateKey(d), events: dayAllDay })
: dayAllDay.map((evt) => (
<div
key={evt.id || evt.title}
data-slot="event-card"
className={cn(
calendarEventVariants({ variant: getEventVariant(evt), size: 'sm' }),
'w-full truncate py-0.5',
)}
onClick={(e) => handleEventCardClick(evt, e)}
>
<span className="truncate font-medium">{evt.title}</span>
</div>
))}
</div>
)
},
)}
</div>
</div>
{/* Scrollable Time Intervals Grid */}
<div className="relative flex max-h-[640px] min-h-[480px] flex-1 overflow-y-auto">
{/* Time Gutter */}
<div className="border-border/50 bg-card w-16 shrink-0 border-r select-none">
{intervals.map((interval) => (
<div
key={interval.time}
style={{ height: `${intervalHeight}px` }}
className="border-border/30 text-muted-foreground relative border-b pr-2.5 text-right text-[11px] font-medium"
>
{renderInterval
? renderInterval({ hour: interval.hour, time: interval.time, label: interval.label })
: interval.label && <span className="relative -top-2 block">{interval.label}</span>}
</div>
))}
</div>
{/* Day Columns Grid */}
<div
className={cn(
'divide-border/50 relative grid flex-1 divide-x',
activeView === 'week' ? 'grid-cols-7' : activeView === 'work-week' ? 'grid-cols-5' : 'grid-cols-1',
)}
>
{(activeView === 'week' ? weekDays : activeView === 'work-week' ? workWeekDays : [activeDate]).map(
(d) => {
const positionedEvents = calculateTimedEventPositions(
events,
d,
firstInterval,
intervalCount,
intervalMinutes,
)
return (
<div key={formatDateKey(d)} className="relative flex flex-col">
{/* Interval Rows */}
{intervals.map((interval) => (
<div
key={interval.time}
style={{ height: `${intervalHeight}px` }}
className="border-border/30 hover:bg-muted/20 cursor-pointer border-b transition-colors"
onClick={() => handleTimeSlotClick(d, interval.hour, 0)}
/>
))}
{/* Timed Events Container */}
<div className="pointer-events-none absolute inset-0 p-0.5">
{positionedEvents.map((item) => (
<div
key={item.event.id || item.event.title}
style={{
top: `${item.top}%`,
height: `${item.height}%`,
left: `calc(${item.left}% + 2px)`,
width: `calc(${item.width}% - 4px)`,
}}
className="pointer-events-auto absolute z-10"
>
{renderEvent ? (
renderEvent({
event: item.event,
view: activeView,
isAllDay: false,
})
) : (
<div
data-slot="event-card"
className={cn(
calendarEventVariants({ variant: getEventVariant(item.event) }),
'flex h-full w-full flex-col justify-start overflow-hidden rounded-md p-1.5 leading-tight shadow-xs',
)}
onClick={(e) => handleEventCardClick(item.event, e)}
>
<span className="truncate text-xs font-semibold">{item.event.title}</span>
<span className="truncate font-mono text-[10px] opacity-80">
{formatTime(item.startMinutes, timeFormat)} –{' '}
{formatTime(item.endMinutes, timeFormat)}
</span>
{item.event.location && (
<span className="mt-auto truncate text-[10px] opacity-70">
📍 {item.event.location}
</span>
)}
</div>
)}
</div>
))}
{/* Live Current Time Indicator */}
{isToday(d) && nowPosition !== null && (
<div
style={{ top: `${nowPosition}%` }}
className="pointer-events-none absolute right-0 left-0 z-20"
>
<div className="relative w-full border-t-2 border-red-500 dark:border-red-400">
<div className="ring-background absolute -top-1 -left-1 size-2 rounded-full bg-red-500 ring-2 dark:bg-red-400" />
</div>
</div>
)}
</div>
</div>
)
},
)}
</div>
</div>
</div>
)}
{/* VIEW 5: CATEGORY / RESOURCE VIEW */}
{activeView === 'category' && (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Category Columns Header */}
<div className="border-border bg-muted/20 flex border-b select-none">
<div className="border-border/50 text-muted-foreground w-16 shrink-0 border-r py-2.5 text-center text-xs font-medium">
<Clock className="mx-auto size-3.5 opacity-60" />
</div>
<div
className="divide-border/50 grid flex-1 divide-x"
style={{
gridTemplateColumns: `repeat(${Math.max(1, normalizedCategories.length)}, minmax(0, 1fr))`,
}}
>
{normalizedCategories.map((cat) => (
<div
key={cat.id}
className="text-foreground flex items-center justify-center gap-1.5 px-2 py-2.5 text-center text-xs font-semibold"
>
{cat.color && (
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: cat.color }} />
)}
<span className="truncate">{cat.name}</span>
</div>
))}
</div>
</div>
{/* Category Intervals Grid */}
<div className="relative flex max-h-[640px] min-h-[480px] flex-1 overflow-y-auto">
{/* Time Gutter */}
<div className="border-border/50 bg-card w-16 shrink-0 border-r select-none">
{intervals.map((interval) => (
<div
key={interval.time}
style={{ height: `${intervalHeight}px` }}
className="border-border/30 text-muted-foreground relative border-b pr-2.5 text-right text-[11px] font-medium"
>
{interval.label && <span className="relative -top-2 block">{interval.label}</span>}
</div>
))}
</div>
{/* Category Columns */}
<div
className="divide-border/50 relative grid flex-1 divide-x"
style={{
gridTemplateColumns: `repeat(${Math.max(1, normalizedCategories.length)}, minmax(0, 1fr))`,
}}
>
{normalizedCategories.map((cat) => {
const catEvents = events.filter((e) => e.category === cat.id || e.category === cat.name)
const positioned = calculateTimedEventPositions(
catEvents,
activeDate,
firstInterval,
intervalCount,
intervalMinutes,
)
return (
<div key={cat.id} className="relative flex flex-col">
{/* Interval Rows */}
{intervals.map((interval) => (
<div
key={interval.time}
style={{ height: `${intervalHeight}px` }}
className="border-border/30 hover:bg-muted/20 cursor-pointer border-b transition-colors"
onClick={() => handleTimeSlotClick(activeDate, interval.hour, 0, cat.id)}
/>
))}
{/* Category Timed Events Container */}
<div className="pointer-events-none absolute inset-0 p-0.5">
{positioned.map((item) => (
<div
key={item.event.id || item.event.title}
style={{
top: `${item.top}%`,
height: `${item.height}%`,
left: `calc(${item.left}% + 2px)`,
width: `calc(${item.width}% - 4px)`,
}}
className="pointer-events-auto absolute z-10"
>
{renderEvent ? (
renderEvent({ event: item.event, view: 'category', isAllDay: false })
) : (
<div
data-slot="event-card"
className={cn(
calendarEventVariants({ variant: getEventVariant(item.event) }),
'flex h-full w-full flex-col justify-start overflow-hidden rounded-md p-1.5 leading-tight shadow-xs',
)}
onClick={(e) => handleEventCardClick(item.event, e)}
>
<span className="truncate text-xs font-semibold">{item.event.title}</span>
<span className="truncate font-mono text-[10px] opacity-80">
{formatTime(item.startMinutes, timeFormat)} – {formatTime(item.endMinutes, timeFormat)}
</span>
</div>
)}
</div>
))}
{/* Live Current Time Indicator */}
{isToday(activeDate) && nowPosition !== null && (
<div
style={{ top: `${nowPosition}%` }}
className="pointer-events-none absolute right-0 left-0 z-20"
>
<div className="relative w-full border-t-2 border-red-500 dark:border-red-400">
<div className="ring-background absolute -top-1 -left-1 size-2 rounded-full bg-red-500 ring-2 dark:bg-red-400" />
</div>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
)}
</div>
)
}
import { cva, type VariantProps } from 'class-variance-authority'
export const calendarEventVariants = cva(
'group relative flex cursor-pointer flex-col overflow-hidden text-left transition-colors select-none border font-normal focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring',
{
variants: {
variant: {
default: 'bg-secondary/80 text-secondary-foreground border-border/50 hover:bg-secondary',
primary: 'bg-primary/10 text-primary border-primary/25 hover:bg-primary/20',
secondary: 'bg-muted/90 text-foreground border-border/60 hover:bg-muted',
success:
'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/20',
warning: 'bg-amber-500/10 text-amber-800 dark:text-amber-400 border-amber-500/30 hover:bg-amber-500/20',
destructive: 'bg-destructive/10 text-destructive border-destructive/30 hover:bg-destructive/20',
info: 'bg-sky-500/10 text-sky-700 dark:text-sky-400 border-sky-500/30 hover:bg-sky-500/20',
purple: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/30 hover:bg-purple-500/20',
rose: 'bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/30 hover:bg-rose-500/20',
},
size: {
sm: 'rounded-sm px-1.5 py-0.5 text-[11px] leading-tight',
default: 'rounded-md px-2 py-1 text-xs leading-normal',
lg: 'rounded-md px-2.5 py-1.5 text-sm leading-snug',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type CalendarEventVariants = VariantProps<typeof calendarEventVariants>
import type { CalendarEvent, PositionedEvent } from './types'
export function parseDate(d: string | Date | undefined | null): Date {
if (!d) return new Date()
if (d instanceof Date) return new Date(d.getTime())
// Format: YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(d)) {
const [y, m, day] = d.split('-').map(Number)
return new Date(y, m - 1, day, 0, 0, 0)
}
// Format: YYYY-MM-DD HH:mm or YYYY-MM-DDTHH:mm
const matchTime = d.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/)
if (matchTime) {
const [, y, m, day, h, min] = matchTime.map(Number)
return new Date(y, m - 1, day, h, min, 0)
}
const parsed = new Date(d)
return isNaN(parsed.getTime()) ? new Date() : parsed
}
export function formatDateKey(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function isSameDay(d1: Date, d2: Date): boolean {
return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth() && d1.getDate() === d2.getDate()
}
export function isToday(d: Date): boolean {
return isSameDay(d, new Date())
}
export function formatTime(minutes: number, format: '12h' | '24h' = '12h'): string {
const totalMin = Math.max(0, Math.min(1439, Math.round(minutes)))
const hour = Math.floor(totalMin / 60)
const min = totalMin % 60
if (format === '24h') {
return `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}`
}
const ampm = hour >= 12 ? 'PM' : 'AM'
const h12 = hour % 12 || 12
return `${h12}:${String(min).padStart(2, '0')} ${ampm}`
}
export function formatHourLabel(hour: number, format: '12h' | '24h' = '12h'): string {
const normalizedHour = ((hour % 24) + 24) % 24
if (format === '24h') {
return `${String(normalizedHour).padStart(2, '0')}:00`
}
if (normalizedHour === 0) return '12 AM'
if (normalizedHour === 12) return '12 PM'
return normalizedHour > 12 ? `${normalizedHour - 12} PM` : `${normalizedHour} AM`
}
export function getEventMinutes(dateInput: string | Date | undefined, defaultMinutes: number = 0): number {
if (!dateInput) return defaultMinutes
if (dateInput instanceof Date) {
return dateInput.getHours() * 60 + dateInput.getMinutes()
}
if (typeof dateInput === 'string') {
// HH:mm
if (/^\d{1,2}:\d{2}$/.test(dateInput)) {
const [h, m] = dateInput.split(':').map(Number)
return h * 60 + m
}
// Check if contains time
const parsed = parseDate(dateInput)
// If input had no time specified (just YYYY-MM-DD), default to defaultMinutes
if (/^\d{4}-\d{2}-\d{2}$/.test(dateInput)) {
return defaultMinutes
}
return parsed.getHours() * 60 + parsed.getMinutes()
}
return defaultMinutes
}
export function getWeekDays(baseDate: Date, weekStartsOn: 0 | 1 = 0): Date[] {
const date = parseDate(baseDate)
const day = date.getDay()
const diff = (day - weekStartsOn + 7) % 7
const startOfWeek = new Date(date.getFullYear(), date.getMonth(), date.getDate() - diff)
const days: Date[] = []
for (let i = 0; i < 7; i++) {
const d = new Date(startOfWeek)
d.setDate(startOfWeek.getDate() + i)
days.push(d)
}
return days
}
export function getWorkWeekDays(baseDate: Date): Date[] {
// Always Monday through Friday
const date = parseDate(baseDate)
const day = date.getDay()
const diff = (day - 1 + 7) % 7 // Monday offset
const monday = new Date(date.getFullYear(), date.getMonth(), date.getDate() - diff)
const days: Date[] = []
for (let i = 0; i < 5; i++) {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
days.push(d)
}
return days
}
export function getMonthDays(
cursor: Date,
weekStartsOn: 0 | 1 = 0,
): { date: Date; dateKey: string; inMonth: boolean }[] {
const y = cursor.getFullYear()
const m = cursor.getMonth()
const firstDay = new Date(y, m, 1)
const offset = (firstDay.getDay() - weekStartsOn + 7) % 7
const startDate = new Date(y, m, 1 - offset)
const days: { date: Date; dateKey: string; inMonth: boolean }[] = []
for (let i = 0; i < 42; i++) {
const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + i)
days.push({
date: d,
dateKey: formatDateKey(d),
inMonth: d.getMonth() === m,
})
}
return days
}
export function calculateTimedEventPositions(
events: CalendarEvent[],
dayDate: Date,
firstInterval: number = 0,
intervalCount: number = 24,
intervalMinutes: number = 60,
): PositionedEvent[] {
const dayKey = formatDateKey(dayDate)
const startDayMinutes = firstInterval * 60
const totalMinutes = intervalCount * intervalMinutes
// Filter events belonging to this day that are timed
const timed = events
.filter((e) => {
if (e.allDay) return false
const sKey =
typeof e.start === 'string' && /^\d{4}-\d{2}-\d{2}/.test(e.start)
? e.start.slice(0, 10)
: formatDateKey(parseDate(e.start))
return sKey === dayKey
})
.map((event) => {
const sMin = getEventMinutes(event.start, 9 * 60)
let eMin = getEventMinutes(event.end, sMin + 60)
if (eMin <= sMin) eMin = sMin + 30 // minimum 30 min duration
return {
event,
startMinutes: sMin,
endMinutes: eMin,
}
})
.sort((a, b) => a.startMinutes - b.startMinutes || b.endMinutes - b.startMinutes - (a.endMinutes - a.startMinutes))
if (timed.length === 0) return []
// Assign columns for overlapping groups
const positioned: PositionedEvent[] = []
// Cluster overlapping events
type ClusterItem = { event: CalendarEvent; startMinutes: number; endMinutes: number; col: number }
const clusters: ClusterItem[][] = []
let currentCluster: ClusterItem[] = []
let clusterEnd = -1
for (const item of timed) {
if (currentCluster.length === 0 || item.startMinutes < clusterEnd) {
currentCluster.push({ ...item, col: 0 })
clusterEnd = Math.max(clusterEnd, item.endMinutes)
} else {
clusters.push(currentCluster)
currentCluster = [{ ...item, col: 0 }]
clusterEnd = item.endMinutes
}
}
if (currentCluster.length > 0) {
clusters.push(currentCluster)
}
// For each cluster, assign columns
for (const cluster of clusters) {
const cols: number[] = [] // end time of each column
for (const item of cluster) {
let placedCol = -1
for (let c = 0; c < cols.length; c++) {
if (item.startMinutes >= cols[c]) {
placedCol = c
cols[c] = item.endMinutes
break
}
}
if (placedCol === -1) {
placedCol = cols.length
cols.push(item.endMinutes)
}
item.col = placedCol
}
const totalCols = Math.max(1, cols.length)
for (const item of cluster) {
const topPct = Math.max(0, Math.min(100, ((item.startMinutes - startDayMinutes) / totalMinutes) * 100))
const bottomPct = Math.max(0, Math.min(100, ((item.endMinutes - startDayMinutes) / totalMinutes) * 100))
const heightPct = Math.max(1.5, bottomPct - topPct)
const widthPct = 100 / totalCols
const leftPct = item.col * widthPct
positioned.push({
event: item.event,
top: topPct,
height: heightPct,
left: leftPct,
width: widthPct,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes,
})
}
}
return positioned
}
export function getCurrentTimePosition(
firstInterval: number = 0,
intervalCount: number = 24,
intervalMinutes: number = 60,
): number | null {
const now = new Date()
const curMinutes = now.getHours() * 60 + now.getMinutes()
const startMinutes = firstInterval * 60
const totalMinutes = intervalCount * intervalMinutes
if (curMinutes < startMinutes || curMinutes > startMinutes + totalMinutes) {
return null
}
return ((curMinutes - startMinutes) / totalMinutes) * 100
}
import type { ReactNode } from 'react'
export type CalendarView = 'month' | 'week' | 'day' | 'work-week' | 'category'
export interface CalendarCategory {
id: string
name: string
color?: string
}
export interface CalendarEvent {
id?: string
title: string
/** YYYY-MM-DD or YYYY-MM-DD HH:mm or ISO string or Date */
start: string | Date
/** YYYY-MM-DD or YYYY-MM-DD HH:mm or ISO string or Date */
end?: string | Date
allDay?: boolean
color?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'destructive'
| 'info'
| 'purple'
| 'rose'
| string
category?: string
description?: string
location?: string
[key: string]: any
}
export interface TimeClickPayload {
date: string
time: string
hour: number
minute: number
category?: string
}
export interface PositionedEvent {
event: CalendarEvent
top: number
height: number
left: number
width: number
startMinutes: number
endMinutes: number
}
export interface CalendarDayCell {
date: Date
dateKey: string
dayNumber: number
inMonth: boolean
isToday: boolean
events: CalendarEvent[]
allDayEvents: CalendarEvent[]
timedEvents: CalendarEvent[]
}
export interface EventCalendarProps {
value?: string | Date
defaultValue?: string | Date
view?: CalendarView
defaultView?: CalendarView
events?: CalendarEvent[]
categories?: (string | CalendarCategory)[]
weekStartsOn?: 0 | 1
firstInterval?: number
intervalCount?: number
intervalMinutes?: number
intervalHeight?: number
timeFormat?: '12h' | '24h'
maxEventsPerDay?: number
showNowIndicator?: boolean
showHeader?: boolean
className?: string
onDateChange?: (date: Date) => void
onViewChange?: (view: CalendarView) => void
onEventClick?: (event: CalendarEvent) => void
onDateClick?: (date: string) => void
onTimeClick?: (payload: TimeClickPayload) => void
onMoreClick?: (payload: { date: string; events: CalendarEvent[] }) => void
renderHeader?: (params: {
currentDate: Date
view: CalendarView
title: string
prev: () => void
next: () => void
today: () => void
setView: (view: CalendarView) => void
}) => ReactNode
renderEvent?: (params: { event: CalendarEvent; view: CalendarView; isAllDay: boolean }) => ReactNode
renderDayHeader?: (params: { date: Date; dateKey: string; isToday: boolean; view: CalendarView }) => ReactNode
renderAllDay?: (params: { date: Date; dateKey: string; events: CalendarEvent[] }) => ReactNode
renderInterval?: (params: { hour: number; time: string; label: string }) => ReactNode
headerActions?: ReactNode
}
export { EventCalendar } from './event-calendar'
export * from './types'
export * from './event-calendar.variants'
export * from './date-utils'
Raw manifest:https://uipkge.dev/r/react/event-calendar.json