diff --git a/res/css/_components.scss b/res/css/_components.scss
index de4c1c677c..66af2ba00f 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -19,6 +19,7 @@
 @import "./structures/_NotificationPanel.scss";
 @import "./structures/_RightPanel.scss";
 @import "./structures/_RoomDirectory.scss";
+@import "./structures/_RoomSearch.scss";
 @import "./structures/_RoomStatusBar.scss";
 @import "./structures/_RoomSubList.scss";
 @import "./structures/_RoomView.scss";
diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss
index d335df305f..d9a2b1dd5c 100644
--- a/res/css/structures/_LeftPanel2.scss
+++ b/res/css/structures/_LeftPanel2.scss
@@ -88,7 +88,44 @@ $roomListMinimizedWidth: 50px;
         }
 
         .mx_LeftPanel2_filterContainer {
-            // TODO: Improve CSS for filtering and its input
+            margin-left: 12px;
+            margin-right: 12px;
+
+            // Create a flexbox to organize the inputs
+            display: flex;
+            align-items: center;
+
+            .mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton {
+                // Cheaty way to return the occupied space to the filter input
+                margin: 0;
+                width: 0;
+
+                // Don't forget to hide the masked ::before icon
+                visibility: hidden;
+            }
+
+            .mx_LeftPanel2_exploreButton {
+                width: 28px;
+                height: 28px;
+                border-radius: 20px;
+                background-color: #fff; // TODO: Variable and theme
+                position: relative;
+                margin-left: 8px;
+
+                &::before {
+                    content: '';
+                    position: absolute;
+                    top: 6px;
+                    left: 6px;
+                    width: 16px;
+                    height: 16px;
+                    mask-image: url('$(res)/img/feather-customised/compass.svg');
+                    mask-position: center;
+                    mask-size: contain;
+                    mask-repeat: no-repeat;
+                    background: $primary-fg-color;
+                }
+            }
         }
 
         .mx_LeftPanel2_actualRoomListContainer {
diff --git a/res/css/structures/_RoomSearch.scss b/res/css/structures/_RoomSearch.scss
new file mode 100644
index 0000000000..d078031090
--- /dev/null
+++ b/res/css/structures/_RoomSearch.scss
@@ -0,0 +1,70 @@
+/*
+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.
+*/
+
+// Note: this component expects to be contained within a flexbox
+.mx_RoomSearch {
+    flex: 1;
+    border-radius: 20px;
+    background-color: #fff; // TODO: Variable & theme
+    height: 26px;
+    padding: 2px;
+
+    // Create a flexbox for the icons (easier to manage)
+    display: flex;
+    align-items: center;
+
+    .mx_RoomSearch_icon {
+        width: 16px;
+        height: 16px;
+        mask: url('$(res)/img/feather-customised/search-input.svg');
+        mask-repeat: no-repeat;
+        background: $primary-fg-color;
+        margin-left: 7px;
+    }
+
+    .mx_RoomSearch_input {
+        border: none !important; // !important to override default app-wide styles
+        flex: 1 !important; // !important to override default app-wide styles
+        color: $primary-fg-color !important; // !important to override default app-wide styles
+        padding: 0;
+        height: 100%;
+        width: 100%;
+        font-size: $font-12px;
+        line-height: $font-16px;
+
+        &:not(.mx_RoomSearch_inputExpanded)::placeholder {
+            color: $primary-fg-color !important; // !important to override default app-wide styles
+        }
+    }
+
+    &.mx_RoomSearch_expanded {
+        .mx_RoomSearch_clearButton {
+            width: 16px;
+            height: 16px;
+            mask-image: url('$(res)/img/feather-customised/x.svg');
+            mask-position: center;
+            mask-size: contain;
+            mask-repeat: no-repeat;
+            background: $primary-fg-color;
+            margin-right: 8px;
+        }
+    }
+
+    .mx_RoomSearch_clearButton {
+        width: 0;
+        height: 0;
+    }
+}
diff --git a/res/img/feather-customised/compass.svg b/res/img/feather-customised/compass.svg
new file mode 100644
index 0000000000..3296260803
--- /dev/null
+++ b/res/img/feather-customised/compass.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-compass"><circle cx="12" cy="12" r="10"></circle><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon></svg>
\ No newline at end of file
diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx
index ff8d35a114..209f219598 100644
--- a/src/components/structures/HomePage.tsx
+++ b/src/components/structures/HomePage.tsx
@@ -22,9 +22,10 @@ import { _t } from "../../languageHandler";
 import SdkConfig from "../../SdkConfig";
 import * as sdk from "../../index";
 import dis from "../../dispatcher/dispatcher";
+import { Action } from "../../dispatcher/actions";
 
 const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
-const onClickExplore = () => dis.dispatch({action: 'view_room_directory'});
+const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
 const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
 
 const HomePage = () => {
diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js
index 05cd97df2a..bae69b5631 100644
--- a/src/components/structures/LeftPanel.js
+++ b/src/components/structures/LeftPanel.js
@@ -252,7 +252,7 @@ const LeftPanel = createReactClass({
         if (!this.props.collapsed) {
             exploreButton = (
                 <div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
-                    <AccessibleButton onClick={() => dis.dispatch({action: 'view_room_directory'})}>{_t("Explore")}</AccessibleButton>
+                    <AccessibleButton onClick={() => dis.fire(Action.ViewRoomDirectory)}>{_t("Explore")}</AccessibleButton>
                 </div>
             );
         }
diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx
index 2fd8612ff5..f417dc99b1 100644
--- a/src/components/structures/LeftPanel2.tsx
+++ b/src/components/structures/LeftPanel2.tsx
@@ -18,16 +18,16 @@ import * as React from "react";
 import TagPanel from "./TagPanel";
 import classNames from "classnames";
 import dis from "../../dispatcher/dispatcher";
-import AccessibleButton from "../views/elements/AccessibleButton";
 import { _t } from "../../languageHandler";
 import SearchBox from "./SearchBox";
 import RoomList2 from "../views/rooms/RoomList2";
-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 UserMenuButton from "./UserMenuButton";
+import RoomSearch from "./RoomSearch";
+import AccessibleButton from "../views/elements/AccessibleButton";
 
 /*******************************************************************
  *   CAUTION                                                       *
@@ -42,7 +42,6 @@ interface IProps {
 }
 
 interface IState {
-    searchExpanded: boolean;
     searchFilter: string; // TODO: Move search into room list?
 }
 
@@ -58,7 +57,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
         super(props);
 
         this.state = {
-            searchExpanded: false,
             searchFilter: "",
         };
     }
@@ -67,24 +65,10 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
         this.setState({searchFilter: term});
     };
 
-    private onSearchCleared = (source: string): void => {
-        if (source === "keyboard") {
-            dis.fire(Action.FocusComposer);
-        }
-        this.setState({searchExpanded: false});
-    }
-
-    private onSearchFocus = (): void => {
-        this.setState({searchExpanded: true});
+    private onExplore = () => {
+        dis.fire(Action.ViewRoomDirectory);
     };
 
-    private onSearchBlur = (event: FocusEvent): void => {
-        const target = event.target as HTMLInputElement;
-        if (target.value.length === 0) {
-            this.setState({searchExpanded: false});
-        }
-    }
-
     private renderHeader(): React.ReactNode {
         // TODO: Update when profile info changes
         // TODO: Presence
@@ -126,6 +110,22 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
         );
     }
 
+    private renderSearchExplore(): React.ReactNode {
+        // TODO: Collapsed support
+
+        return (
+            <div className="mx_LeftPanel2_filterContainer">
+                <RoomSearch onQueryUpdate={this.onSearch} />
+                <AccessibleButton
+                    tabIndex={-1}
+                    className='mx_LeftPanel2_exploreButton'
+                    onClick={this.onExplore}
+                    alt={_t("Explore rooms")}
+                />
+            </div>
+        );
+    }
+
     public render(): React.ReactNode {
         const tagPanel = (
             <div className="mx_LeftPanel2_tagPanelContainer">
@@ -133,18 +133,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
             </div>
         );
 
-        const searchBox = (<SearchBox
-            className="mx_LeftPanel2_filterRoomsSearch"
-            enableRoomSearchFocus={true}
-            blurredPlaceholder={_t('Filter')}
-            placeholder={_t('Filter rooms…')}
-            onKeyDown={() => {/*TODO*/}}
-            onSearch={this.onSearch}
-            onCleared={this.onSearchCleared}
-            onFocus={this.onSearchFocus}
-            onBlur={this.onSearchBlur}
-            collapsed={false}/>); // TODO: Collapsed support
-
         // TODO: Improve props for RoomList2
         const roomList = <RoomList2
             onKeyDown={() => {/*TODO*/}}
@@ -167,14 +155,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
                 {tagPanel}
                 <aside className="mx_LeftPanel2_roomListContainer">
                     {this.renderHeader()}
-                    <div
-                        className="mx_LeftPanel2_filterContainer"
-                        onKeyDown={() => {/*TODO*/}}
-                        onFocus={() => {/*TODO*/}}
-                        onBlur={() => {/*TODO*/}}
-                    >
-                        {searchBox}
-                    </div>
+                    {this.renderSearchExplore()}
                     <div className="mx_LeftPanel2_actualRoomListContainer">
                         {roomList}
                     </div>
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index e08381d8fa..634e13b103 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -624,7 +624,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
                 Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
                 break;
             }
-            case 'view_room_directory': {
+            case Action.ViewRoomDirectory: {
                 const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
                 Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
                     'mx_RoomDirectory_dialogWrapper', false, true);
@@ -1611,9 +1611,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
                 action: 'require_registration',
             });
         } else if (screen === 'directory') {
-            dis.dispatch({
-                action: 'view_room_directory',
-            });
+            dis.fire(Action.ViewRoomDirectory);
         } else if (screen === 'groups') {
             dis.dispatch({
                 action: 'view_my_groups',
diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx
new file mode 100644
index 0000000000..671cd89e08
--- /dev/null
+++ b/src/components/structures/RoomSearch.tsx
@@ -0,0 +1,143 @@
+/*
+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 * as React from "react";
+import { createRef } from "react";
+import classNames from "classnames";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { _t } from "../../languageHandler";
+import { ActionPayload } from "../../dispatcher/payloads";
+import { throttle } from 'lodash';
+import { Key } from "../../Keyboard";
+import AccessibleButton from "../views/elements/AccessibleButton";
+import { Action } from "../../dispatcher/actions";
+
+/*******************************************************************
+ *   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 {
+    onQueryUpdate: (newQuery: string) => void;
+}
+
+interface IState {
+    query: string;
+    focused: boolean;
+}
+
+export default class RoomSearch extends React.PureComponent<IProps, IState> {
+    private dispatcherRef: string;
+    private inputRef: React.RefObject<HTMLInputElement> = createRef();
+
+    constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            query: "",
+            focused: false,
+        };
+
+        this.dispatcherRef = defaultDispatcher.register(this.onAction);
+    }
+
+    public componentWillUnmount() {
+        defaultDispatcher.unregister(this.dispatcherRef);
+    }
+
+    private onAction = (payload: ActionPayload) => {
+        if (payload.action === 'view_room' && payload.clear_search) {
+            this.clearInput();
+        } else if (payload.action === 'focus_room_filter' && this.inputRef.current) {
+            this.inputRef.current.focus();
+        }
+    };
+
+    private clearInput = () => {
+        if (!this.inputRef.current) return;
+        this.inputRef.current.value = "";
+        this.onChange();
+    };
+
+    private onChange = () => {
+        if (!this.inputRef.current) return;
+        this.setState({query: this.inputRef.current.value});
+        this.onSearchUpdated();
+    };
+
+    // it wants this at the top of the file, but we know better
+    // tslint:disable-next-line
+    private onSearchUpdated = throttle(() => {
+            // We can't use the state variable because it can lag behind the input.
+            // The lag is most obvious when deleting/clearing text with the keyboard.
+            this.props.onQueryUpdate(this.inputRef.current.value);
+        }, 200, {trailing: true, leading: true},
+    );
+
+    private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => {
+        this.setState({focused: true});
+        ev.target.select();
+    };
+
+    private onBlur = () => {
+        this.setState({focused: false});
+    };
+
+    private onKeyDown = (ev: React.KeyboardEvent) => {
+        if (ev.key === Key.ESCAPE) {
+            this.clearInput();
+            defaultDispatcher.fire(Action.FocusComposer);
+        }
+    };
+
+    public render(): React.ReactNode {
+        const classes = classNames({
+            'mx_RoomSearch': true,
+            'mx_RoomSearch_expanded': this.state.query || this.state.focused,
+        });
+
+        const inputClasses = classNames({
+            'mx_RoomSearch_input': true,
+            'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
+        });
+
+        return (
+            <div className={classes}>
+                <div className='mx_RoomSearch_icon'/>
+                <input
+                    type="text"
+                    ref={this.inputRef}
+                    className={inputClasses}
+                    value={this.state.query}
+                    onFocus={this.onFocus}
+                    onBlur={this.onBlur}
+                    onChange={this.onChange}
+                    onKeyDown={this.onKeyDown}
+                    placeholder={_t("Search")}
+                    autoComplete="off"
+                />
+                <AccessibleButton
+                    tabIndex={-1}
+                    className='mx_RoomSearch_clearButton'
+                    onClick={this.clearInput}
+                />
+            </div>
+        );
+    }
+}
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index 0ff997ee09..ab3da035c4 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -1458,9 +1458,7 @@ export default createReactClass({
         // using /leave rather than /join. In the short term though, we
         // just ignore them.
         // https://github.com/vector-im/vector-web/issues/1134
-        dis.dispatch({
-            action: 'view_room_directory',
-        });
+        dis.fire(Action.ViewRoomDirectory);
     },
 
     onSearchClick: function() {
diff --git a/src/components/views/elements/RoomDirectoryButton.js b/src/components/views/elements/RoomDirectoryButton.js
index d0bff4beeb..e9de6f8d15 100644
--- a/src/components/views/elements/RoomDirectoryButton.js
+++ b/src/components/views/elements/RoomDirectoryButton.js
@@ -18,11 +18,12 @@ import React from 'react';
 import * as sdk from '../../../index';
 import PropTypes from 'prop-types';
 import { _t } from '../../../languageHandler';
+import {Action} from "../../../dispatcher/actions";
 
 const RoomDirectoryButton = function(props) {
     const ActionButton = sdk.getComponent('elements.ActionButton');
     return (
-        <ActionButton action="view_room_directory"
+        <ActionButton action={Action.ViewRoomDirectory}
             mouseOverAction={props.callout ? "callout_room_directory" : null}
             label={_t("Room directory")}
             iconPath={require("../../../../res/img/icons-directory.svg")}
diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index c9b5d9e3ad..5f7ca1293c 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -40,6 +40,11 @@ export enum Action {
      */
     ViewUserSettings = "view_user_settings",
 
+    /**
+     * Opens the room directory. No additional payload information required.
+     */
+    ViewRoomDirectory = "view_room_directory",
+
     /**
      * Sets the current tooltip. Should be use with ViewTooltipPayload.
      */