Calendar

A generic, responsive calendar with month, week, day, and agenda views, driven by your typed event model.

npx shadcn@latest add https://shuip.plvo.dev/r/calendar.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/calendar.json
bun x shadcn@latest add https://shuip.plvo.dev/r/calendar.json
'use client';
import {
DndContext,
type DragEndEvent,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import type { Locale } from 'date-fns';
import {
addDays,
addMinutes,
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameDay,
isSameMonth,
setHours,
setMinutes,
startOfDay,
startOfMonth,
startOfWeek,
} from 'date-fns';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
export type CalendarEventColor = 'primary' | 'secondary' | 'accent' | 'destructive' | 'muted';
export type CalendarSlot = { start: Date; end: Date; allDay: boolean };
export type CalendarMessages = {
today: string;
previous: string;
next: string;
allDay: string;
noEvents: string;
more: (count: number) => string;
views: Record<CalendarView, string>;
};
export type CalendarMessagesInput = Partial<Omit<CalendarMessages, 'views'>> & {
views?: Partial<Record<CalendarView, string>>;
};
const defaultMessages: CalendarMessages = {
today: 'Today',
previous: 'Previous',
next: 'Next',
allDay: 'all-day',
noEvents: 'No upcoming events',
more: (count) => `+${count} more`,
views: { month: 'Month', week: 'Week', day: 'Day', agenda: 'Agenda' },
};
type CalendarEventLike = Record<string, unknown>;
export type CalendarRootProps<T extends CalendarEventLike> = {
children: React.ReactNode;
events?: T[];
defaultEvents?: T[];
onEventsChange?: (next: T[]) => void;
idField?: keyof T;
titleField: keyof T;
startField: keyof T;
endField: keyof T;
allDayField?: keyof T;
color?: (item: T) => CalendarEventColor | undefined;
renderEvent?: (item: T) => React.ReactNode;
view?: CalendarView;
defaultView?: CalendarView;
onViewChange?: (v: CalendarView) => void;
date?: Date;
defaultDate?: Date;
onDateChange?: (d: Date) => void;
views?: CalendarView[];
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
editable?: boolean;
locale?: Locale;
messages?: CalendarMessagesInput;
onEventClick?: (item: T) => void;
onSlotSelect?: (range: CalendarSlot) => void;
};
type CalendarContextValue<T extends CalendarEventLike = CalendarEventLike> = {
view: CalendarView;
setView: (v: CalendarView) => void;
views: CalendarView[];
date: Date;
setDate: (d: Date) => void;
effectiveView: CalendarView;
isMobile: boolean;
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6;
editable: boolean;
locale?: Locale;
messages: CalendarMessages;
periodLabel: string;
goToToday: () => void;
goToPrevious: () => void;
goToNext: () => void;
events: T[];
getId: (item: T) => string;
getStart: (item: T) => Date;
getEnd: (item: T) => Date;
getTitle: (item: T) => string;
isAllDay: (item: T) => boolean;
getColor: (item: T) => CalendarEventColor | undefined;
renderEvent?: (item: T) => React.ReactNode;
onEventClick?: (item: T) => void;
onSlotSelect?: (range: CalendarSlot) => void;
resizeEvent: (id: string, end: Date) => void;
draggedRef: React.MutableRefObject<boolean>;
};
const CalendarContext = React.createContext<CalendarContextValue | null>(null);
export function useCalendar<T extends CalendarEventLike = CalendarEventLike>(): CalendarContextValue<T> {
const ctx = React.use(CalendarContext);
if (!ctx) throw new Error('useCalendar must be used within <Calendar.Root>');
return ctx as CalendarContextValue<T>;
}
function useControllableState<V>(
controlled: V | undefined,
defaultValue: V,
onChange?: (v: V) => void,
): [V, (v: V) => void] {
const [uncontrolled, setUncontrolled] = React.useState<V>(defaultValue);
const isControlled = controlled !== undefined;
const value = isControlled ? controlled : uncontrolled;
const setValue = React.useCallback(
(next: V) => {
if (!isControlled) setUncontrolled(next);
onChange?.(next);
},
[isControlled, onChange],
);
return [value, setValue];
}
function useIsMobile(breakpoint = 768): boolean {
const subscribe = React.useCallback(
(cb: () => void) => {
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
mql.addEventListener('change', cb);
return () => mql.removeEventListener('change', cb);
},
[breakpoint],
);
const getSnapshot = React.useCallback(
() => window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches,
[breakpoint],
);
return React.useSyncExternalStore(subscribe, getSnapshot, () => false);
}
function CalendarRoot<T extends CalendarEventLike>({
children,
events,
defaultEvents = [] as T[],
onEventsChange,
idField = 'id' as keyof T,
titleField,
startField,
endField,
allDayField,
color,
renderEvent,
view: viewProp,
defaultView = 'month',
onViewChange,
date: dateProp,
defaultDate,
onDateChange,
views = ['month', 'week', 'day', 'agenda'],
weekStartsOn = 1,
editable = false,
locale,
messages: messagesProp,
onEventClick,
onSlotSelect,
}: CalendarRootProps<T>) {
const messages = React.useMemo<CalendarMessages>(
() => ({
...defaultMessages,
...messagesProp,
views: { ...defaultMessages.views, ...messagesProp?.views },
}),
[messagesProp],
);
const [view, setView] = useControllableState<CalendarView>(viewProp, defaultView, onViewChange);
const [date, setDate] = useControllableState<Date>(dateProp, defaultDate ?? startOfDay(new Date()), onDateChange);
const [items, setItems] = useControllableState<T[]>(events, defaultEvents, onEventsChange);
const isMobile = useIsMobile();
const effectiveView = isMobile && view === 'week' ? 'day' : view;
const draggedRef = React.useRef(false);
const getStart = React.useCallback((it: T) => it[startField] as unknown as Date, [startField]);
const getEnd = React.useCallback((it: T) => it[endField] as unknown as Date, [endField]);
const isAllDay = React.useCallback((it: T) => (allDayField ? Boolean(it[allDayField]) : false), [allDayField]);
const getId = React.useCallback((it: T) => String(it[idField]), [idField]);
const getTitle = React.useCallback((it: T) => String(it[titleField] ?? ''), [titleField]);
const getColor = React.useCallback((it: T) => color?.(it), [color]);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
const handleDragEnd = React.useCallback(
(e: DragEndEvent) => {
const { active, over, delta } = e;
draggedRef.current = Math.abs(delta.x) > 4 || Math.abs(delta.y) > 4;
const id = String(active.id);
const item = items.find((it) => getId(it) === id);
if (!item) return;
const oldStart = getStart(item);
const oldEnd = getEnd(item);
const duration = oldEnd.getTime() - oldStart.getTime();
const overDay = (over?.data.current?.day as Date | undefined) ?? oldStart;
let newStart: Date;
if (effectiveView === 'month') {
newStart = setMinutes(setHours(startOfDay(overDay), oldStart.getHours()), oldStart.getMinutes());
} else {
const movedMinutes = minutesSinceDayStart(oldStart) + (delta.y / HOUR_HEIGHT) * 60;
newStart = snapToMinutes(addMinutes(startOfDay(overDay), movedMinutes));
}
if (newStart.getTime() === oldStart.getTime()) return;
const next = items.map((it) =>
getId(it) === id
? ({ ...it, [startField]: newStart, [endField]: new Date(newStart.getTime() + duration) } as T)
: it,
);
setItems(next);
},
[items, getId, getStart, getEnd, effectiveView, startField, endField, setItems],
);
const resizeEvent = React.useCallback(
(id: string, newEnd: Date) => {
setItems(items.map((it) => (getId(it) === id ? ({ ...it, [endField]: newEnd } as T) : it)));
},
[items, getId, endField, setItems],
);
const periodLabel = React.useMemo(() => {
switch (effectiveView) {
case 'month':
return format(date, 'MMMM yyyy', { locale });
case 'week': {
const weekStart = startOfWeek(date, { weekStartsOn });
const weekEnd = endOfWeek(date, { weekStartsOn });
return `${format(weekStart, 'MMM d', { locale })}${format(weekEnd, 'MMM d, yyyy', { locale })}`;
}
case 'day':
return format(date, 'PPP', { locale });
case 'agenda':
return format(date, 'MMMM yyyy', { locale });
}
}, [effectiveView, date, weekStartsOn, locale]);
const shift = React.useCallback(
(dir: 1 | -1) => {
switch (effectiveView) {
case 'month':
setDate(addMonths(date, dir));
break;
case 'week':
setDate(addDays(date, 7 * dir));
break;
default:
setDate(addDays(date, dir));
}
},
[effectiveView, date, setDate],
);
const goToToday = React.useCallback(() => setDate(startOfDay(new Date())), [setDate]);
const goToPrevious = React.useCallback(() => shift(-1), [shift]);
const goToNext = React.useCallback(() => shift(1), [shift]);
const value = React.useMemo<CalendarContextValue<T>>(
() => ({
view,
setView,
views,
date,
setDate,
effectiveView,
isMobile,
weekStartsOn,
editable,
locale,
messages,
periodLabel,
goToToday,
goToPrevious,
goToNext,
events: items,
getId,
getStart,
getEnd,
getTitle,
isAllDay,
getColor,
renderEvent,
onEventClick,
onSlotSelect,
resizeEvent,
draggedRef,
}),
[
view,
setView,
views,
date,
setDate,
effectiveView,
isMobile,
weekStartsOn,
editable,
locale,
messages,
periodLabel,
goToToday,
goToPrevious,
goToNext,
items,
getId,
getStart,
getEnd,
getTitle,
isAllDay,
getColor,
renderEvent,
onEventClick,
onSlotSelect,
resizeEvent,
],
);
return (
<CalendarContext.Provider value={value as unknown as CalendarContextValue}>
{editable ? (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
{children}
</DndContext>
) : (
children
)}
</CalendarContext.Provider>
);
}
function CalendarNav({ className }: { className?: string }) {
const { periodLabel, views, view, setView, goToToday, goToPrevious, goToNext, messages } = useCalendar();
return (
<div className={cn('flex w-full flex-wrap items-center justify-between gap-2', className)}>
<div className='flex items-center gap-1'>
<Button variant='outline' size='sm' onClick={goToToday}>
{messages.today}
</Button>
<Button variant='outline' size='icon' className='size-8' onClick={goToPrevious} aria-label={messages.previous}>
<ChevronLeft className='size-4' />
</Button>
<Button variant='outline' size='icon' className='size-8' onClick={goToNext} aria-label={messages.next}>
<ChevronRight className='size-4' />
</Button>
<span className='ml-2 text-sm font-medium'>{periodLabel}</span>
</div>
{views.length > 1 ? (
<Select value={view} onValueChange={(v) => setView(v as CalendarView)}>
<SelectTrigger className='w-32' size='sm'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{views.map((v) => (
<SelectItem key={v} value={v}>
{messages.views[v]}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
);
}
function CalendarViewComponent({ className }: { className?: string }) {
const {
effectiveView,
date,
weekStartsOn,
events: items,
getStart,
getEnd,
getId,
getTitle,
isAllDay,
getColor,
renderEvent,
editable,
draggedRef,
resizeEvent,
onEventClick,
onSlotSelect,
locale,
messages,
} = useCalendar();
const weekDays = React.useMemo(
() => eachDayOfInterval({ start: startOfWeek(date, { weekStartsOn }), end: endOfWeek(date, { weekStartsOn }) }),
[date, weekStartsOn],
);
const agendaDays = React.useMemo(
() => eachDayOfInterval({ start: startOfDay(date), end: addDays(startOfDay(date), 29) }),
[date],
);
const body = (() => {
switch (effectiveView) {
case 'month':
return (
<MonthView
date={date}
weekStartsOn={weekStartsOn}
items={items}
getStart={getStart}
getId={getId}
getTitle={getTitle}
color={getColor}
renderEvent={renderEvent}
editable={editable}
draggedRef={draggedRef}
onEventClick={onEventClick}
onSlotSelect={onSlotSelect}
locale={locale}
more={messages.more}
/>
);
case 'week':
case 'day':
return (
<TimeGridView
days={effectiveView === 'week' ? weekDays : [date]}
items={items}
getStart={getStart}
getEnd={getEnd}
getId={getId}
getTitle={getTitle}
isAllDay={isAllDay}
color={getColor}
renderEvent={renderEvent}
editable={editable}
draggedRef={draggedRef}
onResize={editable ? resizeEvent : undefined}
onEventClick={onEventClick}
onSlotSelect={onSlotSelect}
locale={locale}
allDayLabel={messages.allDay}
/>
);
case 'agenda':
return (
<AgendaView
days={agendaDays}
items={items}
getStart={getStart}
getEnd={getEnd}
getId={getId}
getTitle={getTitle}
color={getColor}
renderEvent={renderEvent}
onEventClick={onEventClick}
locale={locale}
noEventsLabel={messages.noEvents}
/>
);
}
})();
return <div className={cn('w-full', className)}>{body}</div>;
}
export const Calendar = {
Root: CalendarRoot,
Nav: CalendarNav,
View: CalendarViewComponent,
};
function monthGrid(date: Date, weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6): Date[] {
const start = startOfWeek(startOfMonth(date), { weekStartsOn });
const end = endOfWeek(endOfMonth(date), { weekStartsOn });
return eachDayOfInterval({ start, end });
}
const colorClassMap: Record<CalendarEventColor, string> = {
primary: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
accent: 'bg-accent text-accent-foreground',
destructive: 'bg-destructive text-destructive-foreground',
muted: 'bg-muted text-muted-foreground',
};
function colorClasses(c?: CalendarEventColor): string {
return c ? colorClassMap[c] : colorClassMap.primary;
}
const HOUR_HEIGHT = 48;
const SNAP_MINUTES = 15;
function snapToMinutes(date: Date, minutes = SNAP_MINUTES): Date {
const ms = minutes * 60_000;
return new Date(Math.round(date.getTime() / ms) * ms);
}
function minutesSinceDayStart(date: Date): number {
return date.getHours() * 60 + date.getMinutes();
}
function eventTop(start: Date): number {
return (minutesSinceDayStart(start) / 60) * HOUR_HEIGHT;
}
function eventHeight(start: Date, end: Date): number {
const mins = Math.max(15, (end.getTime() - start.getTime()) / 60_000);
return (mins / 60) * HOUR_HEIGHT;
}
function offsetToMinutes(clientY: number, columnEl: HTMLElement): number {
const rect = columnEl.getBoundingClientRect();
return ((clientY - rect.top) / HOUR_HEIGHT) * 60;
}
type Creating = { dayIndex: number; startMin: number; endMin: number };
type LaidOut<T> = { item: T; col: number; cols: number };
function layoutDay<T>(dayEvents: T[], getStart: (t: T) => Date, getEnd: (t: T) => Date): LaidOut<T>[] {
const sorted = [...dayEvents].sort((a, b) => getStart(a).getTime() - getStart(b).getTime());
const colEnds: number[] = [];
const placed = sorted.map((item) => {
const s = getStart(item).getTime();
let col = colEnds.findIndex((end) => end <= s);
if (col === -1) {
col = colEnds.length;
colEnds.push(0);
}
colEnds[col] = getEnd(item).getTime();
return { item, col };
});
const cols = Math.max(1, colEnds.length);
return placed.map((p) => ({ ...p, cols }));
}
function eventBlockStyle(start: Date, end: Date, laidOut: { col: number; cols: number }): React.CSSProperties {
const widthPct = 100 / laidOut.cols;
return {
top: eventTop(start),
height: eventHeight(start, end),
left: `${laidOut.col * widthPct}%`,
width: `calc(${widthPct}% - 2px)`,
};
}
function EventBlockContent({
title,
start,
end,
label,
}: {
title: string;
start: Date;
end: Date;
label?: React.ReactNode;
}) {
if (label !== undefined) return <>{label}</>;
return (
<>
<span className='block truncate font-medium'>{title}</span>
<span className='block truncate opacity-80'>
{format(start, 'HH:mm')}{format(end, 'HH:mm')}
</span>
</>
);
}
const eventBlockClass = 'absolute z-10 overflow-hidden rounded px-1 py-0.5 text-left text-xs';
function EventBlock({
title,
start,
end,
label,
laidOut,
color,
onClick,
}: {
title: string;
start: Date;
end: Date;
label?: React.ReactNode;
laidOut: { col: number; cols: number };
color?: CalendarEventColor;
onClick?: () => void;
}) {
return (
<button
type='button'
data-event=''
onClick={onClick}
style={eventBlockStyle(start, end, laidOut)}
className={cn(eventBlockClass, colorClasses(color))}
>
<EventBlockContent title={title} start={start} end={end} label={label} />
</button>
);
}
function DraggableEventBlock({
id,
title,
start,
end,
realEnd,
label,
laidOut,
color,
draggedRef,
onClick,
onResize,
onPreview,
}: {
id: string;
title: string;
start: Date;
end: Date;
realEnd: Date;
label?: React.ReactNode;
laidOut: { col: number; cols: number };
color?: CalendarEventColor;
draggedRef: React.MutableRefObject<boolean>;
onClick?: () => void;
onResize?: (id: string, end: Date) => void;
onPreview: (end: Date | null) => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id });
return (
<button
type='button'
data-event=''
ref={setNodeRef}
{...attributes}
{...listeners}
onClick={() => {
if (draggedRef.current) {
draggedRef.current = false;
return;
}
onClick?.();
}}
style={{
...eventBlockStyle(start, end, laidOut),
transform: CSS.Translate.toString(transform),
zIndex: isDragging ? 40 : undefined,
touchAction: 'none',
}}
className={cn(eventBlockClass, colorClasses(color))}
>
<EventBlockContent title={title} start={start} end={end} label={label} />
<ResizeHandle id={id} start={start} end={realEnd} onResize={onResize} onPreview={onPreview} />
</button>
);
}
function ResizeHandle({
id,
start,
end,
onResize,
onPreview,
}: {
id: string;
start: Date;
end: Date;
onResize?: (id: string, end: Date) => void;
onPreview: (end: Date | null) => void;
}) {
const handlePointerDown = (e: React.PointerEvent<HTMLSpanElement>) => {
e.preventDefault();
e.stopPropagation();
const target = e.target as HTMLElement;
target.setPointerCapture(e.pointerId);
const startY = e.clientY;
const originalEnd = end;
const minEnd = new Date(start.getTime() + SNAP_MINUTES * 60_000);
let finalEnd = originalEnd;
const compute = (clientY: number) => {
const deltaMin = ((clientY - startY) / HOUR_HEIGHT) * 60;
let proposed = snapToMinutes(new Date(originalEnd.getTime() + deltaMin * 60_000));
if (proposed.getTime() - start.getTime() < SNAP_MINUTES * 60_000) proposed = minEnd;
return proposed;
};
const onMove = (moveE: PointerEvent) => {
finalEnd = compute(moveE.clientY);
onPreview(finalEnd);
};
const onUp = (upE: PointerEvent) => {
finalEnd = compute(upE.clientY);
target.releasePointerCapture(e.pointerId);
target.removeEventListener('pointermove', onMove);
target.removeEventListener('pointerup', onUp);
onResize?.(id, finalEnd);
onPreview(null);
};
target.addEventListener('pointermove', onMove);
target.addEventListener('pointerup', onUp);
};
return (
<span
data-event=''
className='absolute inset-x-0 bottom-0 z-20 h-1.5 cursor-ns-resize'
onPointerDown={handlePointerDown}
onClick={(e) => e.stopPropagation()}
/>
);
}
const HOURS = Array.from({ length: 24 }, (_, h) => h);
const DAY_MS = 24 * 60 * 60 * 1000;
function CreatingOverlay({ startMin, endMin }: { startMin: number; endMin: number }) {
const lo = Math.min(startMin, endMin);
const span = Math.max(15, Math.abs(endMin - startMin));
return (
<div
className='pointer-events-none absolute inset-x-0 z-30 rounded border border-primary bg-primary/30'
style={{ top: (lo / 60) * HOUR_HEIGHT, height: (span / 60) * HOUR_HEIGHT }}
/>
);
}
function DayColumnContent<T extends Record<string, unknown>>({
laidOut,
getStart,
getEnd,
getId,
getTitle,
color,
renderEvent,
editable,
draggedRef,
resizing,
creatingRange,
onResize,
onPreview,
onEventClick,
}: {
laidOut: LaidOut<T>[];
getStart: (item: T) => Date;
getEnd: (item: T) => Date;
getId: (item: T) => string;
getTitle: (item: T) => string;
color?: (item: T) => CalendarEventColor | undefined;
renderEvent?: (item: T) => React.ReactNode;
editable: boolean;
draggedRef: React.MutableRefObject<boolean>;
resizing: { id: string; end: Date } | null;
creatingRange?: { startMin: number; endMin: number } | null;
onResize?: (id: string, end: Date) => void;
onPreview: (preview: { id: string; end: Date } | null) => void;
onEventClick?: (item: T) => void;
}) {
return (
<>
{HOURS.map((h) => (
<div key={h} className='h-12 border-t first:border-t-0' />
))}
{creatingRange ? <CreatingOverlay startMin={creatingRange.startMin} endMin={creatingRange.endMin} /> : null}
{laidOut.map((entry) => {
const id = getId(entry.item);
const start = getStart(entry.item);
const realEnd = getEnd(entry.item);
const shownEnd = resizing?.id === id ? resizing.end : realEnd;
const label = renderEvent ? renderEvent(entry.item) : undefined;
return editable ? (
<DraggableEventBlock
key={id}
id={id}
title={getTitle(entry.item)}
start={start}
end={shownEnd}
realEnd={realEnd}
label={label}
laidOut={entry}
color={color?.(entry.item)}
draggedRef={draggedRef}
onClick={() => onEventClick?.(entry.item)}
onResize={onResize}
onPreview={(end) => onPreview(end ? { id, end } : null)}
/>
) : (
<EventBlock
key={id}
title={getTitle(entry.item)}
start={start}
end={realEnd}
label={label}
laidOut={entry}
color={color?.(entry.item)}
onClick={() => onEventClick?.(entry.item)}
/>
);
})}
</>
);
}
const dayColumnClass = 'relative flex-1 border-l first:border-l-0';
function DroppableDayColumn<T extends Record<string, unknown>>({
day,
dayIndex,
creating,
onCreatingChange,
onSlotSelect,
...content
}: {
day: Date;
dayIndex: number;
creating: Creating | null;
onCreatingChange: (next: Creating | null) => void;
onSlotSelect?: (range: CalendarSlot) => void;
laidOut: LaidOut<T>[];
getStart: (item: T) => Date;
getEnd: (item: T) => Date;
getId: (item: T) => string;
getTitle: (item: T) => string;
color?: (item: T) => CalendarEventColor | undefined;
renderEvent?: (item: T) => React.ReactNode;
editable: boolean;
draggedRef: React.MutableRefObject<boolean>;
resizing: { id: string; end: Date } | null;
onResize?: (id: string, end: Date) => void;
onPreview: (preview: { id: string; end: Date } | null) => void;
onEventClick?: (item: T) => void;
}) {
const { setNodeRef } = useDroppable({ id: `col-${day.toISOString()}`, data: { day } });
const columnRef = React.useRef<HTMLDivElement | null>(null);
const startMinRef = React.useRef(0);
const canCreate = Boolean(onSlotSelect);
const mergedRef = React.useCallback(
(node: HTMLDivElement | null) => {
setNodeRef(node);
columnRef.current = node;
},
[setNodeRef],
);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!canCreate) return;
if ((e.target as HTMLElement).closest('[data-event]')) return;
const columnEl = columnRef.current;
if (!columnEl) return;
e.preventDefault();
columnEl.setPointerCapture(e.pointerId);
const startMin = offsetToMinutes(e.clientY, columnEl);
startMinRef.current = startMin;
onCreatingChange({ dayIndex, startMin, endMin: startMin });
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!creating || creating.dayIndex !== dayIndex) return;
const columnEl = columnRef.current;
if (!columnEl) return;
onCreatingChange({ dayIndex, startMin: startMinRef.current, endMin: offsetToMinutes(e.clientY, columnEl) });
};
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!creating || creating.dayIndex !== dayIndex) return;
const columnEl = columnRef.current;
if (columnEl?.hasPointerCapture(e.pointerId)) columnEl.releasePointerCapture(e.pointerId);
let lo = Math.min(creating.startMin, creating.endMin);
let hi = Math.max(creating.startMin, creating.endMin);
if (hi - lo < 5) {
lo = creating.startMin;
hi = creating.startMin + 60;
}
const base = startOfDay(day);
const start = snapToMinutes(addMinutes(base, lo));
let end = snapToMinutes(addMinutes(base, hi));
if (end.getTime() - start.getTime() < SNAP_MINUTES * 60_000) {
end = new Date(start.getTime() + SNAP_MINUTES * 60_000);
}
onCreatingChange(null);
onSlotSelect?.({ start, end, allDay: false });
};
const creatingRange = creating?.dayIndex === dayIndex ? creating : null;
return (
<div
ref={mergedRef}
className={cn(dayColumnClass, 'cursor-pointer')}
style={{ height: 24 * HOUR_HEIGHT }}
onPointerDown={canCreate ? handlePointerDown : undefined}
onPointerMove={canCreate ? handlePointerMove : undefined}
onPointerUp={canCreate ? handlePointerUp : undefined}
>
<DayColumnContent {...content} creatingRange={creatingRange} />
</div>
);
}
function PlainDayColumn<T extends Record<string, unknown>>(content: {
laidOut: LaidOut<T>[];
getStart: (item: T) => Date;
getEnd: (item: T) => Date;
getId: (item: T) => string;
getTitle: (item: T) => string;
color?: (item: T) => CalendarEventColor | undefined;
renderEvent?: (item: T) => React.ReactNode;
editable: boolean;
draggedRef: React.MutableRefObject<boolean>;
resizing: { id: string; end: Date } | null;
onResize?: (id: string, end: Date) => void;
onPreview: (preview: { id: string; end: Date } | null) => void;
onEventClick?: (item: T) => void;
}) {
return (
<div className={dayColumnClass} style={{ height: 24 * HOUR_HEIGHT }}>
<DayColumnContent {...content} />
</div>
);
}
function TimeGridView<T extends Record<string, unknown>>({
days,
items,
getStart,
getEnd,
getId,
getTitle,
isAllDay,
color,
renderEvent,
editable,
draggedRef,
onResize,
onEventClick,
onSlotSelect: selectSlot,
locale,
allDayLabel,
}: {
days: Date[];
items: T[];
getStart: (item: T) => Date;
getEnd: (item: T) => Date;
getId: (item: T) => string;
getTitle: (item: T) => string;
isAllDay: (item: T) => boolean;
color?: (item: T) => CalendarEventColor | undefined;
renderEvent?: (item: T) => React.ReactNode;
editable: boolean;
draggedRef: React.MutableRefObject<boolean>;
onResize?: (id: string, end: Date) => void;
onEventClick?: (item: T) => void;
onSlotSelect?: (range: CalendarSlot) => void;
locale?: Locale;
allDayLabel: string;
}) {
const [resizing, setResizing] = React.useState<{ id: string; end: Date } | null>(null);
const [creating, setCreating] = React.useState<Creating | null>(null);
const spansAllDay = React.useCallback(
(item: T) => isAllDay(item) || getEnd(item).getTime() - getStart(item).getTime() >= DAY_MS,
[isAllDay, getEnd, getStart],
);
const canCreateAllDay = editable && Boolean(selectSlot);
return (
<div className='w-full overflow-hidden rounded-md border'>
<div className='max-h-[600px] overflow-y-auto'>
<div className='sticky top-0 z-30 bg-background'>
<div className='flex border-b'>
<div className='w-14 shrink-0 border-r' />
{days.map((day) => (
<div
key={day.toISOString()}
className='flex-1 border-l p-2 text-center text-xs font-medium first:border-l-0'
>
{format(day, 'EEE d', { locale })}
</div>
))}
</div>
<div className='flex border-b'>
<div className='flex w-14 shrink-0 items-center justify-center border-r py-1 text-xs text-muted-foreground'>
{allDayLabel}
</div>
{days.map((day) => {
const allDayEvents = items.filter((item) => spansAllDay(item) && isSameDay(getStart(item), day));
return (
<div
key={day.toISOString()}
className={cn(
'flex min-h-7 flex-1 flex-col gap-1 border-l p-1 first:border-l-0',
canCreateAllDay && 'cursor-pointer',
)}
onClick={
canCreateAllDay
? () => selectSlot?.({ start: startOfDay(day), end: addDays(startOfDay(day), 1), allDay: true })
: undefined
}
>
{allDayEvents.map((item) => (
<button
type='button'
key={getId(item)}
onClick={(e) => {
e.stopPropagation();
onEventClick?.(item);
}}
className={cn(
'w-full truncate rounded px-1 py-0.5 text-left text-xs',
colorClasses(color?.(item)),
)}
>
{renderEvent ? renderEvent(item) : getTitle(item)}
</button>
))}
</div>
);
})}
</div>
</div>
<div className='flex'>
<div className='w-14 shrink-0 border-r'>
{HOURS.map((h) => (
<div key={h} className='h-12 pr-1 text-right text-xs text-muted-foreground'>
{`${String(h).padStart(2, '0')}:00`}
</div>
))}
</div>
{days.map((day, dayIndex) => {
const timed = items.filter((item) => !spansAllDay(item) && isSameDay(getStart(item), day));
const laidOut = layoutDay(timed, getStart, getEnd);
const columnProps = {
laidOut,
getStart,
getEnd,
getId,
getTitle,
color,
renderEvent,
editable,
draggedRef,
resizing,
onResize,
onPreview: setResizing,
onEventClick,
};
return editable ? (
<DroppableDayColumn
key={day.toISOString()}
day={day}
dayIndex={dayIndex}
creating={creating}
onCreatingChange={setCreating}
onSlotSelect={selectSlot}
{...columnProps}
/>
) : (
<PlainDayColumn key={day.toISOString()} {...columnProps} />
);
})}
</div>
</div>
</div>
);
}
const monthPillClass = 'w-full truncate rounded px-1 py-0.5 text-left text-xs';
function MonthEventPill<T>({
item,
id,
title,
label,
color,
draggedRef,
onEventClick,
}: {
item: T;
id: string;
title: string;
label?: React.ReactNode;
color?: CalendarEventColor;
draggedRef: React.MutableRefObject<boolean>;
onEventClick?: (item: T) => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id });
return (
<button
type='button'
ref={setNodeRef}
{...attributes}
{...listeners}
onClick={(e) => {
e.stopPropagation();
if (draggedRef.current) {
draggedRef.current = false;
return;
}
onEventClick?.(item);
}}
style={{ transform: CSS.Translate.toString(transform), zIndex: isDragging ? 40 : undefined, touchAction: 'none' }}
className={cn(monthPillClass, colorClasses(color))}
>
{label ?? title}
</button>
);
}
function MonthCell<T extends Record<string, unknown>>({
day,
date,
visible,
overflow,
getId,
getTitle,
color,
renderEvent,
draggedRef,
onEventClick,
onSlotSelect,
more,
}: {
day: Date;
date: Date;
visible: T[];
overflow: number;
getId: (item: T) => string;
getTitle: (item: T) => string;
color?: (item: T) => CalendarEventColor | undefined;
renderEvent?: (item: T) => React.ReactNode;
draggedRef: React.MutableRefObject<boolean>;
onEventClick?: (item: T) => void;
onSlotSelect?: (range: CalendarSlot) => void;
more: (count: number) => string;
}) {
const { setNodeRef } = useDroppable({ id: `cell-${day.toISOString()}`, data: { day } });
return (
<div
ref={setNodeRef}
className='flex min-h-24 cursor-pointer flex-col gap-1 border-b border-l p-1 hover:bg-accent/50 [&:nth-child(7n+1)]:border-l-0'
onClick={() => onSlotSelect?.({ start: startOfDay(day), end: addDays(startOfDay(day), 1), allDay: true })}
>
<span className={cn('text-xs', !isSameMonth(day, date) && 'text-muted-foreground')}>{format(day, 'd')}</span>
{visible.map((item) => (
<MonthEventPill
key={getId(item)}
item={item}
id={getId(item)}
title={getTitle(item)}
label={renderEvent ? renderEvent(item) : undefined}
color={color?.(item)}
draggedRef={draggedRef}
onEventClick={onEventClick}
/>
))}
{overflow > 0 ? <span className='px-1 text-xs text-muted-foreground'>{more(overflow)}</span> : null}
</div>
);
}
function PlainMonthCell<T extends Record<string, unknown>>({
day,
date,
visible,
overflow,
getId,
getTitle,
color,
renderEvent,
onEventClick,
more,
}: {
day: Date;
date: Date;
visible: T[];
overflow: number;
getId: (item: T) => string;
getTitle: (item: T) => string;
color?: (item: T) => CalendarEventColor | undefined;
renderEvent?: (item: T) => React.ReactNode;
onEventClick?: (item: T) => void;
more: (count: number) => string;
}) {
return (
<div className='flex min-h-24 flex-col gap-1 border-b border-l p-1 [&:nth-child(7n+1)]:border-l-0'>
<span className={cn('text-xs', !isSameMonth(day, date) && 'text-muted-foreground')}>{format(day, 'd')}</span>
{visible.map((item) => (
<button
type='button'
key={getId(item)}
className={cn(monthPillClass, colorClasses(color?.(item)))}
onClick={() => onEventClick?.(item)}
>
{renderEvent ? renderEvent(item) : getTitle(item)}
</button>
))}
{overflow > 0 ? <span className='px-1 text-xs text-muted-foreground'>{more(overflow)}</span> : null}
</div>
);
}
function MonthView<T extends Record<string, unknown>>({
date,
weekStartsOn,
items,
getStart,
getId,
getTitle,
color,
renderEvent,
editable,
draggedRef,
onEventClick,
onSlotSelect,
locale,
more,
}: {
date: Date;
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6;
items: T[];
getStart: (item: T) => Date;
getId: (item: T) => string;
getTitle: (item: T) => string;
color?: (item: T) => CalendarEventColor | undefined;
renderEvent?: (item: T) => React.ReactNode;
editable: boolean;
draggedRef: React.MutableRefObject<boolean>;
onEventClick?: (item: T) => void;
onSlotSelect?: (range: CalendarSlot) => void;
locale?: Locale;
more: (count: number) => string;
}) {
const days = monthGrid(date, weekStartsOn);
const weekdayLabels = days.slice(0, 7).map((day) => format(day, 'EEE', { locale }));
return (
<div className='w-full overflow-hidden rounded-md border'>
<div className='grid grid-cols-7'>
{weekdayLabels.map((label) => (
<div
key={label}
className='border-b border-l p-2 text-center text-xs font-medium text-muted-foreground first:border-l-0'
>
{label}
</div>
))}
{days.map((day) => {
const dayEvents = items
.filter((item) => isSameDay(getStart(item), day))
.sort((a, b) => getStart(a).getTime() - getStart(b).getTime());
const visible = dayEvents.slice(0, 3);
const overflow = dayEvents.length - visible.length;
return editable ? (
<MonthCell
key={day.toISOString()}
day={day}
date={date}
visible={visible}
overflow={overflow}
getId={getId}
getTitle={getTitle}
color={color}
renderEvent={renderEvent}
draggedRef={draggedRef}
onEventClick={onEventClick}
onSlotSelect={onSlotSelect}
more={more}
/>
) : (
<PlainMonthCell
key={day.toISOString()}
day={day}
date={date}
visible={visible}
overflow={overflow}
getId={getId}
getTitle={getTitle}
color={color}
renderEvent={renderEvent}
onEventClick={onEventClick}
more={more}
/>
);
})}
</div>
</div>
);
}
function AgendaView<T extends Record<string, unknown>>({
days,
items,
getStart,
getEnd,
getId,
getTitle,
color,
renderEvent,
onEventClick,
locale,
noEventsLabel,
}: {
days: Date[];
items: T[];
getStart: (item: T) => Date;
getEnd: (item: T) => Date;
getId: (item: T) => string;
getTitle: (item: T) => string;
color?: (item: T) => CalendarEventColor | undefined;
renderEvent?: (item: T) => React.ReactNode;
onEventClick?: (item: T) => void;
locale?: Locale;
noEventsLabel: string;
}) {
const groups = days
.map((day) => ({
day,
events: items
.filter((item) => isSameDay(getStart(item), day))
.sort((a, b) => getStart(a).getTime() - getStart(b).getTime()),
}))
.filter((group) => group.events.length > 0);
if (groups.length === 0) {
return <p className='w-full text-sm text-muted-foreground'>{noEventsLabel}</p>;
}
return (
<div className='flex max-h-[600px] w-full flex-col gap-4 overflow-y-auto'>
{groups.map((group) => (
<div key={group.day.toISOString()} className='flex flex-col gap-1'>
<div className='text-sm font-medium'>{format(group.day, 'EEEE, MMM d', { locale })}</div>
{group.events.map((item) => (
<button
type='button'
key={getId(item)}
onClick={() => onEventClick?.(item)}
className='flex w-full items-center gap-2 text-left text-sm'
>
<span className={cn('size-2 rounded-full', colorClasses(color?.(item)))} />
<span className='text-muted-foreground'>
{format(getStart(item), 'HH:mm')}{format(getEnd(item), 'HH:mm')}
</span>
<span>{renderEvent ? renderEvent(item) : getTitle(item)}</span>
</button>
))}
</div>
))}
</div>
);
}
Loading...

Calendar is a generic, responsive calendar that renders any array of typed events. The event id, title, start, end, and all-day fields are mapped against your own data shape.

It is split into composable parts that share state through context, so you can drop in the default toolbar or build your own navigation without prop drilling:

import { Calendar, useCalendar } from '@/components/block/shuip/calendar';

<Calendar.Root events={events} onEventsChange={setEvents} titleField='title' startField='start' endField='end' editable>
  <Calendar.Nav />   {/* default toolbar — or your own navbar via useCalendar() */}
  <Calendar.View />  {/* the views: month / week / day / agenda */}
</Calendar.Root>;
  • Calendar.Root holds all state and config. The props below live here.
  • Calendar.Nav is the default navigation (today, previous/next, period label, view switcher).
  • Calendar.View is the calendar surface itself.
  • useCalendar() exposes view, date, periodLabel, goToToday, goToPrevious, goToNext, setView, and more — read it from any descendant of Calendar.Root to build a custom toolbar.

Built-in features

  • Generic typed event model: works with any item type T; the id, title, start, end, and all-day fields are mapped via idField / titleField / startField / endField / allDayField.
  • Four views: month, week, day, and agenda, switched from the toolbar or controlled via view / defaultView.
  • Responsive week → day: the week view falls back to a single-day view on narrow screens.
  • Hybrid state: works standalone from defaultEvents, or controlled via events + onEventsChange.
  • Drag to move: drag an event to another day (month) or time slot (week/day) when editable.
  • Resize: drag an event's bottom edge in week/day to change its end time when editable.
  • Drag to create: drag across an empty range to emit onSlotSelect when editable.
  • Per-event colors: derive a color per event with color(item).
  • Custom event content: replace the default event label with renderEvent(item).

Define your event type with a type alias, not an interfaceCalendar.Root<T> requires T extends Record<string, unknown>, which a TypeScript interface does not satisfy.

Examples

Default

Loading...
'use client';
import * as React from 'react';
import { Calendar, type CalendarEventColor, type CalendarSlot } from '@/components/block/shuip/calendar';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
type CalEvent = { id: string; title: string; start: Date; end: Date; color?: CalendarEventColor; allDay?: boolean };
const now = new Date();
const at = (dayOffset: number, h: number, m = 0) =>
new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset, h, m);
const seed: CalEvent[] = [
{ id: '1', title: 'Standup', start: at(0, 9), end: at(0, 9, 30), color: 'secondary' },
{ id: '2', title: 'Design review', start: at(0, 11), end: at(0, 12), color: 'accent' },
{ id: '3', title: 'Lunch', start: at(1, 12), end: at(1, 13) },
{ id: '4', title: 'Offsite', start: at(2, 0), end: at(3, 0), color: 'muted' },
];
const colors: CalendarEventColor[] = ['primary', 'secondary', 'accent', 'destructive', 'muted'];
export default function Example() {
const [events, setEvents] = React.useState<CalEvent[]>(seed);
const [draft, setDraft] = React.useState<CalEvent | null>(null);
const [open, setOpen] = React.useState(false);
const isExisting = draft ? events.some((e) => e.id === draft.id) : false;
const handleSlotSelect = React.useCallback(({ start, end, allDay }: CalendarSlot) => {
setDraft({ id: crypto.randomUUID(), title: '', start, end, allDay });
setOpen(true);
}, []);
return (
<>
<Calendar.Root<CalEvent>
events={events}
onEventsChange={setEvents}
titleField='title'
startField='start'
endField='end'
allDayField='allDay'
color={(e) => e.color}
defaultView='week'
editable
onEventClick={(e) => {
setDraft(e);
setOpen(true);
}}
onSlotSelect={handleSlotSelect}
>
<div className='flex w-full flex-col gap-4'>
<Calendar.Nav />
<Calendar.View />
</div>
</Calendar.Root>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isExisting ? 'Edit event' : 'New event'}</DialogTitle>
</DialogHeader>
{draft ? (
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2'>
<Label htmlFor='cal-title'>Title</Label>
<Input
id='cal-title'
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
/>
</div>
<div className='flex flex-col gap-2'>
<Label htmlFor='cal-color'>Color</Label>
<Select
value={draft.color ?? 'primary'}
onValueChange={(v) => setDraft({ ...draft, color: v as CalendarEventColor })}
>
<SelectTrigger id='cal-color'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{colors.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='flex items-center gap-2'>
<Checkbox
id='cal-allday'
checked={draft.allDay ?? false}
onCheckedChange={(v) => setDraft({ ...draft, allDay: v === true })}
/>
<Label htmlFor='cal-allday'>All day</Label>
</div>
</div>
) : null}
<DialogFooter>
{isExisting ? (
<Button
variant='outline'
onClick={() => {
if (draft) setEvents((prev) => prev.filter((e) => e.id !== draft.id));
setOpen(false);
}}
>
Delete
</Button>
) : null}
<Button
onClick={() => {
if (!draft) return;
setEvents((prev) =>
prev.some((e) => e.id === draft.id)
? prev.map((e) => (e.id === draft.id ? draft : e))
: [...prev, draft],
);
setOpen(false);
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

Local Storage

Loading calendar…
'use client';
import * as React from 'react';
import { Calendar, type CalendarEventColor, type CalendarSlot } from '@/components/block/shuip/calendar';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type CalEvent = { id: string; title: string; start: Date; end: Date; color?: CalendarEventColor };
type StoredEvent = { id: string; title: string; start: string; end: string; color?: CalendarEventColor };
const STORAGE_KEY = 'shuip:calendar-events';
const now = new Date();
const at = (dayOffset: number, h: number, m = 0) =>
new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset, h, m);
const seed: CalEvent[] = [
{ id: '1', title: 'Standup', start: at(0, 9), end: at(0, 9, 30), color: 'secondary' },
{ id: '2', title: 'Ship release', start: at(1, 15), end: at(1, 16), color: 'destructive' },
];
// Dates do not survive JSON, so we serialize to ISO strings and revive on read.
// The artificial delay stands in for a real async source (an API, IndexedDB, …).
async function loadEvents(): Promise<CalEvent[]> {
await new Promise((resolve) => setTimeout(resolve, 500));
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return seed;
const stored = JSON.parse(raw) as StoredEvent[];
return stored.map((e) => ({ ...e, start: new Date(e.start), end: new Date(e.end) }));
}
async function saveEvents(events: CalEvent[]): Promise<void> {
const stored: StoredEvent[] = events.map((e) => ({ ...e, start: e.start.toISOString(), end: e.end.toISOString() }));
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
}
export default function Example() {
const [events, setEvents] = React.useState<CalEvent[] | null>(null);
const [draft, setDraft] = React.useState<CalEvent | null>(null);
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
let active = true;
loadEvents().then((loaded) => {
if (active) setEvents(loaded);
});
return () => {
active = false;
};
}, []);
const persist = React.useCallback((next: CalEvent[]) => {
setEvents(next);
void saveEvents(next);
}, []);
const handleSlotSelect = React.useCallback(({ start, end }: CalendarSlot) => {
setDraft({ id: crypto.randomUUID(), title: '', start, end });
setOpen(true);
}, []);
if (!events) {
return (
<div className='flex h-72 w-full items-center justify-center text-sm text-muted-foreground'>
Loading calendar…
</div>
);
}
const isExisting = draft ? events.some((e) => e.id === draft.id) : false;
return (
<>
<Calendar.Root<CalEvent>
events={events}
onEventsChange={persist}
titleField='title'
startField='start'
endField='end'
color={(e) => e.color}
defaultView='week'
editable
onEventClick={(e) => {
setDraft(e);
setOpen(true);
}}
onSlotSelect={handleSlotSelect}
>
<div className='flex w-full flex-col gap-4'>
<Calendar.Nav />
<Calendar.View />
</div>
</Calendar.Root>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isExisting ? 'Edit event' : 'New event'}</DialogTitle>
</DialogHeader>
{draft ? (
<div className='flex flex-col gap-2'>
<Label htmlFor='ls-title'>Title</Label>
<Input
id='ls-title'
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
/>
</div>
) : null}
<DialogFooter>
{isExisting ? (
<Button
variant='outline'
onClick={() => {
if (draft) persist(events.filter((e) => e.id !== draft.id));
setOpen(false);
}}
>
Delete
</Button>
) : null}
<Button
onClick={() => {
if (!draft) return;
persist(
events.some((e) => e.id === draft.id)
? events.map((e) => (e.id === draft.id ? draft : e))
: [...events, draft],
);
setOpen(false);
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

Nav Variants

Loading...
'use client';
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react';
import * as React from 'react';
import { Calendar, type CalendarView, useCalendar } from '@/components/block/shuip/calendar';
import { Button } from '@/components/ui/button';
import { Calendar as DayPicker } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
type CalEvent = { id: string; title: string; start: Date; end: Date };
const now = new Date();
const at = (dayOffset: number, h: number) => new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset, h);
const seed: CalEvent[] = [
{ id: '1', title: 'Sprint planning', start: at(0, 10), end: at(0, 11) },
{ id: '2', title: 'Release', start: at(1, 15), end: at(1, 16) },
{ id: '3', title: '1:1', start: at(3, 14), end: at(3, 15) },
];
function TabsNav() {
const { periodLabel, views, view, setView, goToPrevious, goToNext } = useCalendar();
return (
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex items-center gap-1'>
<Button variant='ghost' size='icon' className='size-8' onClick={goToPrevious} aria-label='Previous'>
<ChevronLeft className='size-4' />
</Button>
<Button variant='ghost' size='icon' className='size-8' onClick={goToNext} aria-label='Next'>
<ChevronRight className='size-4' />
</Button>
<span className='ml-1 text-sm font-medium'>{periodLabel}</span>
</div>
<Tabs value={view} onValueChange={(v) => setView(v as CalendarView)}>
<TabsList>
{views.map((v) => (
<TabsTrigger key={v} value={v} className='capitalize'>
{v}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
);
}
function CenteredNav() {
const { periodLabel, views, view, setView, goToToday, goToPrevious, goToNext } = useCalendar();
return (
<div className='flex flex-col items-center gap-3'>
<div className='flex items-center gap-4'>
<Button variant='ghost' size='icon' className='size-8' onClick={goToPrevious} aria-label='Previous'>
<ChevronLeft className='size-4' />
</Button>
<span className='min-w-52 text-center text-lg font-semibold tracking-tight'>{periodLabel}</span>
<Button variant='ghost' size='icon' className='size-8' onClick={goToNext} aria-label='Next'>
<ChevronRight className='size-4' />
</Button>
</div>
<div className='flex items-center gap-2'>
<Button variant='outline' size='sm' onClick={goToToday}>
Today
</Button>
<Select value={view} onValueChange={(v) => setView(v as CalendarView)}>
<SelectTrigger className='w-32' size='sm'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{views.map((v) => (
<SelectItem key={v} value={v} className='capitalize'>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
function CompactNav() {
const { periodLabel, views, view, setView, goToToday, goToPrevious, goToNext } = useCalendar();
return (
<div className='flex items-center justify-between gap-2'>
<div className='flex items-center gap-1'>
<Button variant='outline' size='icon' className='size-8' onClick={goToPrevious} aria-label='Previous'>
<ChevronLeft className='size-4' />
</Button>
<Button variant='outline' size='icon' className='size-8' onClick={goToToday} aria-label='Today'>
<CalendarDays className='size-4' />
</Button>
<Button variant='outline' size='icon' className='size-8' onClick={goToNext} aria-label='Next'>
<ChevronRight className='size-4' />
</Button>
</div>
<span className='min-w-0 flex-1 truncate text-center text-sm font-medium'>{periodLabel}</span>
<Select value={view} onValueChange={(v) => setView(v as CalendarView)}>
<SelectTrigger className='w-24' size='sm'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{views.map((v) => (
<SelectItem key={v} value={v} className='capitalize'>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function DatePickerNav() {
const { date, setDate, periodLabel, goToToday, goToPrevious, goToNext } = useCalendar();
const [open, setOpen] = React.useState(false);
return (
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex items-center gap-1'>
<Button variant='outline' size='sm' onClick={goToToday}>
Today
</Button>
<Button variant='outline' size='icon' className='size-8' onClick={goToPrevious} aria-label='Previous'>
<ChevronLeft className='size-4' />
</Button>
<Button variant='outline' size='icon' className='size-8' onClick={goToNext} aria-label='Next'>
<ChevronRight className='size-4' />
</Button>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant='ghost' className='gap-2 text-sm font-medium'>
<CalendarDays className='size-4' />
{periodLabel}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='end'>
<DayPicker
mode='single'
selected={date}
onSelect={(d) => {
if (d) {
setDate(d);
setOpen(false);
}
}}
/>
</PopoverContent>
</Popover>
</div>
);
}
function NavCase({ label, nav }: { label: string; nav: React.ReactNode }) {
return (
<div className='flex flex-col gap-3 rounded-lg border p-4'>
<span className='text-xs font-medium text-muted-foreground'>{label}</span>
<Calendar.Root<CalEvent>
defaultEvents={seed}
titleField='title'
startField='start'
endField='end'
defaultView='agenda'
>
<div className='flex flex-col gap-4'>
{nav}
<Calendar.View />
</div>
</Calendar.Root>
</div>
);
}
export default function Example() {
return (
<div className='flex w-full flex-col gap-6'>
<NavCase label='Segmented tabs' nav={<TabsNav />} />
<NavCase label='Centered' nav={<CenteredNav />} />
<NavCase label='Compact / mobile' nav={<CompactNav />} />
<NavCase label='Date picker jump' nav={<DatePickerNav />} />
</div>
);
}

Views

Loading...
'use client';
import { Calendar } from '@/components/block/shuip/calendar';
type CalEvent = { id: string; title: string; start: Date; end: Date };
const now = new Date();
const at = (dayOffset: number, h: number) => new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset, h);
const events: CalEvent[] = [
{ id: '1', title: 'Sprint planning', start: at(0, 10), end: at(0, 11) },
{ id: '2', title: 'Release', start: at(1, 15), end: at(1, 16) },
{ id: '3', title: '1:1', start: at(3, 14), end: at(3, 15) },
];
export default function Example() {
return (
<Calendar.Root<CalEvent>
defaultEvents={events}
titleField='title'
startField='start'
endField='end'
defaultView='agenda'
>
<div className='flex w-full flex-col gap-4'>
<Calendar.Nav />
<Calendar.View />
</div>
</Calendar.Root>
);
}

Props

Props are passed to Calendar.Root.

Prop

Type

On this page