Moltenship Time
Views

Scrolling views

Week, day and month views that scroll without end, swiping a page at a time.

Live, in your time zone. Scroll or swipe through the days, and drag to move, resize or create events.

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:

calendar.tsx
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

ViewShowsA swipe movesPrevious and NextToday
scrolling-weekThe anchor's week, from weekRulesA weekA weekToday's week
scrolling-3-dayThree days from the anchor dateThree daysThree daysToday in the first column
scrolling-dayThe anchor dateA dayA dayToday
scrolling-monthSix weeks from the week of the 1stA monthA monthToday's month
scrolling-agendaFour weeks from the anchor dateFree, any dayFour weeksToday 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.

Live, in your time zone. Scroll or swipe through the days, and drag to move, resize or create events.

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:

scrolling-calendar.tsx
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>
  );
}
FieldContents
viewThe active view's name
rangeThe visible dates
instantRangerange as instants in the calendar's time zone
loadRangeThe dates to have events for: range and a page either side
loadInstantRangeloadRange 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.

Edit on GitHub

Last updated on

On this page