Used in taskgenius/taskgenius-plugin
A lightweight, configurable TypeScript calendar component library with drag-and-drop support.
- Three view modes - month, week, and day views with dedicated all-day lane
- Extensible view system - register custom views or extend built-ins
- Cross-midnight & multi-day support - timed/all-day events render and drag correctly across days
- Drag-and-drop/resize - move or resize events with optional date-only mode
- Flexible layout controls - first day of week, hide weekends, custom day/time filters
- Overflow handling - event count badges plus configurable "+N more" popover renderer
- Custom rendering hooks - date cells, event styling, event content, and popovers
- Lightweight - <12KB gzipped with zero runtime deps
- Pluggable adapters - Day.js by default, custom adapters supported
- TypeScript first - complete type definitions and SOLID architecture
npm install @taskgenius/calendar dayjsimport { Calendar } from '@taskgenius/calendar';
import '@taskgenius/calendar/styles.css'; // Required - component uses CSS classes
// Method 1: Pass CSS selector string
const calendar = new Calendar('#app', {
view: { type: 'week' },
events: [
{
id: '1',
title: 'Team Meeting',
start: '2025-11-20 10:00',
end: '2025-11-20 11:30',
color: '#3b82f6'
}
],
onEventClick: (event) => {
console.log('Clicked:', event.title);
},
onEventDrop: (event, newStart, newEnd) => {
console.log('Moved:', event.title, newStart.toISOString(), newEnd.toISOString());
}
});
// Method 2: Pass HTMLElement directly
const container = document.getElementById('app');
const calendar2 = new Calendar(container, {
view: { type: 'month' }
});For users who want to minimize bundle size, you can use CalendarCore instead of Calendar. The CalendarCore class does not import any built-in views by default, allowing your bundler to exclude unused view code.
The default Calendar class provides a "batteries-included" experience with automatic view registration:
import { Calendar } from '@taskgenius/calendar';
import '@taskgenius/calendar/styles.css';
// All built-in views (Month, Week, Day) are automatically available
const calendar = new Calendar('#app', {
view: { type: 'month' }
});For optimal bundle size, use CalendarCore with explicit view registration:
import { CalendarCore, MonthView, ViewRegistry } from '@taskgenius/calendar';
import '@taskgenius/calendar/styles.css';
// Only imports MonthView code - Week and Day views are tree-shaken
const calendar = new CalendarCore('#app', {
view: { type: 'month' },
viewRegistry: new ViewRegistry().register(MonthView)
});Multiple views example:
import { CalendarCore, MonthView, WeekView, ViewRegistry } from '@taskgenius/calendar';
const registry = new ViewRegistry()
.register(MonthView)
.register(WeekView);
const calendar = new CalendarCore('#app', {
view: { type: 'week' },
viewRegistry: registry
});- Use
Calendar: Quick start, prototyping, or when bundle size is not a concern - Use
CalendarCore: Production builds where bundle optimization is important
Important: The component requires @taskgenius/calendar/styles.css to render correctly. Since v0.4.0, all layout and styling use external CSS classes instead of inline styles.
Import options:
- ES modules:
import '@taskgenius/calendar/styles.css';(recommended) - HTML link:
<link rel="stylesheet" href="/node_modules/@taskgenius/calendar/dist/styles.css">
Customization:
- Theme settings are delivered via CSS variables:
--tg-primary-color,--tg-primary-rgb,--tg-cell-height,--tg-font-header,--tg-font-event. The library sets these on the.tg-calendarroot; you can override them in your own styles. - To fully customize the look, you can override the default CSS or provide your own styles using the exposed
tg-*class names.
The main entry point for the calendar component. This class extends CalendarCore and automatically registers all built-in views (Month, Week, Day) for a "batteries-included" experience. Perfect for quick starts and when bundle size is not a primary concern.
For advanced users: Use CalendarCore directly for a leaner, tree-shakeable version that requires manual view registration. See the Tree Shaking & Optimization section for details.
new Calendar(container: string | HTMLElement, config?: CalendarConfig)Parameters:
container- CSS selector string (e.g.,'#app') or HTMLElement referenceconfig- Optional configuration object
Example:
// Using CSS selector
const cal1 = new Calendar('#calendar', { view: { type: 'week' } });
// Using HTMLElement
const element = document.getElementById('calendar');
const cal2 = new Calendar(element, { view: { type: 'week' } });| Method | Description |
|---|---|
registerView(ViewClass, options?) |
Register a custom view (class must expose static meta) |
unregisterView(type) |
Remove a registered view type |
getRegisteredViews() |
Get metadata for all registered views |
getViewRegistry() |
Access the view registry instance |
hasView(type) |
Check whether a view type is registered |
setView(type: ViewType | string) |
Switch between registered views (built-in or custom) |
getView() |
Get current view type |
getActiveView() |
Get the active view instance |
addEvent(event: CalendarEvent) |
Add a new event |
removeEvent(id: string) |
Remove event by ID |
updateEvent(id: string, updates: Partial<CalendarEvent>) |
Update event properties |
setEvents(events: CalendarEvent[]) |
Replace all events |
getEvents() |
Get all events |
next() |
Navigate to next period |
prev() |
Navigate to previous period |
today() |
Navigate to today |
goToDate(date: string | Date) |
Navigate to specific date |
getCurrentDate() |
Get current displayed date (ISO string) |
setDraggable(enabled: boolean) |
Enable or disable drag-and-drop at runtime |
isDraggable() |
Check whether drag-and-drop is enabled |
refresh() |
Force re-render |
destroy() |
Cleanup and remove calendar |
interface CalendarConfig {
view?: ViewConfig;
events?: CalendarEvent[];
draggable?: DraggableConfig;
theme?: ThemeConfig;
dateAdapter?: DateAdapter<unknown>; // Custom date adapter (default: Day.js)
dateFormats?: Partial<DateFormatConfig>; // Custom date display formats (unicode tokens recommended)
headerFormat?: { // Deprecated: mapped to dateFormats
month?: string;
day?: string;
};
showEventCounts?: boolean; // Default: false - Show event count badges on date cells
// Event interactions
onEventClick?: (event: CalendarEvent) => void;
onEventDoubleClick?: (event: CalendarEvent) => void;
onEventContextMenu?: (event: CalendarEvent, x: number, y: number) => void;
onEventDrop?: (event: CalendarEvent, newStart: Date, newEnd: Date) => void; // v0.8.0+: Date objects
onEventResize?: (event: CalendarEvent, newStart: Date, newEnd: Date) => void; // v0.9.0+: Resize callback
// View and navigation
onViewChange?: (viewType: ViewType) => void;
onDateChange?: (date: Date) => void;
// Date cell interactions (month view)
onDateClick?: (date: Date) => void;
onDateDoubleClick?: (date: Date) => void;
onDateContextMenu?: (date: Date, x: number, y: number) => void;
// Time slot interactions (week/day view)
onTimeSlotClick?: (dateTime: Date) => void;
onTimeSlotDoubleClick?: (dateTime: Date) => void;
onTimeSlotContextMenu?: (dateTime: Date, x: number, y: number) => void;
// Range selection (drag to select multiple cells)
onDateRangeSelect?: (startDate: Date, endDate: Date) => void;
onTimeRangeSelect?: (startDateTime: Date, endDateTime: Date) => void;
// Rendering hooks
onRenderDateCell?: (ctx: DateCellContext) => void; // Custom date cell rendering
onStyleEvent?: (event: CalendarEvent) => EventStyle; // Custom event styling
onRenderEvent?: (ctx: EventRenderContext) => void; // v0.12.0+: Custom event content
onRenderMoreEventsPopover?: ( // v0.10.0+: Custom "+N more" popover renderer
events: CalendarEvent[],
date: Date,
anchorEl: HTMLElement,
defaultRender: () => void
) => void;
}For custom view registries, the constructor also accepts:
interface ExtendedCalendarConfig extends CalendarConfig {
viewRegistry?: ViewRegistry; // Provide a custom registry
registerBuiltInViews?: boolean; // Default: true - auto-register Month/Week/Day views
}interface ViewConfig {
type: 'month' | 'week' | 'day'; // Default: 'week'
showDateHeader?: boolean; // Default: true (time views)
showWeekNumbers?: boolean; // Default: false (month view)
firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6; // Default: 0 (supports full 0-6 range)
showWeekends?: boolean; // Default: true (false is converted to a dayFilter)
maxEventsPerRow?: number; // Month view: cap events per row before showing "+N more"
dayFilter?: (date: unknown, ctx: DayFilterContext) => DayFilterResult; // Hide/customize days
timeFilter?: (hour: number) => TimeFilterResult; // Hide/customize time slots
timeFormatter?: TimeFormatter; // Custom time axis labels
}dayFilter/timeFilter can return boolean or config objects (DayRenderConfig / TimeSlotConfig). Setting DayRenderConfig.disabled keeps the cell visible but hides events for that date.
interface DraggableConfig {
enabled: boolean; // Default: true
snapMinutes?: number; // Default: 15
ghostOpacity?: number; // Default: 0.5
dateOnly?: boolean; // Default: false - Only adjust dates, keep time unchanged
}interface ThemeConfig {
primaryColor?: string; // Default: '#3b82f6'
cellHeight?: number; // Default: 60 (pixels per hour)
fontSize?: {
header?: string; // Default: '14px'
event?: string; // Default: '12px'
};
}interface CalendarEvent {
id: string; // Unique identifier
title: string; // Display title
start: string; // ISO format: 'yyyy-MM-dd HH:mm' (supports cross-midnight spans)
end: string; // ISO format: 'yyyy-MM-dd HH:mm' (use 00:00/23:59 for all-day)
color?: string; // CSS color value
metadata?: Record<string, unknown>; // Custom data
}import { Calendar } from '@taskgenius/calendar';
import '@taskgenius/calendar/styles.css'; // Required
// Initialize with CSS selector
const calendar = new Calendar('#calendar-container');
// Or initialize with DOM element
const element = document.querySelector('.my-calendar');
const calendar2 = new Calendar(element);
// Add event
calendar.addEvent({
id: '1',
title: 'Meeting',
start: '2025-11-20 10:00',
end: '2025-11-20 11:30',
color: '#3b82f6'
});
// Switch view
calendar.setView('month');
// Navigate
calendar.next();
calendar.prev();
calendar.today();
// Clean up when done (important to prevent memory leaks)
calendar.destroy();const calendar = new Calendar('#app', {
events: myEvents,
onEventClick: (event) => {
showEventDetails(event);
},
onEventDrop: (event, newStart, newEnd) => {
// Event was moved to a new position
console.log('Event moved:', event.title);
saveEventToServer(event.id, {
start: newStart.toISOString(),
end: newEnd.toISOString()
});
},
onEventResize: (event, newStart, newEnd) => {
// Event duration was changed
console.log('Event resized:', event.title);
saveEventToServer(event.id, {
start: newStart.toISOString(),
end: newEnd.toISOString()
});
},
onViewChange: (view) => {
analytics.track('view_changed', { view });
}
});const calendar = new Calendar('#app', {
theme: {
primaryColor: '#8b5cf6',
cellHeight: 80,
fontSize: {
header: '16px',
event: '14px'
}
}
});const calendar = new Calendar('#app', {
draggable: {
enabled: false
}
});const calendar = new Calendar('#app', {
draggable: {
enabled: true,
dateOnly: true // Only adjust dates, preserve original time
}
});const calendar = new Calendar('#app', {
view: {
type: 'week',
firstDayOfWeek: 1, // Start week on Monday
showWeekends: false // Hide Saturday and Sunday
}
});const calendar = new Calendar('#app', {
view: {
type: 'week',
firstDayOfWeek: 1,
dayFilter: (_date, ctx) => ctx.isWeekend ? { visible: false } : true, // Hide weekends with config
timeFilter: (hour) => hour >= 8 && hour < 18, // Working hours only
timeFormatter: (hour) => `${hour}:00` // Custom time axis labels
}
});const calendar = new Calendar('#app', {
view: { type: 'month' },
showEventCounts: true // Display event count on each date cell
});const calendar = new Calendar('#app', {
view: {
type: 'month',
maxEventsPerRow: 3
},
onRenderMoreEventsPopover: (events, date, anchorEl, defaultRender) => {
console.log('Hidden events on', date.toISOString(), events.length);
defaultRender(); // Or render a custom popover
}
});const calendar = new Calendar('#app', {
view: { type: 'month' },
// Date cell interactions (month view)
onDateClick: (date) => {
console.log('Clicked date:', date); // Date object
showCreateEventDialog(date);
},
onDateDoubleClick: (date) => {
console.log('Double-clicked date:', date);
createQuickEvent(date);
},
onDateContextMenu: (date, x, y) => {
console.log('Right-clicked date:', date, 'at position:', x, y);
showContextMenu(date, x, y);
},
// Range selection (drag to select)
onDateRangeSelect: (startDate, endDate) => {
console.log('Selected range:', startDate, 'to', endDate);
createMultiDayEvent(startDate, endDate);
}
});
// For week/day views with time slots
const weekCalendar = new Calendar('#app', {
view: { type: 'week' },
// Time slot interactions
onTimeSlotClick: (dateTime) => {
console.log('Clicked time slot:', dateTime); // Date object with time
createEventAt(dateTime);
},
onTimeSlotDoubleClick: (dateTime) => {
console.log('Double-clicked time slot:', dateTime);
quickCreateEvent(dateTime);
},
onTimeSlotContextMenu: (dateTime, x, y) => {
console.log('Right-clicked time slot:', dateTime);
showTimeSlotMenu(dateTime, x, y);
},
// Time range selection (drag to select multiple slots)
onTimeRangeSelect: (startDateTime, endDateTime) => {
console.log('Selected time range:', startDateTime, 'to', endDateTime);
createTimedEvent(startDateTime, endDateTime);
}
});const calendar = new Calendar('#app', {
// Customize date display formats (uses Unicode tokens)
dateFormats: {
date: 'yyyy/MM/dd', // Default: 'yyyy-MM-dd'
dateTime: 'yyyy/MM/dd HH:mm', // Default: 'yyyy-MM-dd HH:mm'
time: 'HH:mm', // Default: 'HH:mm'
monthHeader: 'MMMM yyyy', // Default: 'yyyy\u5e74M\u6708'
dayHeader: 'MMMM d, yyyy' // Default: 'yyyy\u5e74M\u6708d\u65e5'
}
});Note: Date format tokens use Unicode standard (compatible with date-fns, Day.js, and native adapter):
- Year:
yyyy(2025),yy(25) - Month:
MM(01-12),M(1-12),MMMM(January),MMM(Jan) - Day:
dd(01-31),d(1-31) - Hour:
HH(00-23),H(0-23) - Minute:
mm(00-59),m(0-59) - Legacy
headerFormatis deprecated; it maps todateFormats.monthHeader/dayHeaderfor backward compatibility.
const calendar = new Calendar('#app', {
onRenderDateCell: (ctx) => {
// Add custom badge for past due dates with events
if (ctx.isPastDue && ctx.events.length > 0) {
const badge = document.createElement('div');
badge.className = 'overdue-badge';
badge.textContent = '!';
ctx.cellEl.appendChild(badge);
}
// Add custom class for weekends
if (ctx.date.getDay() === 0 || ctx.date.getDay() === 6) {
ctx.cellEl.classList.add('weekend');
}
}
});Add corresponding CSS for custom elements:
/* Style custom overdue badge */
.overdue-badge {
position: absolute;
top: 2px;
right: 2px;
background: #ef4444;
color: white;
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
/* Style weekend cells */
.weekend {
background-color: #fef3c7;
}const calendar = new Calendar('#app', {
onStyleEvent: (event) => {
// Style based on metadata
const priority = event.metadata?.priority as number;
const isCompleted = event.metadata?.completed as boolean;
return {
color: priority >= 2 ? '#ef4444' : '#3b82f6',
opacity: isCompleted ? 0.5 : 1,
className: isCompleted ? 'completed-event' : ''
};
}
});Add CSS for custom event classes (optional):
/* Additional styling for completed events */
.completed-event {
text-decoration: line-through;
}const calendar = new Calendar('#app', {
onRenderEvent: (ctx) => {
ctx.defaultRender(); // Keep default title/time rendering
if (ctx.event.metadata?.priority === 'high') {
const badge = document.createElement('span');
badge.className = 'priority-badge';
badge.textContent = '!';
ctx.el.appendChild(badge);
}
}
});The library follows SOLID principles with a modular architecture:
src/
├── core/ # Main Calendar class, EventManager, DragController
├── adapters/ # Date library adapters (DayJs, etc.)
├── engines/ # Layout calculation (MonthEngine, TimeEngine)
├── renderers/ # DOM rendering (MonthRenderer, TimeRenderer)
├── styles/ # Static CSS + theme variable helpers (no auto-injection)
├── types/ # TypeScript type definitions
└── utils/ # DOM utilities
- Calendar - Main API and orchestration
- EventManager - CRUD operations for events
- DragController - Drag-and-drop interactions
- MonthEngine/TimeEngine - Layout calculations
- MonthRenderer/TimeRenderer - DOM generation
- DateAdapter - Pluggable date library interface
# Run tests
npm test
# Run tests with UI
npm run test:ui
# Run tests with coverage
npm run test:coverageAlways call destroy() when you no longer need the calendar instance to prevent memory leaks:
// In React
useEffect(() => {
const calendar = new Calendar(containerRef.current, config);
return () => {
calendar.destroy(); // Cleanup on unmount
};
}, []);
// In Vue
onMounted(() => {
calendar = new Calendar(el.value, config);
});
onUnmounted(() => {
calendar.destroy(); // Cleanup on unmount
});
// In vanilla JS
function createCalendar() {
const calendar = new Calendar('#app', config);
// When removing the calendar
function cleanup() {
calendar.destroy();
document.getElementById('app').innerHTML = '';
}
return { calendar, cleanup };
}You can initialize the calendar using either a CSS selector or a direct DOM element reference:
// Option 1: CSS Selector (simple and convenient)
const calendar = new Calendar('#calendar', config);
// Option 2: DOM Element (useful in frameworks)
const container = document.getElementById('calendar');
const calendar = new Calendar(container, config);
// Option 3: Dynamic element (e.g., in React with refs)
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (containerRef.current) {
const calendar = new Calendar(containerRef.current, config);
return () => calendar.destroy();
}
}, []);# Install dependencies
npm install
# Run demo
npm run demo
# Build library
npm run build
# Type check
npx tsc --noEmit@taskgenius/calendar/
├── src/ # Source code
├── tests/ # Test files
│ ├── unit/ # Unit tests
│ ├── integration/ # Integration tests
│ └── fixtures/ # Test data
├── examples/
│ └── demo/ # Vite demo project
├── dist/ # Built output
└── docs/ # Documentation
The library uses a pluggable date adapter system. By default, it uses Day.js.
import { Calendar } from '@taskgenius/calendar';
// Automatically uses DayJsAdapterImplement the DateAdapter interface to use a different date library:
interface DateAdapter<T> {
create(date?: string | Date | T): T;
parse(dateStr: string, format?: string): T;
format(date: T, format: string): string;
// ... other methods
}MIT ©TaskGenius
Contributions are welcome! Please read the contributing guidelines before submitting a PR.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request