Examples
Real-world code examples for every scenario — interactions, views, presets, and customisation.
Interactions
Drag & drop
The scheduler supports 2D free drag — blocks lift out of their row and follow your cursor freely. Drop on any row, any time, any day.
Drag and drop is enabled by default. No extra config needed:
<Scheduler
categories={categories}
employees={employees}
shifts={shifts}
onShiftsChange={setShifts}
/>Track moves with onBlockMoved:
<Scheduler
onBlockMoved={(block, newDate, newStartH, newEndH) => {
updateShift(block.id, { date: newDate, startH: newStartH, endH: newEndH })
}}
/>Disable drag on a specific block: { draggable: false }. Disable resize: { resizable: false }. Disable all interactions: <Scheduler readOnly />.
Conflict detection
The scheduler automatically shows a red ring on any two blocks that overlap for the same employee.
Two shifts conflict when: same employeeId, same date, and time ranges overlap (startH < otherEndH && endH > otherStartH). No configuration needed — it's always on.
const shifts = [
{ id: 's1', employeeId: 'e1', date: '2026-03-19', startH: 9, endH: 17, status: 'published' },
{ id: 's2', employeeId: 'e1', date: '2026-03-19', startH: 14, endH: 22, status: 'draft' },
// ↑ Both get a red ring. s2 cannot be published until resolved.
]Hovering a conflicting block shows a red "Shift conflict — cannot publish" banner in the popover.
Copy, cut & paste
Right-click any block to access Edit, Copy, Cut, and Delete.
| Action | Behaviour |
|---|---|
| Copy shift | Copies to clipboard — original stays on grid |
| Cut shift | Copies to clipboard AND removes from grid immediately |
| Paste | Click any + cell to paste — gets a new id |
Keyboard: Delete/Backspace removes focused block. Arrow keys nudge by the snap interval.
Control the clipboard externally:
const [copiedShift, setCopiedShift] = useState<Block | null>(null)
<Scheduler copiedShift={copiedShift} setCopiedShift={setCopiedShift} />Scroll to now
Scroll to the current time on load with a live pulsing indicator:
const config = createSchedulerConfig({ initialScrollToNow: true })
<Scheduler config={config} ... />Trigger it programmatically from a button:
const scrollToNowRef = useRef<(() => void) | null>(null)
<button onClick={() => scrollToNowRef.current?.()}>Jump to now</button>
<Scheduler scrollToNowRef={scrollToNowRef} ... />Zoom levels
Zoom controls the width of each hour column. 1.0 is default. Range is 0.5–2.0.
const [zoom, setZoom] = useState(1)
<Scheduler zoom={zoom} setZoom={setZoom} ... />| Value | Effect |
|---|---|
0.5 | Very compact — see more time |
1.0 | Default |
1.5 | Detailed — 30min slots clearly visible |
2.0 | Maximum detail |
Read-only mode
Disables all editing: drag, resize, right-click, keyboard shortcuts, add buttons.
<Scheduler readOnly ... />View switching, navigation, hover popover, and zoom still work.
Views
Day view
Single-day timeline. Swipe left/right on touch or use ←/→ keys to navigate.
<Scheduler initialView="day" config={createSchedulerConfig({ initialScrollToNow: true })} ... />Dragging a block past the right edge moves it to the next day. Past the left edge moves to the previous day.
Week view
Default view. 7 columns, one per day. Scroll buffer of ±15 days loads automatically at the edges with no loading spinner.
<Scheduler initialView="week" ... />Month view
Monthly calendar overview. Coloured dots show shift density per day. Click any day to drill into day view.
<Scheduler initialView="month" ... />Year view
Bird's-eye annual heat-map. Shows which days have shifts across the whole year.
<Scheduler initialView="year" ... />List view
Tabular shift list, sortable by date and time. Two sub-modes: day list and week list via the view switcher.
<Scheduler initialView="list" ... />Timeline view
Horizontal resource timeline — resources as rows, time as columns. Best for TV guides, venue bookings, and Gantt charts.
<Scheduler initialView="timeline" config={createSchedulerConfig({ preset: 'tv' })} ... />Multi-day view
Multiple days side-by-side as scrollable columns. On screens wider than 2000px the buffer auto-expands so edge-load zones never overlap.
<Scheduler initialView="week" bufferDays={7} ... />Domain presets
Workforce roster
import { Scheduler, createSchedulerConfig } from '@sushill/shadcn-scheduler'
const config = createSchedulerConfig({
preset: 'roster',
initialScrollToNow: true,
snapMinutes: 30,
workingHours: {
0: null, // Sunday closed
1: { from: 7, to: 23 },
2: { from: 7, to: 23 },
3: { from: 7, to: 23 },
4: { from: 7, to: 23 },
5: { from: 7, to: 23 },
6: { from: 8, to: 20 },
},
})
<Scheduler categories={departments} employees={staff} shifts={shifts}
onShiftsChange={setShifts} config={config} initialView="week" />TV / EPG guide
Channels as rows, programmes as blocks. visibleFrom: 6, visibleTo: 24 for a full broadcast day.
const config = createSchedulerConfig({
preset: 'tv',
defaultSettings: { visibleFrom: 6, visibleTo: 24 },
snapMinutes: 15,
})
<Scheduler categories={channels} employees={channelEmployees}
shifts={programmes} config={config} initialView="day" />Conference schedule
Rooms as categories, speakers as employees, sessions as blocks.
const config = createSchedulerConfig({
preset: 'conference',
defaultSettings: { visibleFrom: 8, visibleTo: 20 },
snapMinutes: 15,
})Music festival
Stages as categories, artists as employees, sets as blocks. Evening-focused hours.
const config = createSchedulerConfig({
preset: 'festival',
defaultSettings: { visibleFrom: 12, visibleTo: 24 },
snapMinutes: 15,
})Healthcare rota
Wards as categories, staff as employees. 24hr range for overnight shifts.
const config = createSchedulerConfig({
preset: 'healthcare',
defaultSettings: { visibleFrom: 0, visibleTo: 24 },
snapMinutes: 30,
})Gantt / project timeline
Teams as categories, members as employees, tasks as full-day blocks.
const config = createSchedulerConfig({
preset: 'gantt',
defaultSettings: { visibleFrom: 7, visibleTo: 18 },
snapMinutes: 60,
})Venue bookings
Rooms/spaces as categories, clients as employees, bookings as blocks.
const config = createSchedulerConfig({
preset: 'venue',
defaultSettings: { visibleFrom: 8, visibleTo: 24 },
snapMinutes: 30,
})Scale & customisation
Large roster (200+ staff)
The scheduler uses TanStack Virtual — only visible rows render. 200 employees with no DOM bloat.
For large teams use individual mode (one row per employee, categories collapse to headers):
// Switch in the Settings gear panel, or set via rowMode prop on config
const config = createSchedulerConfig({
preset: 'roster',
initialScrollToNow: true,
})
<Scheduler categories={bigCats} employees={bigStaff} shifts={shifts}
onShiftsChange={setShifts} config={config} />Performance tips:
- Use
onVisibleRangeChangeto fetch only the visible date window from your API - Individual mode (
rowMode: "individual") is cleaner at scale than category stacking
Custom labels
Override terminology to match your domain:
const config = createSchedulerConfig({
labels: {
category: 'Ward',
employee: 'Staff member',
shift: 'Rota slot',
draft: 'Pending approval',
published: 'Confirmed',
},
})Or use a domain preset which sets the right labels automatically:
createSchedulerConfig({ preset: 'healthcare' }) → Ward, Staff member, Rota slot.
Dark mode
The scheduler reads shadcn/ui CSS variables. Dark mode is automatic — no extra props. When your app switches theme via next-themes or Tailwind's dark class, the scheduler follows instantly.
Custom render slots
Override any part of the UI:
// Custom block content
<Scheduler
blockSlot={(block) => (
<div className="px-2 py-1">
<p className="text-[10px] font-bold">{block.employee}</p>
<p className="text-[9px] opacity-70">{block.startH}:00–{block.endH}:00</p>
</div>
)}
/>
// Custom sidebar rows
<Scheduler
sidebarSlot={(resource) => (
<div className="flex items-center gap-2 px-3">
{resource.avatar && <img src={resource.avatar} className="h-6 w-6 rounded-full" />}
<span className="text-xs font-medium">{resource.name}</span>
</div>
)}
/>
// Custom footer (settings area)
<Scheduler
footerSlot={({ onSettingsChange }) => (
<SchedulerSettings onSettingsChange={onSettingsChange} />
)}
/>Data prefetching
Load shifts on demand as the user scrolls to new date ranges:
const [shifts, setShifts] = useState<Block[]>([])
const handleVisibleRangeChange = async (start: Date, end: Date) => {
const newShifts = await fetchShifts({ from: start, to: end })
setShifts(prev => {
const existingIds = new Set(newShifts.map(s => s.id))
return [...prev.filter(s => !existingIds.has(s.id)), ...newShifts]
})
}
<Scheduler
shifts={shifts}
onShiftsChange={setShifts}
onVisibleRangeChange={handleVisibleRangeChange}
prefetchThreshold={0.7}
/>prefetchThreshold (0–1): how far the user scrolls before the callback fires. 0.7 = fire at 70% of scroll range, loading ahead of the edge.
Using the modular @shadcn-scheduler/* packages
The new monorepo packages let you import only what you use. All packages are tree-shakeable and have no cross-dependencies beyond their peer deps.
Compose a custom scheduler from parts
'use client'
import { useState } from 'react'
import { SchedulerProvider } from '@shadcn-scheduler/shell'
import { MonthView } from '@shadcn-scheduler/view-month'
import { ListView } from '@shadcn-scheduler/view-list'
import { createHealthcareConfig } from '@shadcn-scheduler/preset-healthcare'
import { useAuditTrail } from '@shadcn-scheduler/plugin-audit'
import type { Block } from '@shadcn-scheduler/core'
const config = createHealthcareConfig({ snapMinutes: 30 })
export default function HospitalScheduler() {
const [shifts, setShifts] = useState<Block[]>([])
const [view, setView] = useState<'month' | 'list'>('month')
const { log, onEvent } = useAuditTrail()
return (
<SchedulerProvider categories={wards} employees={staff} config={config}>
{view === 'month' && (
<MonthView date={new Date()} shifts={shifts} setShifts={setShifts} onAddShift={handleAdd} />
)}
{view === 'list' && (
<ListView shifts={shifts} setShifts={setShifts} currentDate={new Date()} view="weeklist" />
)}
</SchedulerProvider>
)
}Use core utilities server-side (no React)
// app/api/shifts/conflicts/route.ts
import { packShifts } from '@shadcn-scheduler/core'
import type { Block, Resource } from '@shadcn-scheduler/core'
export async function POST(req: Request) {
const { blocks, resources }: { blocks: Block[], resources: Resource[] } = await req.json()
const { conflicts, utilization } = packShifts(blocks, resources)
return Response.json({ conflicts, utilization })
}Export shifts without importing the full scheduler
import { exportToICS, exportToCSV } from '@shadcn-scheduler/plugin-export'
// Runs in the browser — no scheduler component needed on the page
exportToICS(shifts, 'my-schedule.ics')
exportToCSV(shifts, categories, 'my-schedule.csv')→ Full packages reference | → Try the live demo
Install only what you need
Each domain preset has its own registry entry — install just the files your use case needs.
| Preset | Command |
|---|---|
| Workforce Roster | npx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-roster.json |
| TV / EPG Guide | npx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-tv.json |
| Conference | npx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-conference.json |
| Music Festival | npx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-festival.json |
| Healthcare Rota | npx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-healthcare.json |
| Project Gantt | npx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-gantt.json |
| Venue Bookings | npx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-venue.json |
TV, Festival, and Venue use the timeline engine (~32 files). Roster, Conference, Healthcare, and Gantt use the grid engine (~49 files). The full bundle installs everything.
New in v0.4 — Phase 1 & 2 features
Recurring shifts
Add a recurrence rule to any block. The scheduler automatically expands occurrences across the visible date window — no external rrule library needed.
const shifts: Block[] = [
{
id: 'morning-standup',
categoryId: 'c1',
employeeId: 'e1',
date: '2026-03-20', // master date — first occurrence
startH: 9, endH: 10,
employee: 'Alice A.',
status: 'published',
recurrence: {
freq: 'weekly',
byDay: [1, 2, 3, 4, 5], // Mon–Fri
count: 52, // 52 occurrences
},
},
{
id: 'evening-clean',
categoryId: 'c2',
employeeId: 'e2',
date: '2026-03-20',
startH: 20, endH: 22,
employee: 'Bob B.',
status: 'draft',
recurrence: {
freq: 'daily',
interval: 2, // every other day
until: '2026-06-30', // stop date
},
},
]Supported: daily, weekly (with byDay), monthly. Stop by count or until date. Each occurrence gets a stable id (${masterId}_r${YYYY-MM-DD}) for API sync.
Shift dependencies
Draw SVG arrows between related blocks — handovers, sequential tasks, or any finish-to-start relationship.
import type { ShiftDependency } from '@sushill/shadcn-scheduler'
const dependencies: ShiftDependency[] = [
{
id: 'dep-1',
fromId: 'shift-a',
toId: 'shift-b',
type: 'finish-to-start', // solid bezier arrow
label: 'handover',
color: 'var(--primary)',
},
{
id: 'dep-2',
fromId: 'task-1',
toId: 'task-2',
type: 'start-to-start', // dashed arrow
},
]
<Scheduler
dependencies={dependencies}
onDependenciesChange={setDependencies}
{...rest}
/>Types: finish-to-start (solid), start-to-start (dashed), finish-to-finish (dashed). Custom color and label per arrow.
Employee availability
Shade unavailable hours per employee. Slots outside declared windows get a diagonal-stripe overlay so you can see at a glance when staff can't be scheduled.
import type { EmployeeAvailability } from '@sushill/shadcn-scheduler'
const availability: EmployeeAvailability[] = [
{
employeeId: 'e3',
windows: [
{ dayOfWeek: 1, startH: 9, endH: 17 }, // Mon 9–5
{ dayOfWeek: 2, startH: 9, endH: 17 }, // Tue 9–5
{ dayOfWeek: 3, startH: 9, endH: 17 },
{ dayOfWeek: 4, startH: 9, endH: 17 },
{ dayOfWeek: 5, startH: 9, endH: 17 },
// No Sat/Sun windows = unavailable on weekends
],
},
{
employeeId: 'e5',
windows: [
// Date-specific override takes precedence over dayOfWeek
{ date: '2026-03-25', startH: 10, endH: 14 },
],
},
]
<Scheduler availability={availability} {...rest} />Works in individual row mode (one row per employee). In category mode, availability is not shown since rows are shared across employees.
Markers & milestones
Vertical lines at any date+hour position. Draggable so users can adjust deadlines in-place.
import type { SchedulerMarker } from '@sushill/shadcn-scheduler'
const [markers, setMarkers] = useState<SchedulerMarker[]>([
{
id: 'deadline',
date: '2026-03-25',
hour: 9,
label: 'Sprint deadline',
color: 'var(--color-amber-500, #f59e0b)',
draggable: true,
},
{
id: 'release',
date: '2026-03-28',
// No hour = renders at day boundary (left edge of the column)
label: 'Release',
color: 'var(--destructive)',
},
])
<Scheduler
markers={markers}
onMarkersChange={setMarkers} // called when user drags a marker
{...rest}
/>Resource utilisation histogram
A bar chart panel below the grid showing scheduled hours vs capacity per department (or per employee in individual mode).
import type { HistogramConfig } from '@sushill/shadcn-scheduler'
const histogramConfig: HistogramConfig = {
capacities: [
{ resourceId: 'c1', hours: 40 }, // Front Desk: 40h/week cap
{ resourceId: 'c2', hours: 35 }, // Kitchen: 35h/week cap
],
}
<Scheduler
showHistogram
histogramHeight={130}
histogramConfig={histogramConfig}
{...rest}
/>Colour logic: green = under capacity, amber = 90–100%, red = over capacity. No capacity set = uses category colour. The histogram filters to the same date window as the grid.
Undo & redo
Full command history. Ctrl+Z undoes the last mutation, Ctrl+Y (or Ctrl+Shift+Z) redoes it. History depth: 20 steps.
No configuration needed — undo/redo is always on. The history is scoped to the <Scheduler> instance and resets on unmount.
iCal export
Export any set of blocks to a .ics file for Google Calendar / Outlook import. No external dependency.
import { exportToICS } from '@sushill/shadcn-scheduler'
// Export all shifts
exportToICS(shifts, 'my-schedule.ics')
// Export only published shifts for a date range
const published = shifts.filter(s => s.status === 'published' && s.date >= '2026-03-01')
exportToICS(published, 'march-schedule.ics')Also available: exportToCSV(shifts) and exportToPDF(container) / exportToImage(container) (require optional peer deps).