Skip to main content

Custom Views

This guide covers how to reshape one of the built-in views with a config override, and how to build a new view by subclassing ViewModel when an override isn't enough.

three day columns, each split into Design, Engineering, and Marketing resource sub-columns with events placed inside them

How views work

A view is a class - a subclass of ViewModel - registered against a string id. The views prop lists the ids that appear in the mode switcher; view picks the active one. The built-ins (day, week, month, agenda, year, resources, timeline) are pre-registered when the package loads.

Each view describes itself through three things:

  • Sections - the layout regions returned from getSections(). A section binds an x-scale, a y-scale, a render mode (bars, boxes, grid, list, year), an optional event filter, and a size. The week view has two sections (multiday bar and timeGrid); the month view has one (month).
  • Range math - rangeStart(date) aligns an arbitrary date to the period start, addRange(date, n) steps by n periods, setRange(date) writes the resulting startDate and endDate onto the instance.
  • Range label - getRangeLabel() returns the toolbar title for the current range.

The base class owns the rest: it filters events, splits them across scale boundaries, lays them out, and produces the SectionResult[] the renderer draws. You only override the parts you need to change.

There are two ways to customize a view:

  1. Override sections on a registered view. Pass a ViewConfig object in views with a sections map. The store deep-merges the override into the section of the same name before processing. No new class, no registration call.
  2. Subclass and register. Write a class that extends ViewModel (or one of the concrete subclasses), then call registerCalendarView(id, Class) so the id resolves to your class.

Use the override path when the existing view shape is right and only the numbers need changing - visible hours, snap step, resource items. Reach for a subclass when you need a different range size, a different section count, or custom label formatting.

Overriding sections on a built-in view

The views prop accepts strings or ViewConfig objects. The object form lets you relabel a view and merge partial overrides into its sections, keyed by section name:

type ViewConfig =
| string
| {
id: string;
label?: string;
sections?: Record<string, any>;
};

Only the keys you set are replaced. Other fields (step, format, ui, filters) keep their defaults. Plain objects merge recursively; arrays, functions, and primitives replace wholesale.

Tweaking the time axis

Narrow the day view to working hours and add a current-time line:

<Calendar
events={data}
view="day"
views={[
{
id: "day",
sections: {
timeGrid: {
yScale: { startHour: 9, endHour: 17 },
ui: { nowLine: true },
},
},
},
"week",
"month",
]}
/>

Section names come from getSections(): multiday and timeGrid for day/week/resources/timeline, month for the month grid. Override keys that don't match a real section name are ignored.

Defining resource columns

The resources view ships with one placeholder column. Replace it by overriding xScale.items and xScale.accessor:

<Calendar
events={data}
view="resources"
views={[
{
id: "resources",
sections: {
timeGrid: {
xScale: {
items: [
{ id: "room-a", label: "Room A" },
{ id: "room-b", label: "Room B" },
],
accessor: "roomId",
},
},
},
},
]}
/>

Each event reads event.roomId to pick a column. Events whose accessor value doesn't match an item id don't render. The timeline view is the same shape transposed - put the resources on yScale instead.

Relabeling without changing sections

Use label to override the mode-switcher text without touching the layout:

const views = [
{ id: "day", label: "Today" },
{ id: "week", label: "This week" },
"month",
];

Subclassing ViewModel

When you need new sections, a different range size, or custom label formatting, subclass the closest built-in view (WeekViewModel, MonthViewModel, etc.) and override what changes. The base pipeline (process(), toPositionStart(), toPositionEnd()) keeps working without you touching it.

The mandatory override points:

MethodReturnsPurpose
getSections()Section[]Declare the view's sections and scales
rangeStart(date)DateAlign an arbitrary date to the period start
addRange(date, n)DateStep the date forward or backward by n
getRangeLabel()stringFormat the toolbar title for the current range

Inside the class you can read this.startDate, this.endDate, this.weekStartDay, and this.fmt(pattern) for locale-aware formatting.

A work-week variant

Reuse the week layout but show only Monday through Friday by extending WeekViewModel and trimming the xScale.length:

import { WeekViewModel } from "@svar-ui/react-calendar";

class WorkWeekViewModel extends WeekViewModel {
getSections() {
const sections = super.getSections();
return sections.map(s => ({
...s,
xScale: { ...s.xScale, length: 5 },
}));
}

rangeStart(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
// Always Mon-Fri, regardless of locale's weekStart
const diff = (((d.getDay() - 1) % 7) + 7) % 7;
d.setDate(d.getDate() - diff);
return d;
}
}

addRange is inherited from WeekViewModel (steps by 7 days), which is what we want - Prev/Next still moves one week at a time.

A two-week variant

Extend the visible span to 14 days, drop the multiday bar, and write a custom range label:

import { WeekViewModel } from "@svar-ui/react-calendar";

class TwoWeeksViewModel extends WeekViewModel {
getSections() {
const [, days] = super.getSections();
return [
{
...days,
xScale: { ...days.xScale, length: 14 },
boxLayout: "overlap",
},
];
}

addRange(date: Date, n: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + n * 14);
return d;
}

getRangeLabel(): string {
const start = this.startDate;
const end = new Date(this.endDate.getTime() - 1);
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
if (start.getMonth() === end.getMonth()) {
const month = start.toLocaleDateString(undefined, { month: "long" });
return `${month} ${start.getDate()}-${end.getDate()}, ${start.getFullYear()}`;
}
return `${start.toLocaleDateString(undefined, opts)} - ${end.toLocaleDateString(undefined, opts)}, ${end.getFullYear()}`;
}
}

Two things worth flagging. addRange controls the navigation step, so changing the visible span without changing it leaves the user clicking Next and moving only one week through a two-week range. And the second section returned by super.getSections() is the time grid (days here) - index zero is the multiday bar.

Optional overrides

For deeper customization, the base class exposes a few more extension points:

MemberPurpose
render = "scrollable"Use the dedicated single-section scrollable renderer instead of the default
setRange(date)Replace the default endDate = addRange(startDate, 1) rule
process(events)Bypass the standard pipeline (agenda and year do this)
buildCells(xScale, yScale)Produce GridCell[] for grid mode sections
sortBeforeLayout(primitives, mode)Reorder primitives before the layout pass runs
mapToPrimitive(chunk, unit, secScale, axis)Customize how chunks become primitives

One more method is worth knowing once process() has run: projectEvent(event) maps a proposed event through the cached sections - filtering, segmenting, and coordinate mapping - but skips the layout pass. It returns the matching { section, mode, primitives } groups, which makes it a cheap way to place a drag preview or a ghost event without touching stored data.

Combined scales

A section's primary axis can nest two scales instead of one. Set the primary scale's type to "combined" and give it an outer and an inner scale - a date grouping unit resource columns, or the reverse. The renderer draws nested headers (Date → Resource, or Resource → Date), and every leaf gets its own secondary time scale and its own collision-layout group, so overlaps resolve column by column.

Combined scales only work as the primary axis of boxes and bars sections - the ViewModel rejects them on the secondary axis and in grid, list, and year modes.

Combined scales reach you through both customization paths - a sections override or a ViewModel subclass. Here's a three-day view that groups resource columns under each date:

import { WeekViewModel, registerCalendarView } from "@svar-ui/react-calendar";

const units = [
{ id: "design", label: "Design" },
{ id: "engineering", label: "Engineering" },
{ id: "marketing", label: "Marketing" },
];

class DateUnitsViewModel extends WeekViewModel {
render = "scrollable";

getSections() {
const timeGrid = super.getSections()[1];
return [
{
...timeGrid,
filter: () => true,
xScale: {
type: "combined",
outer: { type: "date", length: 3, format: "weekScaleFormat" },
inner: {
type: "unit",
items: units,
accessor: "unit",
},
},
},
];
}

addRange(date: Date, n: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + n * 3);
return d;
}
}

registerCalendarView("date-units", DateUnitsViewModel);

Swap outer and inner to group dates under each resource instead. A nested unit scale can also carry multi-unit events - an event assigned to several units renders in every matching leaf. That is a separate scale option; only one nested scale in a combined pair may use it, because each render id carries a single source unit.

Registering and using the view

A class becomes a view when you give it an id through registerCalendarView. The id is what views and view reference:

import { registerCalendarView } from "@svar-ui/react-calendar";

registerCalendarView("workweek", WorkWeekViewModel);
registerCalendarView("2weeks", TwoWeeksViewModel);

Registration is global. Calling it twice with the same id replaces the previous class for every future Calendar instance - handy for swapping a built-in view, but be deliberate about it.

Then list the new ids in views (with optional labels) and pick one as the initial view:

import { Calendar } from "@svar-ui/react-calendar";

const views = [
{ id: "week", label: "Week" },
{ id: "workweek", label: "Work Week" },
{ id: "2weeks", label: "2 Weeks" },
];

<Calendar events={events} date={date} view="workweek" views={views} />

Call registerCalendarView once at module load, before any Calendar mounts. The store reads the registry when it instantiates a view, so a late registration won't reach instances that already exist.