diff --git a/package.json b/package.json
index 52117250fdf4eda54910eec10ffe2cf8d1c61854..e3c9d40bc11b8fa4b28a11ccb917581c597f79ef 100644
--- a/package.json
+++ b/package.json
@@ -149,6 +149,7 @@
         "react-string-replace": "^1.1.1",
         "react-transition-group": "^4.4.1",
         "react-virtualized": "^9.22.5",
+        "react-virtuoso": "^4.12.7",
         "rfc4648": "^1.4.0",
         "sanitize-filename": "^1.6.3",
         "sanitize-html": "2.16.0",
diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx
index 2441182bc8300f441ca01a8fa43a1f71e7539586..844e8824f9560712260b0f9f5c0534bf7333ef48 100644
--- a/src/components/structures/MessagePanel.tsx
+++ b/src/components/structures/MessagePanel.tsx
@@ -35,7 +35,8 @@ import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResiz
 import defaultDispatcher from "../../dispatcher/dispatcher";
 import type LegacyCallEventGrouper from "./LegacyCallEventGrouper";
 import WhoIsTypingTile from "../views/rooms/WhoIsTypingTile";
-import ScrollPanel, { type IScrollState } from "./ScrollPanel";
+import { type IScrollState } from "./ScrollPanel";
+import { ListRange, LogLevel, Virtuoso, VirtuosoHandle } from "react-virtuoso";
 import DateSeparator from "../views/messages/DateSeparator";
 import TimelineSeparator, { SeparatorKind } from "../views/messages/TimelineSeparator";
 import ErrorBoundary from "../views/elements/ErrorBoundary";
@@ -58,6 +59,15 @@ import { getLateEventInfo } from "./grouper/LateEventGrouper";
 const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
 const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
 
+type EventItem = {
+    prevEvent: MatrixEvent | null;
+    wrappedEvent: WrappedEvent;
+    last: boolean;
+    isGrouped: boolean;
+    nextEvent: WrappedEvent | null;
+    nextEventWithTile: MatrixEvent | null;
+};
+
 // check if there is a previous event and it has the same sender as this event
 // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
 export function shouldFormContinuation(
@@ -192,6 +202,7 @@ interface IState {
     ghostReadMarkers: string[];
     showTypingNotifications: boolean;
     hideSender: boolean;
+    isScrolling: boolean;
 }
 
 interface IReadReceiptForUser {
@@ -251,7 +262,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
 
     private readMarkerNode = createRef<HTMLLIElement>();
     private whoIsTyping = createRef<WhoIsTypingTile>();
-    public scrollPanel = createRef<ScrollPanel>();
+    private virtuosoRef = createRef<VirtuosoHandle | null>();
 
     private showTypingNotificationsWatcherRef?: string;
     private eventTiles: Record<string, UnwrappedEventTile> = {};
@@ -259,6 +270,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
     // A map to allow groupers to maintain consistent keys even if their first event is uprooted due to back-pagination.
     public grouperKeyMap = new WeakMap<MatrixEvent, string>();
 
+    private initialIndex = 100000;
+    private items: EventItem[] = [];
+    private scrollingTimeout: number | undefined = undefined;
+
     public constructor(props: IProps) {
         super(props);
 
@@ -268,6 +283,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
             ghostReadMarkers: [],
             showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
             hideSender: this.shouldHideSender(),
+            isScrolling: false,
         };
 
         // Cache these settings on mount since Settings is expensive to query,
@@ -362,7 +378,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
     /* return true if the content is fully scrolled down right now; else false.
      */
     public isAtBottom(): boolean | undefined {
-        return this.scrollPanel.current?.isAtBottom();
+        return false;
+        // return this.scrollPanel.current?.isAtBottom();
     }
 
     /* get the current scroll state. See ScrollPanel.getScrollState for
@@ -371,7 +388,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
      * returns null if we are not mounted.
      */
     public getScrollState(): IScrollState | null {
-        return this.scrollPanel.current?.getScrollState() ?? null;
+        return null;
+        // return this.scrollPanel.current?.getScrollState() ?? null;
     }
 
     // returns one of:
@@ -382,36 +400,43 @@ export default class MessagePanel extends React.Component<IProps, IState> {
     //  +1: read marker is below the window
     public getReadMarkerPosition(): number | null {
         const readMarker = this.readMarkerNode.current;
-        const messageWrapper = this.scrollPanel.current?.divScroll;
-
-        if (!readMarker || !messageWrapper) {
-            return null;
-        }
 
-        const wrapperRect = messageWrapper.getBoundingClientRect();
-        const readMarkerRect = readMarker.getBoundingClientRect();
-
-        // the read-marker pretends to have zero height when it is actually
-        // two pixels high; +2 here to account for that.
-        if (readMarkerRect.bottom + 2 < wrapperRect.top) {
-            return -1;
-        } else if (readMarkerRect.top < wrapperRect.bottom) {
-            return 0;
-        } else {
-            return 1;
-        }
+        return null;
+        // const messageWrapper = this.scrollPanel.current?.divScroll;
+
+        // if (!readMarker || !messageWrapper) {
+        //     return null;
+        // }
+
+        // const wrapperRect = messageWrapper.getBoundingClientRect();
+        // const readMarkerRect = readMarker.getBoundingClientRect();
+
+        // // the read-marker pretends to have zero height when it is actually
+        // // two pixels high; +2 here to account for that.
+        // if (readMarkerRect.bottom + 2 < wrapperRect.top) {
+        //     return -1;
+        // } else if (readMarkerRect.top < wrapperRect.bottom) {
+        //     return 0;
+        // } else {
+        //     return 1;
+        // }
     }
 
     /* jump to the top of the content.
      */
     public scrollToTop(): void {
-        this.scrollPanel.current?.scrollToTop();
+        // console.log("scrollToTop");
+        // this.virtuosoRef.current?.scrollIntoView({ index: 0, align: "start" });
+        // this.virtuosoRef.current?.scrollToIndex()
+        // this.scrollPanel.current?.scrollToTop();
     }
 
     /* jump to the bottom of the content.
      */
     public scrollToBottom(): void {
-        this.scrollPanel.current?.scrollToBottom();
+        // console.log("scrollToBottom");
+        // this.virtuosoRef.current?.scrollIntoView({ index: this.items.length - 1, align: "end" });
+        // this.scrollPanel.current?.scrollToBottom();
     }
 
     /**
@@ -420,7 +445,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
      * @param {KeyboardEvent} ev: the keyboard event to handle
      */
     public handleScrollKey(ev: React.KeyboardEvent | KeyboardEvent): void {
-        this.scrollPanel.current?.handleScrollKey(ev);
+        // this.scrollPanel.current?.handleScrollKey(ev);
     }
 
     /* jump to the given event id.
@@ -434,17 +459,18 @@ export default class MessagePanel extends React.Component<IProps, IState> {
      * defaults to 0.
      */
     public scrollToEvent(eventId: string, pixelOffset?: number, offsetBase?: number): void {
-        this.scrollPanel.current?.scrollToToken(eventId, pixelOffset, offsetBase);
+        // this.scrollPanel.current?.scrollToToken(eventId, pixelOffset, offsetBase);
     }
 
     public scrollToEventIfNeeded(eventId: string): void {
-        const node = this.getNodeForEventId(eventId);
-        if (node) {
-            node.scrollIntoView({
-                block: "nearest",
-                behavior: "instant",
-            });
-        }
+        console.log("scrollToEventIfNeeded");
+        // const node = this.getNodeForEventId(eventId);
+        // if (node) {
+        //     node.scrollIntoView({
+        //         block: "nearest",
+        //         behavior: "instant",
+        //     });
+        // }
     }
 
     private isUnmounting = (): boolean => {
@@ -605,7 +631,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
         return !status || status === EventStatus.SENT;
     }
 
-    private getEventTiles(): ReactNode[] {
+    private getEventItems(): EventItem[] {
         // first figure out which is the last event in the list which we're
         // actually going to show; this allows us to behave slightly
         // differently for the last event in the list. (eg show timestamp)
@@ -651,7 +677,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
             }
         }
 
-        const ret: ReactNode[] = [];
+        const ret: EventItem[] = [];
         let prevEvent: MatrixEvent | null = null; // the last event we showed
 
         // Note: the EventTile might still render a "sent/sending receipt" independent of
@@ -662,7 +688,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
             this.readReceiptsByEvent = this.getReadReceiptsByShownEvent(events);
         }
 
-        let grouper: BaseGrouper | null = null;
+        // let grouper: BaseGrouper | null = null;
 
         for (let i = 0; i < events.length; i++) {
             const wrappedEvent = events[i];
@@ -671,60 +697,58 @@ export default class MessagePanel extends React.Component<IProps, IState> {
             const last = event === lastShownEvent;
             const { nextEventAndShouldShow, nextTile } = this.getNextEventInfo(events, i);
 
-            if (grouper) {
-                if (grouper.shouldGroup(wrappedEvent)) {
-                    grouper.add(wrappedEvent);
-                    continue;
-                } else {
-                    // not part of group, so get the group tiles, close the
-                    // group, and continue like a normal event
-                    ret.push(...grouper.getTiles());
-                    prevEvent = grouper.getNewPrevEvent();
-                    grouper = null;
-                }
-            }
-
-            for (const Grouper of groupers) {
-                if (Grouper.canStartGroup(this, wrappedEvent) && !this.props.disableGrouping) {
-                    grouper = new Grouper(
-                        this,
-                        wrappedEvent,
-                        prevEvent,
-                        lastShownEvent,
-                        nextEventAndShouldShow,
-                        nextTile,
-                    );
-                    break; // break on first grouper
-                }
-            }
-
-            if (!grouper) {
-                if (shouldShow) {
-                    // make sure we unpack the array returned by getTilesForEvent,
-                    // otherwise React will auto-generate keys, and we will end up
-                    // replacing all the DOM elements every time we paginate.
-                    ret.push(
-                        ...this.getTilesForEvent(
-                            prevEvent,
-                            wrappedEvent,
-                            last,
-                            false,
-                            nextEventAndShouldShow,
-                            nextTile,
-                        ),
-                    );
-                    prevEvent = event;
-                }
-
-                const readMarker = this.readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex);
-                if (readMarker) ret.push(readMarker);
+            // if (grouper) {
+            //     if (grouper.shouldGroup(wrappedEvent)) {
+            //         grouper.add(wrappedEvent);
+            //         continue;
+            //     } else {
+            //         // not part of group, so get the group tiles, close the
+            //         // group, and continue like a normal event
+            //         ret.push(...grouper.getTiles());
+            //         prevEvent = grouper.getNewPrevEvent();
+            //         grouper = null;
+            //     }
+            // }
+
+            // for (const Grouper of groupers) {
+            //     if (Grouper.canStartGroup(this, wrappedEvent) && !this.props.disableGrouping) {
+            //         grouper = new Grouper(
+            //             this,
+            //             wrappedEvent,
+            //             prevEvent,
+            //             lastShownEvent,
+            //             nextEventAndShouldShow,
+            //             nextTile,
+            //         );
+            //         break; // break on first grouper
+            //     }
+            // }
+
+            // if (!grouper) {
+            if (shouldShow) {
+                // make sure we unpack the array returned by getTilesForEvent,
+                // otherwise React will auto-generate keys, and we will end up
+                // replacing all the DOM elements every time we paginate.
+                ret.push({
+                    prevEvent,
+                    wrappedEvent,
+                    last,
+                    isGrouped: false,
+                    nextEvent: nextEventAndShouldShow,
+                    nextEventWithTile: nextTile,
+                });
+                prevEvent = event;
             }
-        }
 
-        if (grouper) {
-            ret.push(...grouper.getTiles());
+            // const readMarker = this.readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex);
+            // if (readMarker) ret.push(readMarker);
+            // }
         }
 
+        // if (grouper) {
+        //     ret.push(...grouper.getTiles());
+        // }
+        // console.log(`Rendering event tiles ${ret.length}`);
         return ret;
     }
 
@@ -735,6 +759,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
         isGrouped = false,
         nextEvent: WrappedEvent | null = null,
         nextEventWithTile: MatrixEvent | null = null,
+        isScrolling: boolean,
     ): ReactNode[] {
         const mxEv = wrappedEvent.event;
         const ret: ReactNode[] = [];
@@ -819,6 +844,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
                 showReadReceipts={this.props.showReadReceipts}
                 callEventGrouper={callEventGrouper}
                 hideSender={this.state.hideSender}
+                isScrolling={isScrolling}
             />,
         );
 
@@ -956,60 +982,118 @@ export default class MessagePanel extends React.Component<IProps, IState> {
 
     // Once dynamic content in the events load, make the scrollPanel check the scroll offsets.
     public onHeightChanged = (): void => {
-        this.scrollPanel.current?.checkScroll();
+        // this.scrollPanel.current?.checkScroll();
     };
 
     private resizeObserver = new ResizeObserver(this.onHeightChanged);
 
     private onTypingShown = (): void => {
-        const scrollPanel = this.scrollPanel.current;
-        // this will make the timeline grow, so checkScroll
-        scrollPanel?.checkScroll();
-        if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
-            scrollPanel.preventShrinking();
-        }
+        // const scrollPanel = this.scrollPanel.current;
+        // // this will make the timeline grow, so checkScroll
+        // scrollPanel?.checkScroll();
+        // if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
+        //     scrollPanel.preventShrinking();
+        // }
     };
 
     private onTypingHidden = (): void => {
-        const scrollPanel = this.scrollPanel.current;
-        if (scrollPanel) {
-            // as hiding the typing notifications doesn't
-            // update the scrollPanel, we tell it to apply
-            // the shrinking prevention once the typing notifs are hidden
-            scrollPanel.updatePreventShrinking();
-            // order is important here as checkScroll will scroll down to
-            // reveal added padding to balance the notifs disappearing.
-            scrollPanel.checkScroll();
-        }
+        // const scrollPanel = this.scrollPanel.current;
+        // if (scrollPanel) {
+        //     // as hiding the typing notifications doesn't
+        //     // update the scrollPanel, we tell it to apply
+        //     // the shrinking prevention once the typing notifs are hidden
+        //     scrollPanel.updatePreventShrinking();
+        //     // order is important here as checkScroll will scroll down to
+        //     // reveal added padding to balance the notifs disappearing.
+        //     scrollPanel.checkScroll();
+        // }
     };
 
     public updateTimelineMinHeight(): void {
-        const scrollPanel = this.scrollPanel.current;
-
-        if (scrollPanel) {
-            const isAtBottom = scrollPanel.isAtBottom();
-            const whoIsTyping = this.whoIsTyping.current;
-            const isTypingVisible = whoIsTyping && whoIsTyping.isVisible();
-            // when messages get added to the timeline,
-            // but somebody else is still typing,
-            // update the min-height, so once the last
-            // person stops typing, no jumping occurs
-            if (isAtBottom && isTypingVisible) {
-                scrollPanel.preventShrinking();
-            }
-        }
+        //     const scrollPanel = this.scrollPanel.current;
+        //     if (scrollPanel) {
+        //         const isAtBottom = scrollPanel.isAtBottom();
+        //         const whoIsTyping = this.whoIsTyping.current;
+        //         const isTypingVisible = whoIsTyping && whoIsTyping.isVisible();
+        //         // when messages get added to the timeline,
+        //         // but somebody else is still typing,
+        //         // update the min-height, so once the last
+        //         // person stops typing, no jumping occurs
+        //         if (isAtBottom && isTypingVisible) {
+        //             scrollPanel.preventShrinking();
+        //         }
+        //     }
     }
 
     public onTimelineReset(): void {
-        const scrollPanel = this.scrollPanel.current;
-        if (scrollPanel) {
-            scrollPanel.clearPreventShrinking();
+        // const scrollPanel = this.scrollPanel.current;
+        // if (scrollPanel) {
+        //     scrollPanel.clearPreventShrinking();
+        // }
+    }
+    private readonly pendingFillRequests: Record<"b" | "f", boolean | null> = {
+        b: null,
+        f: null,
+    };
+    // check if there is already a pending fill request. If not, set one off.
+    private maybeFill(backwards: boolean): Promise<void> {
+        const dir = backwards ? "b" : "f";
+        if (this.pendingFillRequests[dir]) {
+            console.log("Already a fill in progress - not starting another; direction=", dir);
+            return Promise.resolve();
         }
+
+        console.log("starting fill; direction=", dir);
+
+        // onFillRequest can end up calling us recursively (via onScroll
+        // events) so make sure we set this before firing off the call.
+        this.pendingFillRequests[dir] = true;
+
+        // wait 1ms before paginating, because otherwise
+        // this will block the scroll event handler for +700ms
+        // if messages are already cached in memory,
+        // This would cause jumping to happen on Chrome/macOS.
+        return new Promise((resolve) => window.setTimeout(resolve, 1))
+            .then(() => {
+                return this.props.onFillRequest?.(backwards);
+            })
+            .finally(() => {
+                this.pendingFillRequests[dir] = false;
+            })
+            .then((hasMoreResults) => {
+                if (this.unmounted) {
+                    return;
+                }
+
+                console.log("fill complete; hasMoreResults=", hasMoreResults, "direction=", dir);
+                if (hasMoreResults) {
+                    // further pagination requests have been disabled until now, so
+                    // it's time to check the fill state again in case the pagination
+                    // was insufficient.
+                    // return this.checkFillState(depth + 1);
+                }
+            });
     }
 
+    private onStartReached = (index: number): void => {
+        //   setTimeout(() => {
+        console.log("onStartReached");
+        console.log(index);
+        this.maybeFill(true);
+        //   }, 10);
+    };
+
+    // private setVisibleRange = (range: ListRange): void => {
+    // if (range.startIndex == 0) {
+    //     // this.props.onFillRequest?.(true);
+    //     this.maybeFill(true);
+    // }
+    //     console.log(`VisibleRange: ${range.startIndex} : ${range.endIndex}`);
+    // };
+
     public render(): React.ReactNode {
-        let topSpinner;
-        let bottomSpinner;
+        let topSpinner: ReactNode;
+        let bottomSpinner: ReactNode;
         if (this.props.backPaginating) {
             topSpinner = (
                 <li key="_topSpinner">
@@ -1054,9 +1138,82 @@ export default class MessagePanel extends React.Component<IProps, IState> {
             mx_MessagePanel_narrow: this.context.narrow,
         });
 
+        // const InnerEventTiles = React.memo((e: EventItem) => {
+        //     React.useEffect(() => {
+        //         console.log("inner mounting", e);
+        //         return () => {
+        //             console.log("inner unmounting", e);
+        //         };
+        //     }, [e]);
+        //     return this.getTilesForEvent(
+        //         e.prevEvent,
+        //         e.wrappedEvent,
+        //         e.last,
+        //         e.isGrouped,
+        //         e.nextEvent,
+        //         e.nextEventWithTile,
+        //     );
+        // });
+
+        const newItems = this.getEventItems();
+        const diff = newItems.length - this.items.length;
+        this.initialIndex -= diff;
+        this.items = newItems;
         return (
             <ErrorBoundary>
-                <ScrollPanel
+                {/* {ircResizer} */}
+                <div style={{ height: "100%" }} className="mx_RoomView_messageListWrapper">
+                    <ol style={{ height: "100%" }} className="mx_RoomView_MessageList" aria-live="polite">
+                        <Virtuoso
+                            ref={this.virtuosoRef}
+                            className={classes}
+                            style={style}
+                            firstItemIndex={this.initialIndex}
+                            data={this.items}
+                            alignToBottom={true}
+                            // logLevel={LogLevel.DEBUG}
+                            isScrolling={(isScrolling) => {
+                                if (isScrolling && !this.state.isScrolling) {
+                                    this.setState({ isScrolling });
+                                    return;
+                                }
+                                clearTimeout(this.scrollingTimeout);
+                                this.scrollingTimeout = window.setTimeout(() => {
+                                    if (this.state.isScrolling != isScrolling) {
+                                        this.setState({ isScrolling });
+                                    }
+                                }, 1000);
+                            }}
+                            // rangeChanged={this.setVisibleRange}
+                            startReached={this.onStartReached}
+                            // increaseViewportBy={{ top: 3000, bottom: 3000 }}
+                            overscan={{ main: 1000, reverse: 1000 }}
+                            itemContent={(i, e) =>
+                                //     <div>
+                                //         <span>
+                                //             {e.wrappedEvent.event.getContent().body} {i}
+                                //         </span>
+                                //     </div>
+                                // )
+
+                                this.getTilesForEvent(
+                                    e.prevEvent,
+                                    e.wrappedEvent,
+                                    e.last,
+                                    e.isGrouped,
+                                    e.nextEvent,
+                                    e.nextEventWithTile,
+                                    // true,
+                                    this.state.isScrolling,
+                                )
+                            }
+                            components={{ Header: () => topSpinner, Footer: () => bottomSpinner }}
+                            // onScroll={(e) => "ONSCROLL!"}
+                        />
+                    </ol>
+                </div>
+
+                {/* <ScrollPanel
                     ref={this.scrollPanel}
                     className={classes}
                     onScroll={this.props.onScroll}
@@ -1071,7 +1228,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
                     {this.getEventTiles()}
                     {whoIsTyping}
                     {bottomSpinner}
-                </ScrollPanel>
+                </ScrollPanel> */}
             </ErrorBoundary>
         );
     }
diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx
index 8346a0ab3195ea4a43b06d1745184de853156f7a..bdea603fb291ed874d335e1b3d2b773f5e1cade9 100644
--- a/src/components/structures/TimelinePanel.tsx
+++ b/src/components/structures/TimelinePanel.tsx
@@ -380,7 +380,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
     }
 
     private get messagePanelDiv(): HTMLDivElement | null {
-        return this.messagePanel.current?.scrollPanel.current?.divScroll ?? null;
+        return null;
+        // return this.messagePanel.current?.scrollPanel.current?.divScroll ?? null;
     }
 
     /**
diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts
index 37aae37de6d5f3901b4b0d8d5d2bbb9d9d179283..21af8ec484d33417cc10c90b16c71ba20f1e299d 100644
--- a/src/components/views/messages/IBodyProps.ts
+++ b/src/components/views/messages/IBodyProps.ts
@@ -48,4 +48,5 @@ export interface IBodyProps {
     // Set to `true` to disable interactions (e.g. video controls) and to remove controls from the tab order.
     // This may be useful when displaying a preview of the event.
     inhibitInteraction?: boolean;
+    isScrolling?: boolean;
 }
diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx
index 231fa7f1fe5f5d8c9f8b6eb65edc8f30b54a7327..53028ca841b9925b79abe01f9f682281e55712ab 100644
--- a/src/components/views/messages/MessageEvent.tsx
+++ b/src/components/views/messages/MessageEvent.tsx
@@ -308,6 +308,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
             getRelationsForEvent: this.props.getRelationsForEvent,
             isSeeingThroughMessageHiddenForModeration: this.props.isSeeingThroughMessageHiddenForModeration,
             inhibitInteraction: this.props.inhibitInteraction,
+            isScrolling: this.props.isScrolling,
         };
         if (hasCaption) {
             return <CaptionBody {...bodyProps} WrappedBodyType={BodyType} />;
@@ -320,9 +321,12 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
 const CaptionBody: React.FunctionComponent<IBodyProps & { WrappedBodyType: React.ComponentType<IBodyProps> }> = ({
     WrappedBodyType,
     ...props
-}) => (
-    <div className="mx_EventTile_content">
-        <WrappedBodyType {...props} />
-        <TextualBody {...{ ...props, ref: undefined }} />
-    </div>
-);
+}) => {
+    console.log(`CaptionBody isScrolling${props.isScrolling}`);
+    return (
+        <div className="mx_EventTile_content">
+            <WrappedBodyType {...props} />
+            <TextualBody {...{ ...props, ref: undefined }} />
+        </div>
+    );
+};
diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx
index d0107b31ecc667f361e34e1e3d4731e4c690bf97..d92c1f338ce93d985de81b3833c478e6aae1abd6 100644
--- a/src/components/views/messages/TextualBody.tsx
+++ b/src/components/views/messages/TextualBody.tsx
@@ -83,7 +83,9 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
             nextProps.editState !== this.props.editState ||
             nextState.links !== this.state.links ||
             nextState.widgetHidden !== this.state.widgetHidden ||
-            nextProps.isSeeingThroughMessageHiddenForModeration !== this.props.isSeeingThroughMessageHiddenForModeration
+            nextProps.isSeeingThroughMessageHiddenForModeration !==
+                this.props.isSeeingThroughMessageHiddenForModeration ||
+            nextProps.isScrolling !== this.props.isScrolling
         );
     }
 
@@ -378,6 +380,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
                     links={this.state.links}
                     mxEvent={this.props.mxEvent}
                     onCancelClick={this.onCancelClick}
+                    isScrolling={this.props.isScrolling}
                 />
             );
         }
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index 2c10d0afd962aecd35bce400856758b36f78e684..1ebcdcb766b917ed598232f5da6992e943216d8f 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -230,6 +230,8 @@ export interface EventTileProps {
     inhibitInteraction?: boolean;
 
     ref?: Ref<UnwrappedEventTile>;
+
+    isScrolling?: boolean;
 }
 
 interface IState {
@@ -1049,7 +1051,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
             needsSenderProfile = true;
         }
 
-        if (this.props.mxEvent.sender && avatarSize !== null) {
+        if (this.props.mxEvent.sender && avatarSize !== null && !this.props.isScrolling) {
             let member: RoomMember | null = null;
             // set member to receiver (target) if it is a 3PID invite
             // so that the correct avatar is shown as the text is
@@ -1077,7 +1079,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
             );
         }
 
-        if (needsSenderProfile && this.props.hideSender !== true) {
+        if (needsSenderProfile && this.props.hideSender !== true && !this.props.isScrolling) {
             if (
                 this.context.timelineRenderingType === TimelineRenderingType.Room ||
                 this.context.timelineRenderingType === TimelineRenderingType.Search ||
@@ -1093,19 +1095,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
         }
 
         const showMessageActionBar = !isEditing && !this.props.forExport;
-        const actionBar = showMessageActionBar ? (
-            <MessageActionBar
-                mxEvent={this.props.mxEvent}
-                reactions={this.state.reactions}
-                permalinkCreator={this.props.permalinkCreator}
-                getTile={this.getTile}
-                getReplyChain={this.getReplyChain}
-                onFocusChange={this.onActionBarFocusChange}
-                isQuoteExpanded={isQuoteExpanded}
-                toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
-                getRelationsForEvent={this.props.getRelationsForEvent}
-            />
-        ) : undefined;
+        const actionBar =
+            showMessageActionBar && !this.props.isScrolling ? (
+                <MessageActionBar
+                    mxEvent={this.props.mxEvent}
+                    reactions={this.state.reactions}
+                    permalinkCreator={this.props.permalinkCreator}
+                    getTile={this.getTile}
+                    getReplyChain={this.getReplyChain}
+                    onFocusChange={this.onActionBarFocusChange}
+                    isQuoteExpanded={isQuoteExpanded}
+                    toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
+                    getRelationsForEvent={this.props.getRelationsForEvent}
+                />
+            ) : undefined;
 
         const showTimestamp =
             this.props.mxEvent.getTs() &&
@@ -1168,14 +1171,16 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
         ) : null;
 
         const useIRCLayout = this.props.layout === Layout.IRC;
-        const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
-        const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
+        const groupTimestamp = !useIRCLayout && !this.props.isScrolling ? linkedTimestamp : null;
+        const ircTimestamp = useIRCLayout && !this.props.isScrolling ? linkedTimestamp : null;
         const bubbleTimestamp = this.props.layout === Layout.Bubble ? messageTimestamp : undefined;
-        const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
-        const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
+        const groupPadlock = !useIRCLayout && !isBubbleMessage && !this.props.isScrolling && this.renderE2EPadlock();
+        const ircPadlock = useIRCLayout && !isBubbleMessage && !this.props.isScrolling && this.renderE2EPadlock();
 
         let msgOption: JSX.Element | undefined;
-        if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
+        if (this.props.isScrolling) {
+            msgOption = undefined;
+        } else if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
             msgOption = <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
         } else if (this.props.showReadReceipts) {
             msgOption = (
@@ -1192,7 +1197,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
         let replyChain: JSX.Element | undefined;
         if (
             haveRendererForEvent(this.props.mxEvent, MatrixClientPeg.safeGet(), this.context.showHiddenEvents) &&
-            shouldDisplayReply(this.props.mxEvent)
+            shouldDisplayReply(this.props.mxEvent) &&
+            !this.props.isScrolling
         ) {
             replyChain = (
                 <ReplyChain
@@ -1405,6 +1411,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
             }
 
             default: {
+                const contextMenu = this.props.isScrolling ? null : this.renderContextMenu();
                 // Pinned, Room, Search
                 // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
                 return React.createElement(
@@ -1429,7 +1436,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
                         {ircPadlock}
                         {avatar}
                         <div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
-                            {this.renderContextMenu()}
+                            {contextMenu}
                             {groupTimestamp}
                             {groupPadlock}
                             {replyChain}
diff --git a/src/components/views/rooms/LinkPreviewGroup.tsx b/src/components/views/rooms/LinkPreviewGroup.tsx
index 69c98cb6c9a3e1fc79c59987e95fa4b88e524e26..6d0888dffa7952018e6bd30d04d6997fd323aa0f 100644
--- a/src/components/views/rooms/LinkPreviewGroup.tsx
+++ b/src/components/views/rooms/LinkPreviewGroup.tsx
@@ -25,9 +25,10 @@ interface IProps {
     links: string[]; // the URLs to be previewed
     mxEvent: MatrixEvent; // the Event associated with the preview
     onCancelClick(): void; // called when the preview's cancel ('hide') button is clicked
+    isScrolling?: boolean;
 }
 
-const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick }) => {
+const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, isScrolling }) => {
     const cli = useContext(MatrixClientContext);
     const [expanded, toggleExpanded] = useStateToggle();
     const [mediaVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId());
@@ -55,28 +56,30 @@ const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick }) =
     }
 
     return (
-        <div className="mx_LinkPreviewGroup">
-            {showPreviews.map(([link, preview], i) => (
-                <LinkPreviewWidget
-                    mediaVisible={mediaVisible}
-                    key={link}
-                    link={link}
-                    preview={preview}
-                    mxEvent={mxEvent}
-                >
-                    {i === 0 ? (
-                        <AccessibleButton
-                            className="mx_LinkPreviewGroup_hide"
-                            onClick={onCancelClick}
-                            aria-label={_t("timeline|url_preview|close")}
-                        >
-                            <CloseIcon width="20px" height="20px" />
-                        </AccessibleButton>
-                    ) : undefined}
-                </LinkPreviewWidget>
-            ))}
-            {toggleButton}
-        </div>
+        !isScrolling && (
+            <div className="mx_LinkPreviewGroup">
+                {showPreviews.map(([link, preview], i) => (
+                    <LinkPreviewWidget
+                        mediaVisible={mediaVisible}
+                        key={link}
+                        link={link}
+                        preview={preview}
+                        mxEvent={mxEvent}
+                    >
+                        {i === 0 ? (
+                            <AccessibleButton
+                                className="mx_LinkPreviewGroup_hide"
+                                onClick={onCancelClick}
+                                aria-label={_t("timeline|url_preview|close")}
+                            >
+                                <CloseIcon width="20px" height="20px" />
+                            </AccessibleButton>
+                        ) : undefined}
+                    </LinkPreviewWidget>
+                ))}
+                {toggleButton}
+            </div>
+        )
     );
 };
 
diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx
index f1fc224471cd00838253f65de5bad34da63b142b..c857a83ccf69ab0dbe1d01343b15af09a71b1fae 100644
--- a/src/events/EventTileFactory.tsx
+++ b/src/events/EventTileFactory.tsx
@@ -60,6 +60,7 @@ export interface EventTileTypeProps
         | "callEventGrouper"
         | "isSeeingThroughMessageHiddenForModeration"
         | "inhibitInteraction"
+        | "isScrolling"
     > {
     ref?: React.RefObject<any>; // `any` because it's effectively impossible to convince TS of a reasonable type
     timestamp?: JSX.Element;
@@ -278,6 +279,7 @@ export function renderTile(
         isSeeingThroughMessageHiddenForModeration,
         timestamp,
         inhibitInteraction,
+        isScrolling,
     } = props;
 
     switch (renderType) {
@@ -313,6 +315,7 @@ export function renderTile(
                 isSeeingThroughMessageHiddenForModeration,
                 timestamp,
                 inhibitInteraction,
+                isScrolling,
             });
     }
 }
diff --git a/yarn.lock b/yarn.lock
index 4bc72c36799f8eadddc48e88d7604fd6fef7b6a8..24ff31c3c7e8aff65c4f8fe0255b574af4fa6b98 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3720,15 +3720,16 @@
     classnames "^2.5.1"
     vaul "^1.0.0"
 
-"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm":
+"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm":
   version "0.0.0"
+  uid ""
 
 "@vector-im/matrix-wysiwyg@2.38.3":
   version "2.38.3"
   resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.3.tgz#cc54d8b3e9472bcd8e622126ba364ee31952cd8a"
   integrity sha512-fqo8P55Vc/t0vxpFar9RDJN5gKEjJmzrLo+O4piDbFda6VrRoqrWAtiu0Au0g6B4hRDPKIuFupk8v9Ja7q8Hvg==
   dependencies:
-    "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm"
+    "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm"
 
 "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1":
   version "1.14.1"
@@ -11060,6 +11061,11 @@ react-virtualized@^9.22.5:
     prop-types "^15.7.2"
     react-lifecycles-compat "^3.0.4"
 
+react-virtuoso@^4.12.7:
+  version "4.12.7"
+  resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.12.7.tgz#300f2585c61d213d4d422420f0d43ffc9674e6f5"
+  integrity sha512-njJp764he6Fi1p89PUW0k2kbyWu9w/y+MwdxmwK2kvdwwzVDbz2c2wMj5xdSruBFVgFTsI7Z85hxZR7aSHBrbQ==
+
 react@^19.0.0:
   version "19.1.0"
   resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75"