diff --git a/res/css/_components.scss b/res/css/_components.scss
index 62bec5ad62..8958aee2fc 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -175,6 +175,7 @@
 @import "./views/rooms/_PresenceLabel.scss";
 @import "./views/rooms/_ReplyPreview.scss";
 @import "./views/rooms/_RoomBreadcrumbs.scss";
+@import "./views/rooms/_RoomBreadcrumbs2.scss";
 @import "./views/rooms/_RoomDropTarget.scss";
 @import "./views/rooms/_RoomHeader.scss";
 @import "./views/rooms/_RoomList.scss";
diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss
index 822a5ac399..502ed18a87 100644
--- a/res/css/structures/_LeftPanel2.scss
+++ b/res/css/structures/_LeftPanel2.scss
@@ -76,9 +76,9 @@ $roomListMinimizedWidth: 50px;
             }
 
             .mx_LeftPanel2_breadcrumbsContainer {
-                // TODO: Improve CSS for breadcrumbs (currently shoved into the view rather than placed)
                 width: 100%;
                 overflow: hidden;
+                margin-top: 8px;
             }
         }
 
diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss
new file mode 100644
index 0000000000..aa0b0ecb08
--- /dev/null
+++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss
@@ -0,0 +1,53 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+@keyframes breadcrumb-popin {
+  0% {
+      // Ideally we'd use `width` instead of `opacity`, but we only
+      // have 16 nanoseconds to render the frame, and width is expensive.
+      opacity: 0;
+      transform: scale(0);
+  }
+  100% {
+      opacity: 1;
+      transform: scale(1);
+  }
+}
+
+.mx_RoomBreadcrumbs2 {
+    // Create a flexbox for the crumbs
+    display: flex;
+    flex-direction: row;
+    align-items: flex-start;
+    width: 100%;
+
+    .mx_RoomBreadcrumbs2_crumb {
+        margin-right: 8px;
+        width: 32px;
+
+        // React loves to add elements, so only target the one we want to animate
+        &:first-child {
+            animation: breadcrumb-popin 0.3s;
+        }
+    }
+
+    .mx_RoomBreadcrumbs2_placeholder {
+        font-weight: 600;
+        font-size: $font-14px;
+        line-height: 32px; // specifically to match the height this is not scaled
+        height: 32px;
+    }
+}
diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx
index c66c0a6799..b42da0be09 100644
--- a/src/components/structures/LeftPanel2.tsx
+++ b/src/components/structures/LeftPanel2.tsx
@@ -26,7 +26,9 @@ import TopLeftMenuButton from "./TopLeftMenuButton";
 import { Action } from "../../dispatcher/actions";
 import { MatrixClientPeg } from "../../MatrixClientPeg";
 import BaseAvatar from '../views/avatars/BaseAvatar';
-import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
+import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2";
+import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
+import { UPDATE_EVENT } from "../../stores/AsyncStore";
 
 /*******************************************************************
  *   CAUTION                                                       *
@@ -43,6 +45,7 @@ interface IProps {
 interface IState {
     searchExpanded: boolean;
     searchFilter: string; // TODO: Move search into room list?
+    showBreadcrumbs: boolean;
 }
 
 export default class LeftPanel2 extends React.Component<IProps, IState> {
@@ -60,7 +63,14 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
         this.state = {
             searchExpanded: false,
             searchFilter: "",
+            showBreadcrumbs: BreadcrumbsStore.instance.visible,
         };
+
+        BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
+    }
+
+    public componentWillUnmount() {
+        BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
     }
 
     private onSearch = (term: string): void => {
@@ -85,6 +95,13 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
         }
     }
 
+    private onBreadcrumbsUpdate = () => {
+        const newVal = BreadcrumbsStore.instance.visible;
+        if (newVal !== this.state.showBreadcrumbs) {
+            this.setState({showBreadcrumbs: newVal});
+        }
+    };
+
     private renderHeader(): React.ReactNode {
         // TODO: Update when profile info changes
         // TODO: Presence
@@ -100,6 +117,16 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
             displayName = myUser.rawDisplayName;
             avatarUrl = myUser.avatarUrl;
         }
+
+        let breadcrumbs;
+        if (this.state.showBreadcrumbs) {
+            breadcrumbs = (
+                <div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer">
+                    <RoomBreadcrumbs2 />
+                </div>
+            );
+        }
+
         return (
             <div className="mx_LeftPanel2_userHeader">
                 <div className="mx_LeftPanel2_headerRow">
@@ -116,9 +143,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
                     </span>
                     <span className="mx_LeftPanel2_userName">{displayName}</span>
                 </div>
-                <div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer">
-                    <RoomBreadcrumbs />
-                </div>
+                {breadcrumbs}
             </div>
         );
     }
@@ -152,7 +177,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
             onBlur={() => {/*TODO*/}}
         />;
 
-        // TODO: Breadcrumbs
         // TODO: Conference handling / calls
 
         const containerClasses = classNames({
diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx
new file mode 100644
index 0000000000..195757ccf0
--- /dev/null
+++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx
@@ -0,0 +1,90 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
+import AccessibleButton from "../elements/AccessibleButton";
+import RoomAvatar from "../avatars/RoomAvatar";
+import { _t } from "../../../languageHandler";
+import { Room } from "matrix-js-sdk/src/models/room";
+import defaultDispatcher from "../../../dispatcher/dispatcher";
+import Analytics from "../../../Analytics";
+import { UPDATE_EVENT } from "../../../stores/AsyncStore";
+
+/*******************************************************************
+ *   CAUTION                                                       *
+ *******************************************************************
+ * This is a work in progress implementation and isn't complete or *
+ * even useful as a component. Please avoid using it until this    *
+ * warning disappears.                                             *
+ *******************************************************************/
+
+interface IProps {
+}
+
+interface IState {
+}
+
+export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState> {
+    private isMounted = true;
+
+    constructor(props: IProps) {
+        super(props);
+
+        BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
+    }
+
+    public componentWillUnmount() {
+        this.isMounted = false;
+        BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
+    }
+
+    private onBreadcrumbsUpdate = () => {
+        if (!this.isMounted) return;
+        this.forceUpdate(); // we have no state, so this is the best we can do
+    };
+
+    private viewRoom = (room: Room, index: number) => {
+        Analytics.trackEvent("Breadcrumbs", "click_node", index);
+        defaultDispatcher.dispatch({action: "view_room", room_id: room.roomId});
+    };
+
+    public render(): React.ReactElement {
+        // TODO: Decorate crumbs with icons
+        const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
+            return (
+                <AccessibleButton
+                    className="mx_RoomBreadcrumbs2_crumb"
+                    key={r.roomId}
+                    onClick={() => this.viewRoom(r, i)}
+                    aria-label={_t("Room %(name)s", {name: r.name})}
+                >
+                    <RoomAvatar room={r} width={32} height={32}/>
+                </AccessibleButton>
+            )
+        });
+
+        if (tiles.length === 0) {
+            tiles.push(
+                <div className="mx_RoomBreadcrumbs2_placeholder">
+                    {_t("No recently visited rooms")}
+                </div>
+            );
+        }
+
+        return <div className='mx_RoomBreadcrumbs2'>{tiles}</div>;
+    }
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index cf6dc2431a..75caf5b593 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1069,6 +1069,7 @@
     "Replying": "Replying",
     "Room %(name)s": "Room %(name)s",
     "Recent rooms": "Recent rooms",
+    "No recently visited rooms": "No recently visited rooms",
     "No rooms to show": "No rooms to show",
     "Unnamed room": "Unnamed room",
     "World readable": "World readable",
diff --git a/src/settings/SettingsStore.js b/src/settings/SettingsStore.js
index 4b18a27c6c..dcdde46631 100644
--- a/src/settings/SettingsStore.js
+++ b/src/settings/SettingsStore.js
@@ -181,6 +181,8 @@ export default class SettingsStore {
      * @param {String} roomId The room ID to monitor for changes in. Use null for all rooms.
      */
     static monitorSetting(settingName, roomId) {
+        roomId = roomId || null; // the thing wants null specifically to work, so appease it.
+
         if (!this._monitors[settingName]) this._monitors[settingName] = {};
 
         const registerWatcher = () => {
diff --git a/src/stores/AsyncStoreWithClient.ts b/src/stores/AsyncStoreWithClient.ts
new file mode 100644
index 0000000000..ce7fd45eec
--- /dev/null
+++ b/src/stores/AsyncStoreWithClient.ts
@@ -0,0 +1,53 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixClient } from "matrix-js-sdk/src/client";
+import { AsyncStore } from "./AsyncStore";
+import { ActionPayload } from "../dispatcher/payloads";
+
+
+export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<T> {
+    protected matrixClient: MatrixClient;
+
+    protected abstract async onAction(payload: ActionPayload);
+
+    protected async onReady() {
+        // Default implementation is to do nothing.
+    }
+
+    protected async onNotReady() {
+        // Default implementation is to do nothing.
+    }
+
+    protected async onDispatch(payload: ActionPayload) {
+        await this.onAction(payload);
+
+        if (payload.action === 'MatrixActions.sync') {
+            // Filter out anything that isn't the first PREPARED sync.
+            if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
+                return;
+            }
+
+            this.matrixClient = payload.matrixClient;
+            await this.onReady();
+        } else if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
+            if (this.matrixClient) {
+                await this.onNotReady();
+                this.matrixClient = null;
+            }
+        }
+    }
+}
diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts
new file mode 100644
index 0000000000..783b38e62f
--- /dev/null
+++ b/src/stores/BreadcrumbsStore.ts
@@ -0,0 +1,154 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import SettingsStore, { SettingLevel } from "../settings/SettingsStore";
+import { Room } from "matrix-js-sdk/src/models/room";
+import { ActionPayload } from "../dispatcher/payloads";
+import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
+import defaultDispatcher from "../dispatcher/dispatcher";
+import { arrayHasDiff } from "../utils/arrays";
+
+const MAX_ROOMS = 20; // arbitrary
+const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up
+
+interface IState {
+    enabled?: boolean;
+    rooms?: Room[];
+}
+
+export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
+    private static internalInstance = new BreadcrumbsStore();
+
+    private waitingRooms: { roomId: string, addedTs: number }[] = [];
+
+    private constructor() {
+        super(defaultDispatcher);
+
+        SettingsStore.monitorSetting("breadcrumb_rooms", null);
+        SettingsStore.monitorSetting("breadcrumbs", null);
+    }
+
+    public static get instance(): BreadcrumbsStore {
+        return BreadcrumbsStore.internalInstance;
+    }
+
+    public get rooms(): Room[] {
+        return this.state.rooms || [];
+    }
+
+    public get visible(): boolean {
+        return this.state.enabled;
+    }
+
+    protected async onAction(payload: ActionPayload) {
+        if (!this.matrixClient) return;
+
+        if (payload.action === 'setting_updated') {
+            if (payload.settingName === 'breadcrumb_rooms') {
+                await this.updateRooms();
+            } else if (payload.settingName === 'breadcrumbs') {
+                await this.updateState({enabled: SettingsStore.getValue("breadcrumbs", null)});
+            }
+        } else if (payload.action === 'view_room') {
+            if (payload.auto_join && !this.matrixClient.getRoom(payload.room_id)) {
+                // Queue the room instead of pushing it immediately. We're probably just
+                // waiting for a room join to complete.
+                this.waitingRooms.push({roomId: payload.room_id, addedTs: Date.now()});
+            } else {
+                await this.appendRoom(this.matrixClient.getRoom(payload.room_id));
+            }
+        }
+    }
+
+    protected async onReady() {
+        await this.updateRooms();
+        await this.updateState({enabled: SettingsStore.getValue("breadcrumbs", null)});
+
+        this.matrixClient.on("Room.myMembership", this.onMyMembership);
+        this.matrixClient.on("Room", this.onRoom);
+    }
+
+    protected async onNotReady() {
+        this.matrixClient.removeListener("Room.myMembership", this.onMyMembership);
+        this.matrixClient.removeListener("Room", this.onRoom);
+    }
+
+    private onMyMembership = async (room: Room) => {
+        // We turn on breadcrumbs by default once the user has at least 1 room to show.
+        if (!this.state.enabled) {
+            await SettingsStore.setValue("breadcrumbs", null, SettingLevel.ACCOUNT, true);
+        }
+    };
+
+    private onRoom = async (room: Room) => {
+        const waitingRoom = this.waitingRooms.find(r => r.roomId === room.roomId);
+        if (!waitingRoom) return;
+        this.waitingRooms.splice(this.waitingRooms.indexOf(waitingRoom), 1);
+
+        if ((Date.now() - waitingRoom.addedTs) > AUTOJOIN_WAIT_THRESHOLD_MS) return; // Too long ago.
+        await this.appendRoom(room);
+    };
+
+    private async updateRooms() {
+        let roomIds = SettingsStore.getValue("breadcrumb_rooms");
+        if (!roomIds || roomIds.length === 0) roomIds = [];
+
+        const rooms = roomIds.map(r => this.matrixClient.getRoom(r)).filter(r => !!r);
+        const currentRooms = this.state.rooms || [];
+        if (!arrayHasDiff(rooms, currentRooms)) return; // no change (probably echo)
+        await this.updateState({rooms});
+    }
+
+    private async appendRoom(room: Room) {
+        const rooms = this.state.rooms.slice(); // cheap clone
+
+        // If the room is upgraded, use that room instead. We'll also splice out
+        // any children of the room.
+        const history = this.matrixClient.getRoomUpgradeHistory(room.roomId);
+        if (history.length > 1) {
+            room = history[history.length - 1]; // Last room is most recent in history
+
+            // Take out any room that isn't the most recent room
+            for (let i = 0; i < history.length - 1; i++) {
+                const idx = rooms.findIndex(r => r.roomId === history[i].roomId);
+                if (idx !== -1) rooms.splice(idx, 1);
+            }
+        }
+
+        // Remove the existing room, if it is present
+        const existingIdx = rooms.findIndex(r => r.roomId === room.roomId);
+        if (existingIdx !== -1) {
+            rooms.splice(existingIdx, 1);
+        }
+
+        // Splice the room to the start of the list
+        rooms.splice(0, 0, room);
+
+        if (rooms.length > MAX_ROOMS) {
+            // This looks weird, but it's saying to start at the MAX_ROOMS point in the
+            // list and delete everything after it.
+            rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
+        }
+
+        // Update the breadcrumbs
+        await this.updateState({rooms});
+        const roomIds = rooms.map(r => r.roomId);
+        if (roomIds.length > 0) {
+            await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
+        }
+    }
+
+}