Advanced

Custom Views & Layouts

The five views are made from three pieces: a range, a layout and a component. Register your own combination and it gets a tab, the arrows, the keyboard and drag and drop like the built-in ones.

Registered by Name

views and layouts are merged with the built-in ones by name. A new name is added after the built-in tabs; a built-in name is replaced and keeps its place. Naming a view in view that nobody registered throws when the scheduler renders.

<!-- Registered per scheduler. Same name as a built-in: yours replaces it and
     keeps its place in the switcher. A view named in the view prop that is not
     registered throws when it renders. -->
<Scheduler
  bind:events
  views={[workWeek, board]}          // ViewDefinition[]
  layouts={[boardLayout]}            // LayoutStrategy[]
  view="workweek"
/>

The built-in views

NameLayoutStepComponent
daytime-grid1 dayDayView
weektime-grid7 days, or daysWeekView
monthmonth-grid, 7 columns1 monthMonthView
yearlist1 yearYearView
agendalist1 monthAgendaView

A Work Week

The smallest useful view: the week component with a Monday to Friday range. The all-day row, the current time line, the keyboard and every gesture come with the component and the time-grid layout.

Oct 19 – 23, 2026

GMT+7
Mon 19
Tue 20
Wed 21
Thu 22
Fri 23
All day
Lunch break
import { getDayOfWeek } from '@internationalized/date';
import { WeekView, type ViewDefinition } from '@sv5ui/scheduler';

const monday = (anchor: ZonedDateTime) =>
  anchor.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract({ days: getDayOfWeek(anchor, 'en-GB') });

// The week component with a five day range. Everything else, the all-day row,
// the current time line, drag and drop, comes with the component and the layout.
export const workWeek: ViewDefinition = {
  name: 'workweek',
  label: 'Work week',
  layout: 'time-grid',
  range: (anchor) => ({ start: monday(anchor), end: monday(anchor).add({ days: 5 }) }),
  step: (anchor, direction) => anchor.add({ weeks: direction }),
  component: WeekView
};
interface ViewDefinition<T> {
  name: string;                                           // the value of the view prop, the tab's key
  label?: string;                                         // the tab's text; built-in names fall back to labels
  range: (anchor, context) => DateRange;                  // what is on screen; end exclusive
  step: (anchor, direction: 1 | -1, context) => ZonedDateTime;   // the arrows and Page keys
  layout: string;                                         // 'time-grid', 'month-grid', 'list' or one of yours
  columnsPerRow?: number;                                 // for month-grid layouts: 7 for weeks
  title?: (anchor, range, context) => string;             // the toolbar title; range is after hiddenDays
  shortTitle?: (anchor, range, context) => string;        // used when compact
  component: Component<ViewProps<T>>;                     // what draws it
}

Other Ranges From Built-in Components

Any range works with any component whose layout fits it. Three more: a rolling week from today, two weeks as a month grid, a quarter as a list with its own title. All three are registered on the demo; switch between them with the tabs.

Oct 19 – Nov 1, 2026

Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
Sunday
19
20
21
22
23
24
25
26
27
28
29
30
31
1
// Two weeks as a month grid: the month component, seven columns, fourteen days
export const twoWeeks: ViewDefinition = {
  name: 'fortnight',
  label: 'Two weeks',
  layout: 'month-grid',
  columnsPerRow: 7,
  range: (anchor) => ({ start: monday(anchor), end: monday(anchor).add({ days: 14 }) }),
  step: (anchor, direction) => anchor.add({ weeks: 2 * direction }),
  component: MonthView
};
// A rolling week that starts today, in the week component
export const nextSeven: ViewDefinition = {
  name: 'next7',
  label: 'Next 7 days',
  layout: 'time-grid',
  range: (anchor) => {
    const start = anchor.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
    return { start, end: start.add({ days: 7 }) };
  },
  step: (anchor, direction) => anchor.add({ days: 7 * direction }),
  component: WeekView
};
// A quarter as a list, with its own title
export const quarter: ViewDefinition = {
  name: 'quarter',
  label: 'Quarter',
  layout: 'list',
  range: (anchor) => {
    const start = anchor.set({ month: anchor.month - ((anchor.month - 1) % 3), day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
    return { start, end: start.add({ months: 3 }) };
  },
  step: (anchor, direction) => anchor.add({ months: 3 * direction }),
  title: (anchor) => `Q${Math.ceil(anchor.month / 3)} ${anchor.year}`,
  component: AgendaView
};

Titles

title and shortTitle receive the anchor, the visible range and the context. The range is what is on screen after hiddenDays, so a title built from it says "Sep 14 – 18" when the weekend is hidden. Without them the toolbar formats the range the way the week view does.

// range is the visible range: with hiddenDays hiding the weekend, a week's range
// ends on Friday night. context has locale and timeZone for Intl.
title: (anchor, range, context) =>
  new Intl.DateTimeFormat(context.locale, { month: 'short', day: 'numeric', timeZone: context.timeZone })
    .formatRange(range.start.toDate(), range.end.subtract({ days: 1 }).toDate()),
shortTitle: (anchor, range, context) =>
  new Intl.DateTimeFormat(context.locale, { month: 'short', timeZone: context.timeZone }).format(anchor.toDate())

// Without title the toolbar formats the range itself, like the built-in week does.

Replacing a Built-in View

Register the same name and yours wins. The demo's agenda covers two weeks from the anchor instead of a month; its tab is still in the agenda's place.

Oct 20 – Nov 2, 2026

  • Tuesday, October 20

    Today 3 events · 3h 15m
  • Wednesday, October 21

    4 events · 3h 15m
  • Thursday, October 22

    3 events · 1h 45m
  • Friday, October 23

    4 events · 4h 45m
  • Saturday, October 24

    1 event · 1h
  • Monday, October 26

    2 events · 1h 15m
  • Tuesday, October 27

    1 event · 15m
  • Wednesday, October 28

    2 events · 15m
  • Thursday, October 29

    1 event · 15m
  • Friday, October 30

    1 event · 15m
  • Monday, November 2

    1 event · 15m
// Same name as a built-in: the agenda now shows two weeks from the anchor and
// keeps its tab where it was
export const agendaFortnight: ViewDefinition = {
  name: 'agenda',
  layout: 'list',
  range: (anchor) => {
    const start = anchor.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
    return { start, end: start.add({ days: 14 }) };
  },
  step: (anchor, direction) => anchor.add({ days: 14 * direction }),
  component: AgendaView
};

Writing a View Component

A view is a component that takes ViewProps<T> and nothing else: no context, no store, so it can be tested on its own. The board below is a complete one in about a hundred lines: a column per weekday, a card per event, drag a card to another day to move it. It comes with its own layout, because the built-in three do not produce one card per event per column. Resizing is switched off since a board has no length axis; see Interaction Plugins.

Oct 19 – 23

19 Mon 2
20 Tue 3
21 Wed 4
22 Thu 3
23 Fri 3
<script lang="ts">
  import { Scheduler } from '@sv5ui/scheduler';
  import BoardView from '$lib/board/BoardView.svelte';
  import { boardLayout, boardView } from '$lib/board/board';

  // The board has no length axis, so the resize plugin is replaced by one that does nothing
  const noResize = { name: 'resize', attach: () => () => {} };
</script>

<Scheduler
  bind:events
  views={[boardView(BoardView)]}
  layouts={[boardLayout]}
  interactions={[noResize]}
  view="board"
  creatable={false}
/>

Three files. The view component, src/lib/board/BoardView.svelte:

src/lib/board/BoardView.svelte
<script lang="ts" generics="T">
	import { Button } from 'sv5ui';
	import type { SpanPosition, ViewProps } from '@sv5ui/scheduler';

	// A view is a component that takes ViewProps and nothing else: no context,
	// no store. Everything it needs to draw and to stay interactive arrives here.
	let {
		days,
		positioned,
		preview,
		interactions,
		focus,
		selectedEventId,
		loading,
		scheduler,
		onSelectEvent,
		onOpenEvent,
		onDeleteEvent
	}: ViewProps<T> = $props();

	const spans = $derived(
		positioned.filter((position): position is SpanPosition<T> => position.kind === 'span')
	);
	const byDay = $derived(
		days.map((_, index) =>
			spans.filter((position) => position.startColumn === index).toSorted((a, b) => a.lane - b.lane)
		)
	);
	// The ghost: the layout's positions for the event being dragged, in the same shape
	const ghosts = $derived(
		(preview?.positioned ?? []).filter(
			(position): position is SpanPosition<T> => position.kind === 'span'
		)
	);

	const dayFormat = $derived(
		new Intl.DateTimeFormat(scheduler.locale, {
			weekday: 'short',
			day: 'numeric',
			timeZone: scheduler.timeZone
		})
	);
	const timeFormat = $derived(
		new Intl.DateTimeFormat(scheduler.locale, {
			hour: 'numeric',
			minute: '2-digit',
			hour12: scheduler.hour12,
			timeZone: scheduler.timeZone
		})
	);
	const isToday = (day: (typeof days)[number]) =>
		day.year === scheduler.now.year &&
		day.month === scheduler.now.month &&
		day.day === scheduler.now.day;
</script>

<!-- interactions.grid makes this the one tab stop: it sets role, tabindex and the
     name, and routes the keyboard and the built-in gestures. -->
<div class="flex h-full min-h-0 overflow-x-auto bg-surface" {@attach interactions.grid}>
	{#each days as day, index (day.toString())}
		<!-- data-sch-day-index lets hitTest find the column. data-sch-all-day says a drop
		     here means "this day"; data-sch-day-cell says keep the event's clock time. -->
		<section
			class={[
				'flex min-w-44 flex-1 flex-col border-e border-outline-variant/60 last:border-e-0',
				focus?.dayIndex === index && 'bg-primary/5 ring-2 ring-primary ring-inset'
			]}
			data-sch-day-index={index}
			data-sch-all-day
			data-sch-day-cell
			aria-label={dayFormat.format(day.toDate())}
		>
			<header
				class={[
					'border-b border-outline-variant/60 px-3 py-2 text-xs font-semibold',
					isToday(day) ? 'text-primary' : 'text-on-surface/70'
				]}
				aria-current={isToday(day) ? 'date' : undefined}
			>
				{dayFormat.format(day.toDate())}
				<span class="ms-1 font-normal text-on-surface/50">{byDay[index].length}</span>
			</header>
			<ul class="flex-1 space-y-2 overflow-y-auto p-2">
				{#each byDay[index] as position (position.event.id)}
					{@const selected = selectedEventId === position.event.id}
					{@const dragging = preview?.kind === 'move' && preview.event.id === position.event.id}
					<!-- data-sch-event is what the rollback animation looks for;
					     interactions.event(position) is what makes the card draggable -->
					<li
						data-sch-event={position.event.id}
						class={dragging ? 'opacity-40' : ''}
						{@attach interactions.event(position)}
					>
						<button
							type="button"
							aria-pressed={selected}
							class={[
								'w-full cursor-grab rounded-lg border p-2 text-start shadow-sm transition-colors active:cursor-grabbing',
								selected
									? 'border-primary bg-primary/10 ring-2 ring-primary'
									: 'border-outline-variant/60 bg-surface-container-lowest hover:border-primary/50'
							]}
							onclick={() => onSelectEvent(selected ? null : position.event.id)}
						>
							<p class="truncate text-sm font-medium text-on-surface">{position.event.title}</p>
							<p class="text-xs text-on-surface/60">
								{position.event.allDay
									? scheduler.labels.allDay
									: `${timeFormat.format(position.event.start.toDate())} to ${timeFormat.format(position.event.end.toDate())}`}
							</p>
						</button>
						{#if selected}
							<div class="mt-1 flex gap-1">
								{#if onOpenEvent}
									<Button
										label="Details"
										size="xs"
										variant="soft"
										onclick={() => onOpenEvent(position.event.id)}
									/>
								{/if}
								{#if scheduler.editable && position.event.editable !== false && !position.event.seriesId}
									<Button
										label={scheduler.labels.deleteEvent}
										size="xs"
										variant="ghost"
										color="error"
										onclick={() => onDeleteEvent(position.event.id)}
									/>
								{/if}
							</div>
						{/if}
					</li>
				{/each}
				{#each ghosts.filter((g) => g.startColumn === index) as ghost (ghost.event.id)}
					<li
						class="rounded-lg border-2 border-dashed border-primary/60 bg-primary/5 p-2"
						aria-hidden="true"
					>
						<p class="truncate text-sm font-medium text-primary">{ghost.event.title}</p>
					</li>
				{/each}
				{#if byDay[index].length === 0 && !loading && ghosts.length === 0}
					<li class="px-1 py-2 text-xs text-on-surface/40">Nothing planned</li>
				{/if}
			</ul>
		</section>
	{/each}
</div>

The layout and the definition, src/lib/board/board.ts. The definition takes the component as an argument so the module has no Svelte import:

src/lib/board/board.ts
/**
 * One column per day, one card per event, stacked in start order. Every
 * position is a span one column wide, so the board can lay them out as a list.
 * An event that starts before the range lands in the first column.
 */
export const boardLayout: LayoutStrategy = {
	name: 'board',
	layout<T>(events: SchedulerEvent<T>[], _range: DateRange, { days }: LayoutContext) {
		const lanes = new Array<number>(days.length).fill(0);
		return events
			.toSorted((a, b) => a.start.compare(b.start))
			.map((event) => {
				const index = Math.max(
					0,
					days.findIndex((day) => sameDay(day, event.start))
				);
				const position: SpanPosition<T> = {
					kind: 'span',
					event,
					row: 0,
					lane: lanes[index]++,
					startColumn: index,
					endColumn: index + 1,
					continuesBefore: false,
					continuesAfter: false
				};
				return position;
			});
	}
};
src/lib/board/board.ts
/** Monday of the week that holds the anchor. */
export function monday(anchor: ZonedDateTime): ZonedDateTime {
	return anchor
		.set({ hour: 0, minute: 0, second: 0, millisecond: 0 })
		.subtract({ days: getDayOfWeek(anchor, 'en-GB') });
}

/** A five-day board, Monday to Friday, stepping by weeks. */
export function boardView(component: Component<ViewProps<unknown>>): ViewDefinition {
	return {
		name: 'board',
		label: 'Board',
		layout: 'board',
		range: (anchor) => {
			const start = monday(anchor);
			return { start, end: start.add({ days: 5 }) };
		},
		step: (anchor, direction) => anchor.add({ weeks: direction }),
		title: (_anchor, range, context) =>
			new Intl.DateTimeFormat(context.locale, {
				month: 'short',
				day: 'numeric',
				timeZone: context.timeZone
			}).formatRange(range.start.toDate(), range.end.subtract({ days: 1 }).toDate()),
		component
	};
}

What the scheduler needs from a view, so its gestures, keyboard and rollback keep working:

// What a view must do for the scheduler's gestures, keyboard and rollback to work
{@attach interactions.grid}                 // on the element that takes pointer and keyboard; it gets role, tabindex and a name
data-sch-day-index={i}                      // on each drop target, so hitTest can find it
data-sch-all-day                            // the target means "this day", not a time
data-sch-day-cell                           // with all-day: a timed event keeps its clock time when dropped here (the month does this)
{@attach interactions.event(position)}      // on each event element, for move and resize
data-sch-event={event.id}                   // on the same element, so a failed save can animate the event back
preview.positioned                          // draw it as the ghost while a gesture is in progress
loading                                     // hold back your empty state while it is true
onSelectEvent(id) / onDeleteEvent(id) / onOpenEvent?.(id)   // the store's actions; never touch events directly
interface ViewProps<T> {
  view: string;                                  // the definition's name
  anchor: ZonedDateTime;                         // the date prop
  range: DateRange;
  days: ZonedDateTime[];                         // midnights of the visible days, after hiddenDays
  columnsPerRow: number;
  events: SchedulerEvent<T>[];                   // in range, after the filters, series expanded
  positioned: PositionedEvent<T>[];              // the layout's output for those events
  scale: TimeScale;                              // wall clock to pixels
  scheduler: SchedulerContext;                   // timeZone, locale, labels, editable, now...
  snippets: ViewSnippets<T>;                     // event, cell, header, empty, detail, if the user gave them
  focus: GridFocus | null;                       // the keyboard focus: dayIndex, minutes or null
  interactions: {
    grid: Attachment<HTMLElement>;               // put on the element that takes pointer and keyboard
    event: (position) => Attachment<HTMLElement>;   // put on each event element
  };
  preview: ViewPreview<T> | null;                // the drag ghost: kind, event, positioned
  selectedEventId: string | null;
  loading: boolean;                              // a source is fetching this range
  onSelectEvent: (id: string | null) => void;
  onOpenEvent?: (id: string) => void;            // opens eventPanel; present with detail="popover" and an eventPanel
  onDeleteEvent: (id: string) => void;           // through the pipeline, like the Delete key
  detailPopover: boolean;                        // detail is "popover"; the built-in popover is not exported, so draw your own or use onOpenEvent
  navigate: (date: ZonedDateTime, view?: string) => void;
}

Layout Strategies

A layout turns the events of a range into positions, and nothing else: it is a pure function of its inputs. Register one under a built-in name to change how a built-in view arranges events. The demo replaces time-grid with a cascade: overlapping events step to the right and keep most of the width instead of sharing the column.

October 19, 2026

Monday

October 19, 2026

GMT+7
/**
 * Overlapping events cascade instead of sharing the column: each one starts a
 * little further right and keeps most of the width, like stacked cards. Timed
 * events only; a complete strategy would also emit span positions for all-day
 * and multi-day events, which is what the built-in time-grid does.
 */
export const cascadeLayout: LayoutStrategy = {
	name: 'time-grid',
	layout<T>(events: SchedulerEvent<T>[], _range: DateRange, { days, scale }: LayoutContext) {
		const positions: TimePosition<T>[] = [];
		days.forEach((day, dayIndex) => {
			const today = events
				.filter((event) => !event.allDay && sameDay(event.start, day))
				.toSorted((a, b) => a.start.compare(b.start));
			const active: SchedulerEvent<T>[] = [];
			for (const event of today) {
				// Drop what has ended before this one starts; the depth left is the column
				for (let i = active.length - 1; i >= 0; i--)
					if (active[i].end.compare(event.start) <= 0) active.splice(i, 1);
				const column = active.length;
				active.push(event);
				const top = scale.toPixel(event.start);
				const bottom = sameDay(event.end, day) ? scale.toPixel(event.end) : scale.dayHeight;
				const left = Math.min(column * 0.15, 0.6);
				positions.push({
					kind: 'time',
					event,
					dayIndex,
					segmentStart: event.start,
					segmentEnd: event.end,
					top,
					height: Math.max(bottom - top, scale.slotHeight / 2),
					left,
					width: 1 - left,
					column,
					columns: column + 1
				});
			}
		});
		return positions;
	}
};
interface LayoutStrategy {
  name: string;
  layout<T>(events: SchedulerEvent<T>[], range: DateRange, context: LayoutContext): PositionedEvent<T>[];
}

interface LayoutContext {
  scale: TimeScale;              // toPixel and toDate for the current slot settings
  days: ZonedDateTime[];         // the visible days
  columnsPerRow: number;         // from the view definition, or the day count
  scheduler: SchedulerContext;
}

// Two shapes of output
interface TimePosition<T> {      // a block in a day column
  kind: 'time'; event; dayIndex; segmentStart; segmentEnd;
  top: number; height: number;   // pixels, from scale
  left: number; width: number;   // fractions of the column, 0 to 1
  column: number; columns: number;
}
interface SpanPosition<T> {      // a bar across columns
  kind: 'span'; event; row; lane;
  startColumn: number; endColumn: number;   // end exclusive
  continuesBefore: boolean; continuesAfter: boolean;
}

// A strategy is pure: same input, same output, no DOM. The month grid clips lanes
// to the cell height itself and folds the rest into +N; a layout only assigns lanes.

TimeScale

The ruler of the time grid. It maps a clock time to a pixel and back, on the wall clock, so 9:00 is the same height on a day the clocks change. Layouts and views receive the scheduler's scale; createTimeScale builds one for something drawn outside the grid.

import { createTimeScale } from '@sv5ui/scheduler';

// The same scale the grid uses: wall clock in, pixels out, and back
const scale = createTimeScale({ slotMinutes: 30, slotHeight: 24, startHour: 0, endHour: 24 });
scale.dayHeight;                        // 1152
scale.toPixel(date);                    // px from the top of the day, by the clock: 9:00 is 432 on every day, DST or not
scale.toDate(432, dayStart);            // 9:00 of that day, snapped to the slot
scale.toDate(1152, dayStart);           // midnight of the next day

// Inside a layout or a view you get the scheduler's scale as context.scale; build
// your own only for something drawn outside the grid, like a ruler or a minimap.

SchedulerContext

The settings every view, layout, plugin and snippet can read. It is one object with getters, so reading now inside a template re-renders when the minute changes.

interface SchedulerContext {
  timeZone: TimeZoneId;
  locale: string;
  weekStartsOn: WeekDay;
  hour12?: boolean;
  businessHours?: BusinessHours;
  holidays: Holiday[];
  calendars: SchedulerCalendar[];
  dayCount?: number;              // the days prop
  weekNumbers: boolean;
  hiddenDays: WeekDay[];
  editable: boolean;
  direction: 'ltr' | 'rtl';
  compact: boolean;               // below compactBreakpoint
  labels: SchedulerLabels;
  now: ZonedDateTime;             // updated every minute, in timeZone
}

Props

The registration props, and the ones the demos combine them with.

PropTypeDefault
viewsViewDefinition<T>[][]
layoutsLayoutStrategy[][]
interactionsInteractionPlugin<T>[][]
viewstring'week'
creatablebooleantrue
eventPanelSnippet<[EventPanelSnippetProps<T>]>-

Snippets

What the board demo's eventPanel receives; the panel opens from a custom view through onOpenEvent.

TypeFields
EventPanelSnippetPropsevent, close, remove, update, deletable