Agenda
A chronological list of upcoming events, grouped by day, and a version that scrolls through months or years.
Try it in the live demo: pick Agenda in the view switcher.
createAgendaView lists the events in the coming weeks, one group per day. Days without events are
left out. Only the days in view render, so a long range, such as weeks: 104, stays fast. For a
list that scrolls without end and loads events as you go, use the
scrolling agenda.
Add it
Copy registry/list-view into your app, along with the theme and registry/calendar, as described
in UI source. Then register the view:
import { createAgendaView } from "@/components/calendar/list-view/agenda-view";
export const Calendar = createCalendar<Meeting>()({
eventAdapter,
views: [createAgendaView<Meeting>({ weeks: 2, getEventColor: (meeting) => meeting.color })],
});The view is named agenda. It renders with @tanstack/react-virtual, which the registry item
installs.
Scrolling agenda
createScrollingAgendaView from registry/scrolling-list-view is the agenda as a
scrolling view: one list that scrolls through months or years, with the
anchor date on top. This preview holds a year of events either way of today, a few thousand of
them, and fetches them from a pretend server as you scroll. Try it in the demo with "Scroll day,
week, month and agenda" on in the settings.
import { createScrollingAgendaView } from "@/components/calendar/scrolling-list-view/scrolling-agenda-view";
views: [createScrollingAgendaView<Meeting>({ dayView: "scrolling-day", getEventColor })];| View | Shows | Loads | Previous and Next | Today |
|---|---|---|---|---|
scrolling-agenda | weeks from the anchor date (4) | A page either side | weeks | Today on top |
- The anchor date is the day on top. The range starts at the anchor date rather than its week,
since the list comes to rest on any day. When a scroll comes to rest, the anchor date moves to the
day on top, so
Calendar.Title,onAnchorDateChange, and a URL that stores the date follow it. On a long scroll it moves every half page on the way, so the next weeks load before they scroll into view. - Today, Previous, Next, and any other anchor date jump the list to put that date on top, or
the next day with events, at once, as Google Calendar's agenda does, rather than gliding there as
the other scrolling views do over a page or two. The list holds that date on top while its events
load, until you scroll. With a controlled
anchorDate, the list stays where it was scrolled until your state takes the latest dateonAnchorDateChangesent, as in the other scrolling views. - Only the days in view render. Each is measured once it renders, as its events wrap, and days that load above or below the ones on screen don't move them.
- The end of the list follows the last loaded day. It's as tall as the view, so any day can
scroll to the top, and says "Loading events…" while
loadingis set, or that nothing more is scheduled. Scrolling on past either end of the loaded days moves a page that way, once per gesture, so the list crosses weeks without events. Weeks shown without events get the empty state, at the end of the list or among the days after them.
Loading events as you scroll
The scrolling agenda's load range is a page of weeks either way of the ones it shows. As with the
other scrolling views, onVisibleRangeChange on Root reports it after the first render and
whenever the anchor date moves it. Fetch what you haven't fetched yet, and set loading while some
is on its way:
const timeZone = "Europe/Berlin";
/**
* Fetches meetings for the dates the agenda lists, each date once, as it scrolls, and says so
* while some are still on their way.
*/
export function LongAgenda({
loadMeetings,
}: {
readonly loadMeetings: (from: Temporal.Instant, to: Temporal.Instant) => Promise<Meeting[]>;
}) {
const [loaded, setLoaded] = useState<ReadonlyMap<string, Meeting>>(new Map());
const [fetching, setFetching] = useState(0);
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();
setFetching((count) => count + 1);
void loadMeetings(from, to)
.then((arrived) =>
setLoaded((current) => new Map([...current, ...arrived.map((m) => [m.id, m] as const)])),
)
.finally(() => setFetching((count) => count - 1));
},
[loadMeetings],
);
return (
<Calendar.Root
events={meetings}
defaultAnchorDate={Temporal.Now.plainDateISO(timeZone)}
onVisibleRangeChange={load}
timeZone={timeZone}
locale="en-US"
viewOptions={{ "scrolling-agenda": { loading: fetching > 0 } }}
weekRules={weekRules}
>
<Calendar.ActiveView />
</Calendar.Root>
);
}While loading is set, the end of the list says "Loading events…", and a view without events
shows a loading state in place of the empty one. Time never fetches or keeps events itself.
Anatomy
AgendaView from registry/list-view renders a list with one group per day. The view builds these
parts itself, so they're not parts you compose: to change one, edit its file. The tree shows where
each part lives.
- Agenda view
AgendaView - Day group header
- Day group
data-date - All-day badge
- Item
AgendaItem - Day k of n
dayCount - End of list
AgendaEnd - Empty state
AgendaView agenda-view.tsx
├── AgendaList agenda-list.tsx, scrolls the days in view
│ ├── Day group agenda-day.tsx, data-date, one per day
│ │ ├── Day group header weekday and date, sticky
│ │ └── AgendaItem agenda-item.tsx, one per event
│ └── End of list agenda-states.tsx, AgendaEnd: scrolling agenda only
└── Empty state agenda-states.tsx, EmptyAgenda: in place of the listAgendaItem shows the time range or an "All day" badge, the title, and "Day 2 of 3", which comes
from dayIndex and dayCount. use-agenda-virtualizer.ts decides which days render. The
scrolling agenda's ScrollingAgendaView renders the same parts, and use-agenda-scroll.ts in
registry/scrolling-list-view keeps its scroll position and the anchor date together.
Options
Prop
Type
What it shows
- Days: each day with events gets a date column that stays in view while you scroll its events. Today is highlighted.
- Times are formatted as a range with
Intl, as in "10:00 – 11:30 AM". Events that started the day before read "Until 2:00 AM"; events that run into the next day read "From 10:00 PM". - All-day events show an "All day" badge, and events longer than a day show "Day 2 of 3".
- Empty range: a short message when the weeks shown have no events, or a loading one while
loadingis set. - Clicking a date opens that day in the view named
day. Register a day view so the click has somewhere to go. - Changes being saved: events with a pending change get
aria-busyand render at 60% opacity.
Each day has data-date, and each event is a <button> with data-event-id.
Keyboard
Tab into the preview above and try these keys. The Accessibility page has the full map.
| Focus | Key | Effect |
|---|---|---|
| An event | ↑ ↓ | Previous or next event |
| An event | Page Up, Page Down | First event of the previous or next day |
| An event | Home, End | First or last event |
| An event | Enter | Open it, as a click does |
| An event | Shift+Enter | Open its date in the day view |
| A range without events | ↑ or Page Up, ↓ or Page Down | The previous or next range |
Moving past the first or last event shows the previous or next weeks. In the scrolling agenda the keys move through the weeks it shows, and moving past them pages on as focus moves to the next event, scrolling it into view. The list scrolls to keep the focused event in view, and the focused day stays rendered while focus is in the list. Focus belongs to an event on a date, not to an element: if its day scrolls out of the loaded weeks, focus goes with it, and it returns once the day is in view again. The agenda doesn't move or create events from the keyboard, since it has no days or times to move them to.
Screen readers
The list is an <ol> named by its weeks. Only the days in view render, so each day's <li>
carries aria-posinset and aria-setsize, counting every day listed: "list item, 5 of 41". The
list is aria-busy while loading is set.
Last updated on