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
| Name | Layout | Step | Component |
|---|---|---|---|
day | time-grid | 1 day | DayView |
week | time-grid | 7 days, or days | WeekView |
month | month-grid, 7 columns | 1 month | MonthView |
year | list | 1 year | YearView |
agenda | list | 1 month | AgendaView |
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
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
// 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 15mWednesday, October 21
4 events · 3h 15mThursday, October 22
3 events · 1h 45mFriday, October 23
4 events · 4h 45mSaturday, October 24
1 event · 1hMonday, October 26
2 events · 1h 15mTuesday, October 27
1 event · 15mWednesday, October 28
2 events · 15mThursday, October 29
1 event · 15mFriday, October 30
1 event · 15mMonday, 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
<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:
<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:
/**
* 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;
});
}
};/** 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 directlyinterface 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
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.
| Prop | Type | Default |
|---|---|---|
views | ViewDefinition<T>[] | [] |
layouts | LayoutStrategy[] | [] |
interactions | InteractionPlugin<T>[] | [] |
view | string | 'week' |
creatable | boolean | true |
eventPanel | Snippet<[EventPanelSnippetProps<T>]> | - |
Snippets
What the board demo's eventPanel receives; the panel opens from a custom view through onOpenEvent.
| Type | Fields |
|---|---|
EventPanelSnippetProps | event, close, remove, update, deletable |