diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomList/RoomList.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomList/RoomList.stories.tsx/default-auto.png
new file mode 100644
index 0000000000..9cf03c0899
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomList/RoomList.stories.tsx/default-auto.png differ
diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.module.css b/packages/shared-components/src/room-list/RoomList/RoomList.module.css
new file mode 100644
index 0000000000..c444c8c1cd
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomList/RoomList.module.css
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2025 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.
+ */
+
+/**
+ * Room list container styles
+ */
+.roomList {
+ height: 100%;
+ width: 100%;
+}
diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.stories.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.stories.tsx
new file mode 100644
index 0000000000..a76ffe7e34
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomList/RoomList.stories.tsx
@@ -0,0 +1,87 @@
+/*
+ * 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 } from "storybook/test";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { RoomList, type RoomListViewState } from "./RoomList";
+import type { RoomListSnapshot, RoomListViewActions } from "../RoomListView";
+import { useMockedViewModel } from "../../viewmodel";
+import type { FilterId } from "../RoomListPrimaryFilters";
+import { renderAvatar, createGetRoomItemViewModel, mockRoomIds } from "../story-mocks";
+
+type RoomListStoryProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: any) => React.ReactElement };
+
+// Use first 10 room IDs for this story
+const storyRoomIds = mockRoomIds.slice(0, 10);
+
+// Wrapper component that creates a mocked ViewModel
+const RoomListWrapper = ({
+ onToggleFilter,
+ createChatRoom,
+ createRoom,
+ getRoomItemViewModel,
+ updateVisibleRooms,
+ renderAvatar: renderAvatarProp,
+ ...rest
+}: RoomListStoryProps): JSX.Element => {
+ const vm = useMockedViewModel(rest, {
+ onToggleFilter,
+ createChatRoom,
+ createRoom,
+ getRoomItemViewModel,
+ updateVisibleRooms,
+ });
+
+ return (
+
+
+
+ );
+};
+
+const mockFilterIds: FilterId[] = ["unread", "people"];
+
+const defaultRoomListState: RoomListViewState = {
+ activeRoomIndex: 0,
+ spaceId: "!space:server",
+ filterKeys: undefined,
+};
+
+const meta: Meta = {
+ title: "Room List/RoomList",
+ component: RoomListWrapper,
+ tags: ["autodocs"],
+ args: {
+ isLoadingRooms: false,
+ isRoomListEmpty: false,
+ filterIds: mockFilterIds,
+ activeFilterId: undefined,
+ roomIds: storyRoomIds,
+ roomListState: defaultRoomListState,
+ canCreateRoom: true,
+ onToggleFilter: fn(),
+ createChatRoom: fn(),
+ createRoom: fn(),
+ getRoomItemViewModel: createGetRoomItemViewModel(storyRoomIds),
+ updateVisibleRooms: fn(),
+ renderAvatar,
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx
new file mode 100644
index 0000000000..c5ee9b8b75
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx
@@ -0,0 +1,67 @@
+/*
+ * 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 { render, screen, fireEvent } from "@test-utils";
+import { VirtuosoMockContext } from "react-virtuoso";
+import { composeStories } from "@storybook/react-vite";
+import { describe, it, expect } from "vitest";
+
+import * as stories from "./RoomList.stories";
+
+const { Default } = composeStories(stories);
+
+const renderWithMockContext = (component: React.ReactElement): ReturnType => {
+ return render(component, {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+};
+
+describe("", () => {
+ it("renders Default story", () => {
+ const { container } = renderWithMockContext();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render the room list listbox", () => {
+ renderWithMockContext();
+ expect(screen.getByRole("listbox", { name: "Room list" })).toBeInTheDocument();
+ });
+
+ it("should render room items", () => {
+ renderWithMockContext();
+ const items = screen.getAllByRole("option");
+ expect(items.length).toBeGreaterThan(0);
+ });
+
+ it("should mark selected room with aria-selected true", () => {
+ renderWithMockContext();
+ const items = screen.getAllByRole("option");
+ // The first item (index 0) should be selected based on Default story (activeRoomIndex: 0)
+ expect(items[0]).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("should handle focus state correctly", () => {
+ renderWithMockContext();
+
+ const listbox = screen.getByRole("listbox", { name: "Room list" });
+ fireEvent.focus(listbox);
+
+ const items = screen.getAllByRole("option");
+ // First item should have tabIndex 0 (focusable) when list is focused
+ expect(items[0]).toHaveAttribute("tabIndex", "0");
+ });
+
+ it("should call updateVisibleRooms on render", () => {
+ renderWithMockContext();
+ expect(Default.args.updateVisibleRooms).toHaveBeenCalled();
+ });
+});
diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.tsx
new file mode 100644
index 0000000000..ee5f7b3961
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomList/RoomList.tsx
@@ -0,0 +1,197 @@
+/*
+ * 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, useMemo, useRef, type JSX, type ReactNode } from "react";
+import { type ScrollIntoViewLocation } from "react-virtuoso";
+import { isEqual } from "lodash";
+
+import { useViewModel } from "../../viewmodel";
+import { _t } from "../../utils/i18n";
+import { VirtualizedList, type VirtualizedListContext } from "../../utils/VirtualizedList";
+import { RoomListItemView } from "../RoomListItem";
+import type { RoomListViewModel } from "../RoomListView";
+
+/**
+ * Filter key type - opaque string type for filter identifiers
+ */
+export type FilterKey = string;
+
+/**
+ * State for the room list data (nested within RoomListSnapshot)
+ */
+export interface RoomListViewState {
+ /** Optional active room index for keyboard navigation */
+ activeRoomIndex?: number;
+ /** Space ID for context tracking */
+ spaceId?: string;
+ /** Active filter keys for context tracking */
+ filterKeys?: FilterKey[];
+}
+
+/**
+ * Props for the RoomList component
+ */
+export interface RoomListProps {
+ /**
+ * The view model containing all room list data and callbacks
+ */
+ vm: RoomListViewModel;
+
+ /**
+ * Render function for room avatar
+ * @param room - The opaque Room object from the client
+ */
+ renderAvatar: (room: any) => ReactNode;
+
+ /**
+ * Optional callback for keyboard key down events
+ */
+ onKeyDown?: (e: React.KeyboardEvent) => void;
+}
+
+/** Height of a single room list item in pixels */
+const ROOM_LIST_ITEM_HEIGHT = 48;
+
+/**
+ * Type for context used in ListView
+ */
+type Context = { spaceId: string; filterKeys: FilterKey[] | undefined };
+
+/**
+ * Amount to extend the top and bottom of the viewport by.
+ * From manual testing and user feedback 25 items is reported to be enough to avoid blank space
+ * when using the mouse wheel, and the trackpad scrolling at a slow to moderate speed where you
+ * can still see/read the content. Using the trackpad to sling through a large percentage of the
+ * list quickly will still show blank space. We would likely need to simplify the item content to
+ * improve this case.
+ */
+const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
+
+/**
+ * A virtualized list of rooms.
+ * This component provides efficient rendering of large room lists using virtualization,
+ * and renders RoomListItemView components for each room.
+ *
+ * @example
+ * ```tsx
+ * } />
+ * ```
+ */
+export function RoomList({ vm, renderAvatar, onKeyDown }: RoomListProps): JSX.Element {
+ const snapshot = useViewModel(vm);
+ const { roomListState, roomIds } = snapshot;
+ const activeRoomIndex = roomListState.activeRoomIndex;
+ const lastSpaceId = useRef(undefined);
+ const lastFilterKeys = useRef(undefined);
+ const roomCount = roomIds.length;
+
+ /**
+ * Callback when the visible range changes
+ * Notifies the view model which rooms are visible
+ */
+ const rangeChanged = useCallback(
+ (range: { startIndex: number; endIndex: number }) => {
+ vm.updateVisibleRooms(range.startIndex, range.endIndex);
+ },
+ [vm],
+ );
+
+ /**
+ * Get the item component for a specific index
+ * Gets the room's view model and passes it to RoomListItemView
+ */
+ const getItemComponent = useCallback(
+ (
+ index: number,
+ roomId: string,
+ context: VirtualizedListContext,
+ onFocus: (item: string, e: React.FocusEvent) => void,
+ ): JSX.Element => {
+ const isSelected = activeRoomIndex === index;
+ const roomItemVM = vm.getRoomItemViewModel(roomId);
+
+ // Item is focused when the list has focus AND this item's key matches tabIndexKey
+ // This matches the old RoomList implementation's roving tabindex pattern
+ const isFocused = context.focused && context.tabIndexKey === roomId;
+
+ return (
+
+ );
+ },
+ [activeRoomIndex, roomCount, renderAvatar, vm],
+ );
+
+ /**
+ * Get the key for a room item
+ * Since we're using virtualization, items are always room ID strings
+ */
+ const getItemKey = useCallback((item: string): string => {
+ return item;
+ }, []);
+
+ const context = useMemo(
+ () => ({ spaceId: roomListState.spaceId || "", filterKeys: roomListState.filterKeys }),
+ [roomListState.spaceId, roomListState.filterKeys],
+ );
+
+ /**
+ * Determine if we should scroll the active index into view
+ * This happens when the space or filters change
+ */
+ const scrollIntoViewOnChange = useCallback(
+ (params: {
+ context: VirtualizedListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
+ }): ScrollIntoViewLocation | null | undefined | false => {
+ const { spaceId, filterKeys } = params.context.context;
+ const shouldScrollIndexIntoView =
+ lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys);
+ lastFilterKeys.current = filterKeys;
+ lastSpaceId.current = spaceId;
+
+ if (shouldScrollIndexIntoView) {
+ return {
+ align: "start",
+ index: activeRoomIndex || 0,
+ behavior: "auto",
+ };
+ }
+ return false;
+ },
+ [activeRoomIndex],
+ );
+
+ return (
+ true}
+ rangeChanged={rangeChanged}
+ onKeyDown={onKeyDown}
+ increaseViewportBy={{
+ bottom: EXTENDED_VIEWPORT_HEIGHT,
+ top: EXTENDED_VIEWPORT_HEIGHT,
+ }}
+ />
+ );
+}
diff --git a/packages/shared-components/src/room-list/RoomList/__snapshots__/RoomList.test.tsx.snap b/packages/shared-components/src/room-list/RoomList/__snapshots__/RoomList.test.tsx.snap
new file mode 100644
index 0000000000..f14e886bb1
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomList/__snapshots__/RoomList.test.tsx.snap
@@ -0,0 +1,1277 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > renders Default story 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/packages/shared-components/src/room-list/RoomList/index.ts b/packages/shared-components/src/room-list/RoomList/index.ts
new file mode 100644
index 0000000000..0b0498d139
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomList/index.ts
@@ -0,0 +1,9 @@
+/*
+ * 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 { RoomList } from "./RoomList";
+export type { RoomListProps, RoomListViewState, FilterKey } from "./RoomList";
diff --git a/packages/shared-components/src/room-list/story-mocks.tsx b/packages/shared-components/src/room-list/story-mocks.tsx
new file mode 100644
index 0000000000..03450469c6
--- /dev/null
+++ b/packages/shared-components/src/room-list/story-mocks.tsx
@@ -0,0 +1,136 @@
+/*
+ * 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 { fn } from "storybook/test";
+
+import type { RoomListItemSnapshot } from "./RoomListItem";
+import { RoomNotifState } from "./RoomListItem/RoomNotifs";
+
+/**
+ * Mock avatar component for stories
+ */
+export const mockAvatar = (name: string): React.ReactElement => (
+
+ {name.substring(0, 2).toUpperCase()}
+
+);
+
+/**
+ * Render avatar function for stories
+ */
+export const renderAvatar = (room: any): React.ReactElement => {
+ return mockAvatar(room?.name || "Room");
+};
+
+/**
+ * Room names used for mock data
+ */
+const roomNames = [
+ "General",
+ "Random",
+ "Engineering",
+ "Design",
+ "Product",
+ "Marketing",
+ "Sales",
+ "Support",
+ "Announcements",
+ "Off-topic",
+ "Team Alpha",
+ "Team Beta",
+ "Project X",
+ "Project Y",
+ "Water Cooler",
+ "Feedback",
+ "Ideas",
+ "Bugs",
+ "Features",
+ "Releases",
+];
+
+/**
+ * Create a mock room item snapshot for stories
+ */
+export const createMockRoomSnapshot = (id: string, name: string, index: number): RoomListItemSnapshot => ({
+ id,
+ room: { name },
+ name,
+ isBold: index % 3 === 0,
+ messagePreview: index % 2 === 0 ? `Last message in ${name}` : undefined,
+ notification: {
+ hasAnyNotificationOrActivity: index % 5 === 0,
+ isUnsentMessage: false,
+ invited: false,
+ isMention: index % 5 === 0,
+ isActivityNotification: false,
+ isNotification: index % 5 === 0,
+ hasUnreadCount: index % 5 === 0,
+ count: index % 5 === 0 ? index : 0,
+ muted: false,
+ },
+ showMoreOptionsMenu: true,
+ showNotificationMenu: true,
+ isFavourite: false,
+ isLowPriority: false,
+ canInvite: true,
+ canCopyRoomLink: true,
+ canMarkAsRead: false,
+ canMarkAsUnread: true,
+ roomNotifState: RoomNotifState.AllMessages,
+});
+
+/**
+ * Create a mock getRoomItemViewModel function for stories
+ */
+export const createGetRoomItemViewModel = (roomIds: string[]): ((roomId: string) => any) => {
+ const viewModels = new Map();
+ roomIds.forEach((roomId, index) => {
+ const name = roomNames[index % roomNames.length];
+ const snapshot = createMockRoomSnapshot(roomId, name, index);
+
+ const mockViewModel = {
+ getSnapshot: () => snapshot,
+ subscribe: fn(),
+ unsubscribe: fn(),
+ onOpenRoom: fn(),
+ onMarkAsRead: fn(),
+ onMarkAsUnread: fn(),
+ onToggleFavorite: fn(),
+ onToggleLowPriority: fn(),
+ onInvite: fn(),
+ onCopyRoomLink: fn(),
+ onLeaveRoom: fn(),
+ onSetRoomNotifState: fn(),
+ };
+ viewModels.set(roomId, mockViewModel);
+ });
+
+ return (roomId: string) => viewModels.get(roomId);
+};
+
+/**
+ * Mock room IDs for different list sizes
+ */
+export const mockRoomIds = Array.from({ length: 20 }, (_, i) => `!room${i}:server`);
+export const smallListRoomIds = mockRoomIds.slice(0, 5);
+export const largeListRoomIds = Array.from({ length: 100 }, (_, i) => `!room${i}:server`);