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.

→ Try it live

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.

ActionBehaviour
Copy shiftCopies to clipboard — original stays on grid
Cut shiftCopies to clipboard AND removes from grid immediately
PasteClick 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.52.0.

const [zoom, setZoom] = useState(1)

<Scheduler zoom={zoom} setZoom={setZoom} ... />
ValueEffect
0.5Very compact — see more time
1.0Default
1.5Detailed — 30min slots clearly visible
2.0Maximum 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" />

→ Live roster demo


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" />

→ Live TV demo


Conference schedule

Rooms as categories, speakers as employees, sessions as blocks.

const config = createSchedulerConfig({
  preset: 'conference',
  defaultSettings: { visibleFrom: 8, visibleTo: 20 },
  snapMinutes: 15,
})

→ Live conference demo


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,
})

→ Live festival demo


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,
})

→ Live healthcare demo


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,
})

→ Live Gantt demo


Venue bookings

Rooms/spaces as categories, clients as employees, bookings as blocks.

const config = createSchedulerConfig({
  preset: 'venue',
  defaultSettings: { visibleFrom: 8, visibleTo: 24 },
  snapMinutes: 30,
})

→ Live venue demo


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 onVisibleRangeChange to 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.

PresetCommand
Workforce Rosternpx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-roster.json
TV / EPG Guidenpx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-tv.json
Conferencenpx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-conference.json
Music Festivalnpx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-festival.json
Healthcare Rotanpx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-healthcare.json
Project Ganttnpx shadcn@latest add https://shadcn-scheduler.vercel.app/r/scheduler-gantt.json
Venue Bookingsnpx 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.

→ Full installation guide


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).