Scrolling views
Week, day and month views that scroll without end, swiping a page at a time.
Try them in the live demo: turn on "Scroll day, week and month" in the settings.
The week and day and month views move a page at a time. The scrolling versions follow your finger, as Apple's Calendar does on iPad, and swipe a page at a time: the week and day views sideways, so a week comes to rest on the next week and a day on the next day, and the month up and down, a month at a time. They're separate views, so the paged ones keep working as they are, and you can register both. The scrolling agenda scrolls a list of days the same way, comes to rest on any day, and jumps to a date at once.
Add them
Copy registry/scrolling-time-grid-view and registry/scrolling-month-view into your app, along
with the items they build on (registry/time-grid-view and registry/month-view), the theme, and
registry/calendar, as described in UI source. Then
register the views:
import {
createCalendar,
defineCalendarView,
iteratePlainDates,
scrollingTimeGridStrategy,
type CalendarVisibleRange,
} from "@moltenship/calendar";
import { useCallback, useMemo, useRef, useState } from "react";
import { createScrollingMonthView } from "@/components/calendar/scrolling-month-view/scrolling-month-view";
import {
createScrollingDayView,
createScrollingWeekView,
ScrollingTimeGridView,
} from "@/components/calendar/scrolling-time-grid-view/scrolling-time-grid-view";
import type { TimeGridViewOptions } from "@/components/calendar/time-grid-view/time-grid-view";
import { eventAdapter, weekRules, type Meeting } from "./quick-start";
const getEventColor = (meeting: Meeting) => meeting.color;
// Any number of days, swiping that many at a time: the strategy places them, and the week's
// renderer draws them.
const scrollingThreeDayView = defineCalendarView({
defaultOptions: { dayView: "scrolling-day", getEventColor },
name: "scrolling-3-day",
strategy: scrollingTimeGridStrategy<TimeGridViewOptions<Meeting>, Meeting>({
count: 3,
kind: "days",
}),
Renderer: ScrollingTimeGridView<Meeting>,
});
export const Calendar = createCalendar<Meeting>()({
eventAdapter,
views: [
createScrollingWeekView<Meeting>({ dayView: "scrolling-day", getEventColor }),
scrollingThreeDayView,
createScrollingDayView<Meeting>({ getEventColor }),
createScrollingMonthView<Meeting>({ dayView: "scrolling-day", getEventColor }),
],
});The views are named scrolling-week, scrolling-day, and scrolling-month, and take the same
options as their paged versions. dayView names the view a date opens; it defaults to "day" in
the month. For another number of days, define a view with
scrollingTimeGridStrategy({ count, kind: "days" }) and ScrollingTimeGridView, as
scrolling-3-day does above; it swipes that many days at a time.
How they move
| View | Shows | A swipe moves | Previous and Next | Today |
|---|---|---|---|---|
scrolling-week | The anchor's week, from weekRules | A week | A week | Today's week |
scrolling-3-day | Three days from the anchor date | Three days | Three days | Today in the first column |
scrolling-day | The anchor date | A day | A day | Today |
scrolling-month | Six weeks from the week of the 1st | A month | A month | Today's month |
scrolling-agenda | Four weeks from the anchor date | Free, any day | Four weeks | Today on top |
The anchor date is the scroll position. A swipe follows your finger and comes to rest on the
next page, or back on this one if it went less than halfway; a fling moves one page. When a
scroll comes to rest, the view sets anchorDate to what it shows, so Calendar.Title, onAnchorDateChange, and a URL that stores the date follow the
scroll. During a long swipe the anchor follows every half page. When the anchor changes any other
way, from Today, Next, or your own setAnchorDate, the view scrolls there: smoothly over a page or
two, and at once from further away, from the keyboard, or when the system asks for reduced motion.
With a controlled anchorDate, the view stays where it was scrolled until your state takes the
latest date onAnchorDateChange sent, even a few renders later; any other anchorDate moves the
view to it. If you defer the update, commit the latest date rather than each one in turn.
The month shows the anchor's month as the paged month does: six weeks from the one that holds its first day, which every month fits, with the days of other months dimmed. A swipe comes to rest on the next month with its first week on top, four or five weeks on, so the distance varies.
Loading events as you scroll
Each scrolling view renders a page of days or weeks on either side of what it shows, with their
events, so a swipe never reveals an empty page. Its strategy reports those dates as its
load range, and onVisibleRangeChange on Root hands them to you: after the first render, and
whenever the view, its dates, or the time zone change, but not when only events do.
Fetch events for loadRange and pass them in events. The next page is then loaded before it
scrolls into view:
const timeZone = "Europe/Berlin";
/** Fetches meetings for the dates the view needs, each date once, and keeps what arrived. */
export function ScrollingCalendar({
loadMeetings,
}: {
readonly loadMeetings: (from: Temporal.Instant, to: Temporal.Instant) => Promise<Meeting[]>;
}) {
const [loaded, setLoaded] = useState<ReadonlyMap<string, Meeting>>(new Map());
const requested = useRef(new Set<string>());
const meetings = useMemo(() => [...loaded.values()], [loaded]);
const load = useCallback(
({ loadRange }: CalendarVisibleRange) => {
const missing = [...iteratePlainDates(loadRange)].filter(
(date) => !requested.current.has(date.toString()),
);
if (missing.length === 0) return;
for (const date of missing) requested.current.add(date.toString());
const from = missing[0]!.toZonedDateTime(timeZone).toInstant();
const to = missing.at(-1)!.add({ days: 1 }).toZonedDateTime(timeZone).toInstant();
void loadMeetings(from, to).then((arrived) =>
setLoaded((current) => new Map([...current, ...arrived.map((m) => [m.id, m] as const)])),
);
},
[loadMeetings],
);
return (
<Calendar.Root
events={meetings}
defaultAnchorDate={Temporal.Now.plainDateISO(timeZone)}
onVisibleRangeChange={load}
timeZone={timeZone}
locale="en-US"
weekRules={weekRules}
>
<Calendar.ActiveView />
</Calendar.Root>
);
}| Field | Contents |
|---|---|
view | The active view's name |
range | The visible dates |
instantRange | range as instants in the calendar's time zone |
loadRange | The dates to have events for: range and a page either side |
loadInstantRange | loadRange as instants, ready to send to an API |
Paged views report too: their loadRange is the visible range. Time still never fetches or keeps
events itself; keep what you've loaded in your own state.
Keyboard
The keys work as in the week and day and month views, over the dates on screen. Moving past the last visible day, or pressing Page Down, scrolls a whole page. Days and weeks rendered around the visible ones are hidden from screen readers, so the grid reads as one week, one day, or six weeks.
Dragging
Drag, resize, and create work as in the paged views. Dragging toward an edge keeps scrolling into the days or weeks beyond, rendering them as it goes. The week and day views stop snapping while a gesture is under way, and come to rest on the nearest page when you let go.
How it's built
useScrollingAxis in registry/calendar/scrolling-axis.tsx places days, or weeks, on an axis
thousands of items long, and the view renders only the items near its load range. The header,
all-day row, and hour gutter are sticky in the same scrolling element, so they never lag behind the
days. Scroll positions are never React state: the view renders again only when the anchor moves.
Server markup puts the visible dates at the start of the axis, so the page shows them before
scripts run. Once laid out, the axis extends before them and the scroll position follows, before
the browser paints. In right-to-left layouts, pass direction="rtl" to Root.
Last updated on