+ );
+}
diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineView.module.css b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.module.css
new file mode 100644
index 0000000000..1b4dd5829f
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.module.css
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+.root {
+ height: 100%;
+ width: 100%;
+ position: relative;
+}
+
+.scroller {
+ height: 100%;
+ width: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ /* Turn off the browser's own scroll anchoring. It would try to hold the scroll position
+ when content changes, which conflicts with TanStack doing the same job in JS. */
+ overflow-anchor: none;
+}
+
+/* Applied during the first load. The list is laid out and scrolled into position, but kept
+ hidden (a spinner shows instead) so the user does not watch it shuffle into place. */
+.hidden {
+ visibility: hidden;
+}
+
+.list {
+ width: 100%;
+ position: relative;
+ list-style: none;
+ margin: 0;
+}
+
+/* Rows are positioned absolutely, stacked by TanStack rather than by normal document flow.
+ TanStack writes each row's transform and the container's height straight to the DOM (the
+ directDomUpdates option), so never set transform or height here — it would be overwritten
+ or fight with what TanStack writes. */
+.tile {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ overflow-anchor: none;
+ list-style: none;
+}
+
+/* Holds the spinner shown in the middle of the panel while the timeline is still hidden. */
+.cover {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineView.stories.tsx b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.stories.tsx
new file mode 100644
index 0000000000..0331cdc977
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.stories.tsx
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { type JSX } from "react";
+import { fn, expect, waitFor } from "storybook/test";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { TimelineView } from "./TimelineView";
+import type { TimelineItem, TimelineViewActions, TimelineViewSnapshot } from "./types";
+import { useMockedViewModel } from "../../../core/viewmodel";
+import { withViewDocs } from "../../../../.storybook/withViewDocs";
+
+// A handful of deterministic, fixed-height message rows so the virtualizer lays
+// out predictably for the visual snapshot (no avatars/media to decode).
+const SENDERS = ["Alice", "Bob", "Carol"];
+const LINES = [
+ "Morning! Did the deploy go out?",
+ "Yep, green across the board.",
+ "Nice. I'll close the ticket then.",
+ "One flaky test on CI, re-running.",
+ "Passed on the second go.",
+ "Great, merging now.",
+];
+const mockEvents = Array.from({ length: 12 }, (_, i) => ({
+ key: `evt-${i}`,
+ sender: SENDERS[i % SENDERS.length],
+ body: LINES[i % LINES.length],
+}));
+const mockContent = new Map(mockEvents.map((e) => [e.key, e]));
+
+const mockItems: TimelineItem[] = mockEvents.map((e) => ({
+ key: e.key,
+ kind: "event",
+ continuation: false,
+ lastInSection: true,
+}));
+
+const renderItem = (item: TimelineItem): React.ReactNode => {
+ const content = mockContent.get(item.key);
+ if (!content) return null;
+ return (
+
+ );
+};
+const TimelineViewWrapper = withViewDocs(TimelineViewWrapperImpl, TimelineView);
+
+// The timeline lays out hidden behind a cover and reveals once the anchor settles
+// (a couple of animation frames). Wait for that before the snapshot is captured.
+const waitForReveal: NonNullable = async ({ canvasElement }) => {
+ const scroller = canvasElement.querySelector('[data-testid="timeline-scroller"]');
+ await waitFor(() => expect(scroller).toBeVisible());
+};
+
+const meta = {
+ title: "Timeline/TimelineView",
+ component: TimelineViewWrapper,
+ tags: ["autodocs"],
+ args: {
+ items: mockItems,
+ atLiveEnd: true,
+ pendingAnchor: null,
+ highlightedEventId: null,
+ isAtBottom: true,
+ canJumpToReadMarker: false,
+ numUnreadMessages: 0,
+ hasHighlights: false,
+ onStartReached: fn(),
+ onEndReached: fn(),
+ onAnchorReached: fn(),
+ onVisibleRangeChanged: fn(),
+ onAtBottomStateChange: fn(),
+ onJumpToReadMarker: fn(),
+ onMarkAllAsRead: fn(),
+ onJumpToLive: fn(),
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ play: waitForReveal,
+ // The virtualizer measures real DOM and reveals over a couple of frames; allow a
+ // little more pixel slack than the global default to absorb sub-pixel layout jitter.
+ parameters: {
+ snapshot: {
+ failureThreshold: 30,
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** A live timeline pinned to the bottom — no overlay controls. */
+export const Default: Story = {};
+
+/** Scrolled up off the live end, with unread messages: the jump-to-bottom badge shows. */
+export const WithJumpToBottom: Story = {
+ args: {
+ atLiveEnd: false,
+ isAtBottom: false,
+ numUnreadMessages: 3,
+ },
+};
+
+/** A read marker above the viewport surfaces the unread bar. */
+export const WithUnreadMarker: Story = {
+ args: {
+ canJumpToReadMarker: "above",
+ },
+};
diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineView.test.tsx b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.test.tsx
new file mode 100644
index 0000000000..a0f6f7dc94
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.test.tsx
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React from "react";
+import { act, render, screen, waitFor, type RenderResult } from "@test-utils";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+
+import { TimelineView } from "./TimelineView";
+import type { TimelineItem, TimelineViewModel, TimelineViewSnapshot } from "./types";
+
+const baseSnapshot: TimelineViewSnapshot = {
+ items: [],
+ atLiveEnd: true,
+ pendingAnchor: null,
+ highlightedEventId: null,
+ isAtBottom: true,
+ canJumpToReadMarker: false,
+ numUnreadMessages: 0,
+ hasHighlights: false,
+};
+
+function eventItems(count: number, offset = 0): TimelineItem[] {
+ return Array.from({ length: count }, (_, i) => ({
+ key: `evt-${offset + i}`,
+ kind: "event" as const,
+ continuation: false,
+ lastInSection: true,
+ }));
+}
+
+type Actions = {
+ onStartReached: ReturnType;
+ onEndReached: ReturnType;
+ onAnchorReached: ReturnType;
+ onVisibleRangeChanged: ReturnType;
+ onAtBottomStateChange: ReturnType;
+ onJumpToReadMarker: ReturnType;
+ onMarkAllAsRead: ReturnType;
+ onJumpToLive: ReturnType;
+};
+
+interface FakeVm {
+ vm: TimelineViewModel;
+ actions: Actions;
+ /** Push a new snapshot and notify subscribers (wrapped in act). */
+ update: (patch: Partial) => void;
+}
+
+function makeFakeVm(initial: Partial = {}): FakeVm {
+ let snapshot: TimelineViewSnapshot = { ...baseSnapshot, ...initial };
+ const listeners = new Set<() => void>();
+ const actions: Actions = {
+ onStartReached: vi.fn(),
+ onEndReached: vi.fn(),
+ onAnchorReached: vi.fn(),
+ onVisibleRangeChanged: vi.fn(),
+ onAtBottomStateChange: vi.fn(),
+ onJumpToReadMarker: vi.fn(),
+ onMarkAllAsRead: vi.fn(),
+ onJumpToLive: vi.fn(),
+ };
+ // vi.fn() carries a constructable signature that trips assignability to the
+ // ViewModel's action method types, so cast the assembled object.
+ const vm = {
+ getSnapshot: () => snapshot,
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ ...actions,
+ } as unknown as TimelineViewModel;
+ const update = (patch: Partial): void => {
+ act(() => {
+ snapshot = { ...snapshot, ...patch };
+ listeners.forEach((l) => l());
+ });
+ };
+ return { vm, actions, update };
+}
+
+// Each row is a fixed 40px so the virtualizer measures a deterministic layout.
+const ROW_HEIGHT = 40;
+const renderItem = (item: TimelineItem): React.ReactNode => (
+
+ {item.key}
+
+);
+
+// Fixed-height viewport: the TimelineView is height:100%, so its parent must size it.
+const VIEWPORT_HEIGHT = 300;
+function renderTimeline(vm: TimelineViewModel): RenderResult {
+ return render(
+
+
+
,
+ );
+}
+
+describe("", () => {
+ it("renders each item via the renderItem callback", async () => {
+ const { vm } = makeFakeVm({ items: eventItems(5) });
+ renderTimeline(vm);
+
+ expect(await screen.findByTestId("row-evt-0")).toBeInTheDocument();
+ expect(screen.getByTestId("row-evt-4")).toBeInTheDocument();
+ });
+
+ it("stays hidden behind the cover then reveals after the anchor settles", async () => {
+ // A list taller than the viewport so there is a real scroll offset to settle on.
+ const { vm, actions } = makeFakeVm({ items: eventItems(30) });
+ renderTimeline(vm);
+
+ const scroller = screen.getByTestId("timeline-scroller");
+ // Cover is up initially: the scroller is hidden and the anchor hasn't settled.
+ expect(scroller).toHaveStyle({ visibility: "hidden" });
+ expect(actions.onAnchorReached).not.toHaveBeenCalled();
+
+ await waitFor(() => expect(actions.onAnchorReached).toHaveBeenCalledTimes(1), { timeout: 5000 });
+ await waitFor(() => expect(scroller).toHaveStyle({ visibility: "visible" }));
+ });
+
+ it("reports the visible range and at-bottom state once live", async () => {
+ const { vm, actions } = makeFakeVm({ items: eventItems(30) });
+ renderTimeline(vm);
+
+ await waitFor(() => expect(actions.onAnchorReached).toHaveBeenCalled(), { timeout: 5000 });
+ await waitFor(() => expect(actions.onVisibleRangeChanged).toHaveBeenCalled());
+ await waitFor(() => expect(actions.onAtBottomStateChange).toHaveBeenCalled());
+
+ // Indices are 0-based into the items array.
+ const [start, end] = actions.onVisibleRangeChanged.mock.calls.at(-1)!;
+ expect(start).toBeGreaterThanOrEqual(0);
+ expect(end).toBeGreaterThan(start);
+ });
+
+ it("re-renders when the view model pushes a new snapshot", async () => {
+ const { vm, update } = makeFakeVm({ items: eventItems(5) });
+ renderTimeline(vm);
+ await screen.findByTestId("row-evt-0");
+
+ update({ items: eventItems(6) });
+
+ expect(await screen.findByTestId("row-evt-5")).toBeInTheDocument();
+ });
+
+ it("shows the jump-to-bottom control once revealed when scrolled off the bottom", async () => {
+ const { vm, actions } = makeFakeVm({ items: eventItems(30), isAtBottom: false });
+ renderTimeline(vm);
+
+ const jumpToBottom = await screen.findByRole(
+ "button",
+ { name: "Scroll to most recent messages" },
+ { timeout: 5000 },
+ );
+
+ const user = userEvent.setup();
+ await user.click(jumpToBottom);
+ expect(actions.onJumpToLive).toHaveBeenCalledTimes(1);
+ // The View hands the VM its imperative scroll handle.
+ expect(actions.onJumpToLive.mock.calls[0][0]).toBeTypeOf("function");
+ });
+});
diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineView.tsx b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.tsx
new file mode 100644
index 0000000000..30a5936ee7
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.tsx
@@ -0,0 +1,402 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type JSX } from "react";
+import classNames from "classnames";
+import { useVirtualizer, type VirtualItem, type Virtualizer } from "@tanstack/react-virtual";
+import { InlineSpinner } from "@vector-im/compound-web";
+
+import { useViewModel } from "../../../core/viewmodel/useViewModel";
+import type { AnchorAlign, ImmediateScroll, TimelineItem, TimelineViewProps } from "./types";
+import { BACKWARD_LOADING_KEY, FORWARD_LOADING_KEY } from "./types";
+import { TimelineOverlayButtons } from "./TimelineOverlayButtons";
+import styles from "./TimelineView.module.css";
+
+/**
+ * Renders the room timeline: a scrollable list of messages.
+ *
+ * The list is virtualised — only the rows currently on screen (plus a few just outside it)
+ * exist in the DOM, so a room with thousands of messages stays fast. TanStack Virtual does
+ * that work; we drive it from `RoomTimelineViewModel`, which supplies the rows to show and
+ * is told in return what the user can see.
+ *
+ * The hard part of a chat timeline is holding the scroll position steady while the list
+ * changes underneath the reader. Rows appear at the top when older history loads, get
+ * removed when the loaded window is trimmed, and loading spinners come and go. Each of
+ * those shifts everything below it, which without care makes the message someone is
+ * reading jump away mid-sentence. How each case is handled:
+ *
+ * - **Older history arrives at the top.** `anchorTo: "end"` makes TanStack remember which
+ * row the user is looking at and, once the new rows are inserted above it, adjust the
+ * scroll position by the height that was added so that row stays exactly where it was
+ * on screen. It does this before the browser paints, so the shift is never visible.
+ *
+ * `isValidAnchorItem` stops it picking a loading spinner as that remembered row: the
+ * spinner is replaced by the messages it was waiting for, so afterwards there is no
+ * such row left to line up against and the timeline would lurch to the top instead.
+ *
+ * - **New messages arrive at the bottom.** `followOnAppend` scrolls down to keep them in
+ * view, but only when we are already at the live end and not jumping somewhere else.
+ *
+ * - **Reaching either end**, which is the cue to load more, is worked out from which rows
+ * are currently rendered. TanStack has no "you reached the top/bottom" callback.
+ *
+ * - **Jumping to a particular message** looks up how far down that message sits and
+ * scrolls straight to that position. We avoid TanStack's `scrollToIndex`, which keeps
+ * steering towards a row *number*: if history loads while it is doing that, every row
+ * shifts down and it follows the wrong one to the top. See `offsetForKey`.
+ *
+ * `directDomUpdates: true` lets TanStack position rows by writing to the DOM itself rather
+ * than going through a React render, so measuring a row and moving it happen in the same
+ * frame (splitting them across two caused a visible stutter). React still re-renders when
+ * the set of visible rows changes.
+ *
+ * Known gap: `overscan` counts rows rather than pixels, so how far it actually reaches
+ * beyond the viewport varies with how tall those rows happen to be.
+ */
+
+/** Seed height for not-yet-measured rows; kept near a typical chat row so the
+ * estimate→measured correction stays small. TanStack caches real heights by key thereafter. */
+const ESTIMATED_ITEM_HEIGHT = 48;
+/** Rows rendered beyond the visible range each side — a COUNT, not px; ~16 ≈ a screenful. */
+const OVERSCAN = 16;
+/** px from the list bottom still counted as "at the bottom". */
+const AT_BOTTOM_THRESHOLD_PX = 4;
+/**
+ * How long we are willing to keep the timeline hidden on first load, in milliseconds.
+ *
+ * A spinner covers the list while it scrolls to the message it should start at, because that
+ * scroll is neither instant nor nice to watch: rows are still being measured, so the user
+ * would see blank white space where messages have not been placed yet, and would watch rows
+ * resize as their content finishes arriving — URL previews appearing, polls decrypting. We
+ * drop the cover as soon as the target row settles at the position it was aiming for.
+ *
+ * That content can keep changing height for a while, which shifts the target underneath the
+ * scroll, so settling sometimes takes longer than expected — and occasionally never quite
+ * completes, for instance when the requested alignment would need more content below it than
+ * the room has, and the browser clamps the scroll short of it. This is the point at which we
+ * stop waiting and show the timeline anyway: a slightly unsettled timeline beats an endless
+ * spinner.
+ */
+const REVEAL_TIMEOUT_MS = 1000;
+
+/**
+ * How far the view has got through its first load:
+ * - "init" — nothing rendered yet; waiting for the first batch of messages.
+ * - "placing" — rows are laid out but still hidden while we scroll to the right spot.
+ * - "live" — the timeline is visible and the user is in control of scrolling.
+ */
+type Phase = "init" | "placing" | "live";
+
+export function TimelineView({ vm, renderItem }: TimelineViewProps): JSX.Element {
+ const snapshot = useViewModel(vm);
+
+ // The effects and callbacks below run outside React's render — from scroll events and
+ // animation frames — so they cannot use the `snapshot` variable captured when this
+ // function last ran, as it may be out of date by then. These refs always hold the
+ // latest values for them to read.
+ const snapshotRef = useRef(snapshot);
+ snapshotRef.current = snapshot;
+
+ // Loading spinners are ordinary entries in this list (kind: "loading") rather than
+ // something floating on top of it. That way each spinner occupies real space in the
+ // scroll area, so showing or hiding one is just another change to the list that the
+ // scroll anchoring described above already knows how to absorb.
+ const items = snapshot.items;
+ const itemsRef = useRef(items);
+ itemsRef.current = items;
+
+ const scrollerRef = useRef(null);
+
+ // On the first load we lay the rows out, scroll to the message we should start at, and
+ // only then show the result. Otherwise the user would watch the list shuffle around as
+ // rows are measured and the scroll position corrected. A spinner covers the gap. This
+ // happens once per room, as the panel is recreated when the room changes.
+ const [revealed, setRevealed] = useState(false);
+ const revealedRef = useRef(false);
+
+ // Gives each row a stable identity (its event id). TanStack uses these to recognise the
+ // same row from one update to the next, which is what makes the scroll anchoring
+ // possible at all. It also compares the first row's key between renders to spot when
+ // rows have been added or removed at the top, so this must always read the current list.
+ const getItemKey = useCallback((index: number): string => items[index]?.key ?? String(index), [items]);
+
+ // Refuses loading spinners as the row the scroll position is anchored to; see the note
+ // on `isValidAnchorItem` in the file comment above for why anchoring to one breaks.
+ //
+ // This tests the row's key rather than its position in the list. When rows are added or
+ // removed, TanStack is still working from the positions as they were before the change
+ // while `items` already reflects it, so looking a row up by position here would check
+ // the wrong one. The spinner keys are shared with the view model, in types.ts.
+ const isValidAnchorItem = useCallback(
+ (item: VirtualItem): boolean => item.key !== BACKWARD_LOADING_KEY && item.key !== FORWARD_LOADING_KEY,
+ [],
+ );
+
+ // ─── State used by the scroll reporting below ──────────────────────────────
+ const phaseRef = useRef("init");
+ // We only want to tell the view model about things that have actually changed, so these
+ // hold the last values we sent and repeats are skipped.
+ const lastVisibleRangeRef = useRef<{ start: number; end: number } | null>(null);
+ const lastAtBottomRef = useRef(null);
+ // For the "reached the top/bottom" reports we remember a short description of the
+ // situation we last reported, in the form ":", and clear it
+ // whenever we move away from that end. This stops us reporting over and over while
+ // sitting still at the end, while still reporting again once more history has loaded
+ // and the user scrolls further into it.
+ const startEdgeTokenRef = useRef("");
+ const endEdgeTokenRef = useRef("");
+
+ // Tells the view model what the user can currently see: which rows are on screen,
+ // whether we are at the bottom, and whether either end of the loaded messages has been
+ // reached (the cue for it to load more). TanStack calls this whenever it updates —
+ // on scroll, after measuring a row, or when the set of visible rows changes.
+ //
+ // This only reads state, it never scrolls. It stays quiet until the first load has
+ // finished and while we are scrolling to a message the view model asked for, because
+ // until then the scroll position reflects our own automatic placement rather than
+ // anything the user did.
+ const reportVisibleState = useCallback(
+ (v: Virtualizer): void => {
+ if (phaseRef.current !== "live" || snapshotRef.current.pendingAnchor !== null) return;
+ const itemCount = itemsRef.current.length;
+ const visibleRange = v.range;
+
+ // Which rows are on screen, given as positions in the items array.
+ if (
+ visibleRange &&
+ (lastVisibleRangeRef.current?.start !== visibleRange.startIndex ||
+ lastVisibleRangeRef.current?.end !== visibleRange.endIndex)
+ ) {
+ lastVisibleRangeRef.current = { start: visibleRange.startIndex, end: visibleRange.endIndex };
+ vm.onVisibleRangeChanged(visibleRange.startIndex, visibleRange.endIndex);
+ }
+
+ // Are we scrolled to the bottom? Worked out from figures TanStack already holds
+ // (how far we have scrolled, the viewport height, the total height) rather than
+ // measuring the DOM, which would force the browser to redo layout on every call.
+ const scrollOffset = v.scrollOffset ?? 0;
+ const viewportHeight = v.scrollRect?.height ?? 0;
+ const totalSize = v.getTotalSize();
+ const atBottom = viewportHeight > 0 && scrollOffset + viewportHeight >= totalSize - AT_BOTTOM_THRESHOLD_PX;
+ if (atBottom !== lastAtBottomRef.current) {
+ lastAtBottomRef.current = atBottom;
+ vm.onAtBottomStateChange(atBottom);
+ }
+
+ // Have we reached either end of the loaded messages? True once the very first or
+ // very last row is among those being rendered, which tells the view model it may
+ // need to load more history in that direction.
+ const renderedItems = v.getVirtualItems();
+ const firstRenderedIndex = renderedItems.length ? renderedItems[0].index : -1;
+ const lastRenderedIndex = renderedItems.length ? renderedItems[renderedItems.length - 1].index : -1;
+ if (firstRenderedIndex === 0) {
+ const token = `${itemCount}:${visibleRange ? visibleRange.startIndex : 0}`;
+ if (startEdgeTokenRef.current !== token) {
+ startEdgeTokenRef.current = token;
+ vm.onStartReached();
+ }
+ } else {
+ startEdgeTokenRef.current = "";
+ }
+ if (itemCount > 0 && lastRenderedIndex === itemCount - 1) {
+ const token = `${itemCount}:${visibleRange ? visibleRange.endIndex : 0}`;
+ if (endEdgeTokenRef.current !== token) {
+ endEdgeTokenRef.current = token;
+ vm.onEndReached();
+ }
+ } else {
+ endEdgeTokenRef.current = "";
+ }
+ },
+ [vm],
+ );
+
+ const virtualizer = useVirtualizer({
+ count: items.length,
+ getScrollElement: () => scrollerRef.current,
+ // Only consulted for rows that have not been measured yet. TanStack measures each
+ // row as it renders and remembers the result by key, reusing it as rows are added,
+ // trimmed or reloaded, so we do not need a size cache of our own.
+ estimateSize: () => ESTIMATED_ITEM_HEIGHT,
+ getItemKey,
+ overscan: OVERSCAN,
+ // Keep whatever the user is looking at visually still when rows are added or
+ // removed, correcting the scroll position before the browser paints. This is the
+ // main thing stopping the timeline jumping; see the file comment for how it works.
+ anchorTo: "end",
+ // ...but never hold onto a loading spinner as that reference row (see above). This
+ // option comes from our @tanstack/virtual-core patch and is pending upstream.
+ isValidAnchorItem,
+ // Scroll down to follow newly arrived messages, but only when we are at the live end
+ // of the timeline and are not part-way through jumping somewhere else.
+ followOnAppend: snapshot.atLiveEnd && snapshot.pendingAnchor === null,
+ // Let TanStack place rows by writing to the DOM directly instead of re-rendering.
+ // Because of this, never set transform or height on a row in JSX below — it would
+ // fight with what TanStack writes.
+ directDomUpdates: true,
+ // Called on every TanStack update; we use it to report what is on screen upwards.
+ onChange: reportVisibleState,
+ });
+
+ // Works out how far down the list we would have to scroll, in pixels, to bring the row
+ // with `targetKey` into view — `align` saying whether it should end up at the top,
+ // the middle or the bottom of the viewport. Returns null if that message is not among
+ // the ones currently loaded, in which case the caller cannot scroll to it yet.
+ //
+ // Callers hand the result to `scrollToOffset`, which simply scrolls to a fixed pixel
+ // position. We deliberately do not use `scrollToIndex`: that keeps steering towards a
+ // row *number* as rows are measured, so if older history loads while it is still
+ // adjusting, every row shifts down and it follows the wrong one up to the top.
+ const offsetForKey = useCallback(
+ (targetKey: string | null, align: AnchorAlign): number | null => {
+ const idx = targetKey ? itemsRef.current.findIndex((i) => i.key === targetKey) : -1;
+ if (idx < 0) return null;
+ const info = virtualizer.getOffsetForIndex(idx, align);
+ return info ? info[0] : null;
+ },
+ [virtualizer],
+ );
+ // ─── First load: scroll to the starting message while hidden, then reveal ──
+ // Runs once, as soon as the first batch of messages arrives.
+ // Holds the pending animation frame from the settle loop below, so it can be cancelled
+ // if the panel goes away while that loop is still running — switching room part-way
+ // through the first load, for example. Without this the callback would carry on and
+ // update state on a component that no longer exists, and call a disposed view model.
+ const coldRafRef = useRef(undefined);
+ useEffect(() => {
+ return () => {
+ if (coldRafRef.current !== undefined) cancelAnimationFrame(coldRafRef.current);
+ };
+ }, []);
+ useLayoutEffect(() => {
+ if (phaseRef.current !== "init" || items.length === 0) return;
+ // Move out of "init" immediately, so that if more messages arrive while we are
+ // still placing this effect runs again but returns here rather than starting over.
+ phaseRef.current = "placing";
+ // Start at the message the view model asked for. If it did not ask for one, or that
+ // message is not in the batch we were given, start at the newest message instead.
+ const anchor = snapshotRef.current.pendingAnchor;
+ const list = itemsRef.current;
+ let idx = anchor ? list.findIndex((i) => i.key === anchor.targetKey) : -1;
+ if (idx < 0) idx = list.length - 1;
+ const align: AnchorAlign = anchor?.align ?? "end";
+ // Let TanStack carry out this scroll: it keeps correcting the target as rows are
+ // measured and their real heights become known. We must not set the scroll position
+ // ourselves as well — two things moving the viewport at once end up fighting.
+ if (idx >= 0) virtualizer.scrollToIndex(idx, { align, behavior: "auto" });
+ // Now watch each frame until that row actually reaches the position it was heading
+ // for, and reveal the timeline once it has. requestAnimationFrame passes the frame's
+ // timestamp, so we can measure how long we have been waiting in real time and give up
+ let startedAt: number | undefined;
+ const tick = (now: number): void => {
+ startedAt ??= now;
+ const info = virtualizer.getOffsetForIndex(idx, align);
+ const offset = virtualizer.scrollOffset ?? 0;
+ const landed = info !== undefined && Math.abs(info[0] - offset) <= 1.5;
+ if (landed || now - startedAt >= REVEAL_TIMEOUT_MS) {
+ phaseRef.current = "live";
+ if (!revealedRef.current) {
+ revealedRef.current = true;
+ setRevealed(true);
+ }
+ vm.onAnchorReached();
+ return;
+ }
+ coldRafRef.current = requestAnimationFrame(tick);
+ };
+ coldRafRef.current = requestAnimationFrame(tick);
+ }, [items.length, virtualizer, vm]);
+
+ // ─── Later jumps: scroll to a message the view model has asked for ─────────
+ // Once the first load is done, the view model can ask us to jump somewhere by setting
+ // `pendingAnchor`. This is used by "jump to the latest message" and "jump to the first
+ // unread message" when the target was not already loaded, so it had to be fetched and
+ // the timeline rebuilt around it first.
+ //
+ // This effect runs after every render, so it acts as soon as those messages appear, and
+ // it scrolls before the browser paints so the jump is never seen as a scrolling motion.
+ // `lastPlacedAnchorKeyRef` records which message we last jumped to, so that later
+ // renders do not repeat the same jump and fight the user's own scrolling.
+ const lastPlacedAnchorKeyRef = useRef(null);
+ useLayoutEffect(() => {
+ if (phaseRef.current !== "live") return;
+ const anchor = snapshotRef.current.pendingAnchor;
+ if (!anchor) {
+ lastPlacedAnchorKeyRef.current = null;
+ return;
+ }
+ if (lastPlacedAnchorKeyRef.current !== anchor.targetKey) {
+ const target = offsetForKey(anchor.targetKey, anchor.align);
+ if (target !== null) {
+ virtualizer.scrollToOffset(target);
+ lastPlacedAnchorKeyRef.current = anchor.targetKey;
+ vm.onAnchorReached();
+ }
+ }
+ });
+
+ // Handed to the overlay buttons, and through them to the view model, so it can scroll
+ // us straight away when the message it wants is already loaded — no fetch needed, and
+ // no round trip through `pendingAnchor` above.
+ const scrollNow = useCallback(
+ (anchor) => {
+ const target = offsetForKey(anchor.targetKey, anchor.align);
+ if (target !== null) virtualizer.scrollToOffset(target);
+ },
+ [offsetForKey, virtualizer],
+ );
+
+ const virtualItems = virtualizer.getVirtualItems();
+
+ return (
+
+
+ {/* An of
rows, so screen readers announce this as a list of messages
+ and can say how many there are. The role="list" is stated explicitly even
+ though an already is one: Safari with VoiceOver stops treating a list
+ as a list once list-style is set to none, which our CSS does. The old
+ ScrollPanel does the same thing for the same reason. */}
+ {/* eslint-disable jsx-a11y/no-redundant-roles -- see comment above */}
+
+ {/* eslint-enable jsx-a11y/no-redundant-roles */}
+ {virtualItems.map((vi) => {
+ const item: TimelineItem | undefined = items[vi.index];
+ if (!item) return null;
+ return (
+
+ {renderItem(item)}
+
+ );
+ })}
+
+
+ {!revealed && (
+
+
+
+ )}
+ {revealed && }
+
+ );
+}
diff --git a/packages/shared-components/src/room/timeline/TimelineView/index.ts b/packages/shared-components/src/room/timeline/TimelineView/index.ts
new file mode 100644
index 0000000000..c005db20ce
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/TimelineView/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+export { TimelineView } from "./TimelineView";
+export { BACKWARD_LOADING_KEY, FORWARD_LOADING_KEY } from "./types";
+export type {
+ TimelineItem,
+ TimelineItemKind,
+ TimelineViewSnapshot,
+ TimelineViewActions,
+ TimelineViewModel,
+ TimelineViewProps,
+ NavigationAnchor,
+ AnchorAlign,
+ ImmediateScroll,
+} from "./types";
diff --git a/packages/shared-components/src/room/timeline/TimelineView/types.ts b/packages/shared-components/src/room/timeline/TimelineView/types.ts
new file mode 100644
index 0000000000..6a4b54989d
--- /dev/null
+++ b/packages/shared-components/src/room/timeline/TimelineView/types.ts
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import type { ReactNode } from "react";
+import type { ViewModel } from "../../../core/viewmodel/ViewModel";
+
+// ─── Timeline item: one renderable row ─────────────────────────────
+
+/** Discriminated union of every row kind the timeline can render. */
+export type TimelineItemKind = "event" | "date-separator" | "read-marker" | "loading" | "gap";
+
+export interface EventTimelineItem {
+ key: string;
+ kind: "event";
+ /** Whether this event continues unbroken from the previous sender (suppresses avatar/name). */
+ continuation: boolean;
+ /**
+ * Whether this event closes its continuation group (next event has a different
+ * sender / a gap / a separator, or it is the last). Rounds the group's closing
+ * corner — border-radius only, so it is recomputed every build, never cached.
+ */
+ lastInSection: boolean;
+}
+
+export interface DateSeparatorTimelineItem {
+ key: string;
+ kind: "date-separator";
+ label: string;
+}
+
+export interface ReadMarkerTimelineItem {
+ key: string;
+ kind: "read-marker";
+}
+
+export interface LoadingTimelineItem {
+ key: string;
+ kind: "loading";
+}
+
+export interface GapTimelineItem {
+ key: string;
+ kind: "gap";
+}
+
+export type TimelineItem =
+ | EventTimelineItem
+ | DateSeparatorTimelineItem
+ | ReadMarkerTimelineItem
+ | LoadingTimelineItem
+ | GapTimelineItem;
+
+/**
+ * Stable keys for the edge loading spinners. The ViewModel assigns one of these as the
+ * `key` of a `kind:"loading"` item; the View matches them to keep scroll anchoring off a
+ * spinner (whose key vanishes when the loaded batch replaces it). Shared here so the two
+ * sides can't drift.
+ */
+export const BACKWARD_LOADING_KEY = "backward-loading";
+export const FORWARD_LOADING_KEY = "forward-loading";
+
+// ─── Navigation anchor ─────────────────────────────────────────────
+
+/** Where in the viewport to place the target when scrolling to an anchor. */
+export type AnchorAlign = "start" | "center" | "end";
+
+export interface NavigationAnchor {
+ /** The `TimelineItem.key` to scroll to. */
+ targetKey: string;
+ /** Where in the viewport to place the target. */
+ align: AnchorAlign;
+}
+
+/**
+ * Imperative scroll-to-anchor handle the View hands to ViewModel actions, to scroll
+ * without waiting for a data update. The VM invokes it only when the target is already
+ * in the loaded window; otherwise it sets `pendingAnchor` and lets a load() drive the
+ * scroll. Must be called synchronously inside the action — the View's closure captures
+ * the current items snapshot to resolve the target.
+ */
+export type ImmediateScroll = (anchor: NavigationAnchor) => void;
+
+// ─── Timeline view model contract ──────────────────────────────────
+
+export interface TimelineViewSnapshot {
+ /** The ordered list of items to render. */
+ items: TimelineItem[];
+
+ /**
+ * True when the timeline window has reached the live end — i.e. there are
+ * no more forward events to paginate to. Used to gate follow-on-append so
+ * that the view only auto-scrolls to the bottom when we are actually
+ * viewing the live end of the room.
+ */
+ atLiveEnd: boolean;
+
+ /**
+ * Placement target for the current load. The View scrolls it into place on mount
+ * and re-asserts it on later loads without remounting. While set, follow-on-append
+ * is disabled (this also keeps a cold-loading list pinned to the anchor rather than
+ * snapping to the bottom). Cleared when the View reports {@link TimelineViewActions.onAnchorReached}.
+ */
+ pendingAnchor: NavigationAnchor | null;
+
+ /**
+ * The event ID that should be visually highlighted (e.g. permalink target).
+ * Unlike `pendingAnchor`, this is not cleared after scrolling — it persists
+ * so the event tile stays highlighted.
+ */
+ highlightedEventId: string | null;
+
+ /** True when the list is scrolled to the bottom (within a 4px threshold). */
+ isAtBottom: boolean;
+
+ /**
+ * Whether a read-marker is visible above (`"above"`) or below (`"below"`) the
+ * current viewport, or not reachable/applicable (`false`).
+ * - `"above"` — marker is above the viewport (or above the loaded window).
+ * - `"below"` — marker is below the viewport but within the loaded window.
+ * Controls visibility and direction of the "Jump to unread" / "Mark as read" bar.
+ */
+ canJumpToReadMarker: "above" | "below" | false;
+
+ /**
+ * Number of new messages that have arrived since the user last scrolled
+ * to the live bottom. Reset to zero when the user reaches the live bottom.
+ * Used as the badge count on the "Jump to bottom" button.
+ */
+ numUnreadMessages: number;
+
+ /**
+ * True when at least one of the new-since-leaving-bottom messages is a
+ * highlight (mention / keyword). Drives the highlight style on the
+ * "Jump to bottom" button.
+ */
+ hasHighlights: boolean;
+}
+
+export interface TimelineViewActions {
+ /** Called when the view reaches the start; VM decides whether to paginate. */
+ onStartReached(): void;
+
+ /** Called when the view reaches the end; VM decides whether to paginate. */
+ onEndReached(): void;
+
+ /**
+ * Report that the anchor placement has settled (the target has stabilised in
+ * the visible range). The VM clears `pendingAnchor`, re-enabling
+ * follow-on-append and normal scroll-position tracking.
+ */
+ onAnchorReached(): void;
+
+ /**
+ * Called on every visible-range change; the VM tracks the bottommost visible event
+ * for scroll-position persistence. Indices are 0-based into the items array.
+ */
+ onVisibleRangeChanged(startIndex: number, endIndex: number): void;
+
+ /** Called when the at-bottom state changes; VM uses this to decide whether to clear the saved scroll position on dispose. */
+ onAtBottomStateChange(atBottom: boolean): void;
+
+ /**
+ * Scroll to the read-marker item (jump to unread messages).
+ *
+ * `scrollNow` is invoked synchronously when the marker is already in the
+ * loaded window (no data update needed). Otherwise the VM triggers a load
+ * at the marker and the scroll happens via `pendingAnchor` after the load.
+ */
+ onJumpToReadMarker(scrollNow: ImmediateScroll): void;
+
+ /** Mark all currently-visible messages as read, clearing the read marker. */
+ onMarkAllAsRead(): void;
+
+ /**
+ * Navigate to the live end of the timeline.
+ *
+ * `scrollNow` is invoked synchronously when the window already reaches
+ * the live end (no data update needed). Otherwise the VM reloads the
+ * timeline window at the live end and the scroll happens via
+ * `pendingAnchor` after the load.
+ */
+ onJumpToLive(scrollNow: ImmediateScroll): void;
+}
+
+export type TimelineViewModel = ViewModel;
+
+// ─── Shared timeline view props ────────────────────────────────────
+
+export interface TimelineViewProps {
+ vm: TimelineViewModel;
+
+ /**
+ * Render callback for each timeline item.
+ * The shared container calls this for every visible item.
+ */
+ renderItem: (item: TimelineItem) => ReactNode;
+}
diff --git a/patches/@tanstack__virtual-core@3.17.6.patch b/patches/@tanstack__virtual-core@3.17.6.patch
new file mode 100644
index 0000000000..2604e074b5
--- /dev/null
+++ b/patches/@tanstack__virtual-core@3.17.6.patch
@@ -0,0 +1,47 @@
+diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
+index 387489bd25c9ebcf6afd97df5e741706fbdec65d..0e9961bb69b411824ba91f56694774e801d599c4 100644
+--- a/dist/esm/index.d.ts
++++ b/dist/esm/index.d.ts
+@@ -78,6 +78,12 @@ export interface VirtualizerOptions;
+ lanes?: number;
+ anchorTo?: ScrollAnchor;
++ /**
++ * With `anchorTo: "end"`, reject an item as the scroll anchor (e.g. a transient
++ * placeholder/sentinel row whose key does not survive the update); anchoring
++ * steps inward to the nearest accepted item. Element-web patch, pending upstream.
++ */
++ isValidAnchorItem?: (item: VirtualItem) => boolean;
+ followOnAppend?: FollowOnAppend;
+ scrollEndThreshold?: number;
+ isScrollingResetDelay?: number;
+diff --git a/dist/esm/index.js b/dist/esm/index.js
+index 00fd4a4b0a0049193f8862c2604cb76013d605d5..37c058b25d30f42f82617d9cc4c2831bc0b13704 100644
+--- a/dist/esm/index.js
++++ b/dist/esm/index.js
+@@ -295,7 +295,24 @@ class Virtualizer {
+ const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey);
+ if (didEdgeKeysChange) {
+ edgeKeysChanged = true;
+- const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
++ let item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
++ // With anchorTo: "end" the scroll is re-pinned by the key of the item
++ // under the current offset. If the consumer rejects that item as an
++ // anchor via isValidAnchorItem — e.g. a transient placeholder/sentinel
++ // row (loading spinner, "load more", skeleton) whose key does not
++ // survive the update — re-pinning by its key would fail and the
++ // prepend would go uncompensated (the list jumps to the edge). Such
++ // rows only sit at the extreme edges, so step one index inward to the
++ // nearest accepted item, whose key persists across the update.
++ const isValidAnchorItem = merged.isValidAnchorItem;
++ if (item && isValidAnchorItem && !isValidAnchorItem(item)) {
++ const step = item.index === 0 ? 1 : -1;
++ let i = item.index;
++ while (i >= 0 && i < measurements.length && measurements[i] && !isValidAnchorItem(measurements[i])) {
++ i += step;
++ }
++ item = measurements[i] ?? item;
++ }
+ if (item) {
+ anchor = [item.key, this.getScrollOffset() - item.start];
+ }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c1d8f0b4e1..788f4815dd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -311,6 +311,7 @@ packageExtensionsChecksum: sha256-kCJccFy7l6HmjcpzmxV958fHndI9zZq246f1ttgkURI=
patchedDependencies:
'@arcmantle/vite-plugin-import-css-sheet': 8019aa9feca17db6bab3483612b4150c911d70b58fddc52bf2d7258a1484a747
'@matrix-org/react-sdk-module-api': 016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96
+ '@tanstack/virtual-core@3.17.6': 9db9d80c4fe7a7a9911643522aef9bc9359075570c38f3d847ff75ed635d921c
'@types/auto-launch': b60dc9846a11a1684ce52c55493f29ea398d3dc44b88f66ab44e9843d6c01538
'@vector-im/matrix-wysiwyg': 7bdf6150f2905bc2f055a6bcaa7b9d78fa7ffde82e800bcc454ac7b0096bd65e
await-lock: b767a571946a4f8710ac54b1a7bec8a7c1570f9b85d75922392fae20d0578964
@@ -1384,6 +1385,9 @@ importers:
'@matrix-org/spec':
specifier: ^1.7.0
version: 1.16.0
+ '@tanstack/react-virtual':
+ specifier: 3.14.8
+ version: 3.14.8(react-dom@19.2.8)(react@19.2.8)
'@vector-im/compound-design-tokens':
specifier: 'catalog:'
version: 10.2.1(@types/react@19.2.18)(react@19.2.8)
@@ -5826,6 +5830,15 @@ packages:
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
+ '@tanstack/react-virtual@3.14.8':
+ resolution: {integrity: sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tanstack/virtual-core@3.17.6':
+ resolution: {integrity: sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==}
+
'@testcontainers/postgresql@12.0.4':
resolution: {integrity: sha512-a/pLU6j5lpKKAlUTPwqweqMGhOSjgTSb6HBX69TOrXn32ifU37nnQDmNFTj8ddOAw+BQL9oTRkeOxVbZkqhgZA==}
@@ -18405,6 +18418,14 @@ snapshots:
dependencies:
defer-to-connect: 2.0.1
+ '@tanstack/react-virtual@3.14.8(react-dom@19.2.8)(react@19.2.8)':
+ dependencies:
+ '@tanstack/virtual-core': 3.17.6(patch_hash=9db9d80c4fe7a7a9911643522aef9bc9359075570c38f3d847ff75ed635d921c)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@tanstack/virtual-core@3.17.6(patch_hash=9db9d80c4fe7a7a9911643522aef9bc9359075570c38f3d847ff75ed635d921c)': {}
+
'@testcontainers/postgresql@12.0.4(supports-color@10.2.2)':
dependencies:
testcontainers: 12.0.4(supports-color@10.2.2)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 3d4b5bb72c..f82efcbaef 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -119,6 +119,8 @@ patchedDependencies:
plist: patches/plist.patch
# Workaround for type fails
"@arcmantle/vite-plugin-import-css-sheet": patches/@arcmantle__vite-plugin-import-css-sheet.patch
+ # Workaround for scroll anchoring landing on transient placeholder rows (pending upstream)
+ "@tanstack/virtual-core@3.17.6": patches/@tanstack__virtual-core@3.17.6.patch
peerDependencyRules:
allowedVersions:
@@ -214,3 +216,7 @@ minimumReleaseAgeExclude:
# Temporary for testing purposes
- "@typescript/*"
- typescript@7.0.2
+ # New timeline: 3.14.8/3.17.6 carry the upstream end-anchored-prepend fix
+ # (#1237); they were freshly published so are still inside the release-age
+ # window. Revisit once the release has aged past the cutoff.
+ - "@tanstack/*"