diff --git a/playwright/e2e/pinned-messages/index.ts b/playwright/e2e/pinned-messages/index.ts
index 5e61b11e857a2aba984979851ed3d32770278fa3..bb7705ba98801384088638538bad0ecb83c58298 100644
--- a/playwright/e2e/pinned-messages/index.ts
+++ b/playwright/e2e/pinned-messages/index.ts
@@ -168,9 +168,8 @@ export class Helpers {
 
     /**
      * Return the right panel
-     * @private
      */
-    private getRightPanel() {
+    public getRightPanel() {
         return this.page.locator("#mx_RightPanel");
     }
 
@@ -183,7 +182,6 @@ export class Helpers {
         await expect(rightPanel.getByRole("heading", { name: "Pinned messages" })).toHaveText(
             `${messages.length} Pinned messages`,
         );
-        await expect(rightPanel).toMatchScreenshot(`pinned-messages-list-messages-${messages.length}.png`);
 
         const list = rightPanel.getByRole("list");
         await expect(list.getByRole("listitem")).toHaveCount(messages.length);
@@ -243,6 +241,36 @@ export class Helpers {
         await item.getByRole("button").click();
         await this.page.getByRole("menu", { name: "Open menu" }).getByRole("menuitem", { name: "Unpin" }).click();
     }
+
+    /**
+     * Return the banner
+     * @private
+     */
+    public getBanner() {
+        return this.page.getByTestId("pinned-message-banner");
+    }
+
+    /**
+     * Assert that the banner contains the given message
+     * @param msg
+     */
+    async assertMessageInBanner(msg: string) {
+        await expect(this.getBanner().getByText(msg)).toBeVisible();
+    }
+
+    /**
+     * Return the view all button
+     */
+    public getViewAllButton() {
+        return this.page.getByRole("button", { name: "View all" });
+    }
+
+    /**
+     * Return the close list button
+     */
+    public getCloseListButton() {
+        return this.page.getByRole("button", { name: "Close list" });
+    }
 }
 
 export { expect };
diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts
index 53f657ea7fa872e2534a923a5e15f7a90a68d96e..339c3b1f0ec2dfcf214cd7e0e4fb38c085ead75d 100644
--- a/playwright/e2e/pinned-messages/pinned-messages.spec.ts
+++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts
@@ -48,6 +48,7 @@ test.describe("Pinned messages", () => {
         await util.openRoomInfo();
         await util.openPinnedMessagesList();
         await util.assertPinnedMessagesList(["Msg1", "Msg2", "Msg4"]);
+        await expect(util.getRightPanel()).toMatchScreenshot(`pinned-messages-list-pin-3.png`);
     });
 
     test("should unpin one message", async ({ page, app, room1, util }) => {
@@ -59,6 +60,7 @@ test.describe("Pinned messages", () => {
         await util.openPinnedMessagesList();
         await util.unpinMessageFromMessageList("Msg2");
         await util.assertPinnedMessagesList(["Msg1", "Msg4"]);
+        await expect(util.getRightPanel()).toMatchScreenshot(`pinned-messages-list-unpin-2.png`);
         await util.backPinnedMessagesList();
         await util.assertPinnedCountInRoomInfo(2);
     });
@@ -87,4 +89,65 @@ test.describe("Pinned messages", () => {
         await util.pinMessagesFromQuickActions(["Msg1"], true);
         await util.assertPinnedCountInRoomInfo(0);
     });
+
+    test("should display one message in the banner", async ({ page, app, room1, util }) => {
+        await util.goTo(room1);
+        await util.receiveMessages(room1, ["Msg1"]);
+        await util.pinMessages(["Msg1"]);
+        await util.assertMessageInBanner("Msg1");
+        await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-1-Msg1.png");
+    });
+
+    test("should display 2 messages in the banner", async ({ page, app, room1, util }) => {
+        await util.goTo(room1);
+        await util.receiveMessages(room1, ["Msg1", "Msg2"]);
+        await util.pinMessages(["Msg1", "Msg2"]);
+
+        await util.assertMessageInBanner("Msg1");
+        await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg1.png");
+
+        await util.getBanner().click();
+        await util.assertMessageInBanner("Msg2");
+        await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg2.png");
+
+        await util.getBanner().click();
+        await util.assertMessageInBanner("Msg1");
+        await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg1.png");
+    });
+
+    test("should display 4 messages in the banner", async ({ page, app, room1, util }) => {
+        await util.goTo(room1);
+        await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
+        await util.pinMessages(["Msg1", "Msg2", "Msg3", "Msg4"]);
+
+        for (const msg of ["Msg1", "Msg4", "Msg3", "Msg2"]) {
+            await util.assertMessageInBanner(msg);
+            await expect(util.getBanner()).toMatchScreenshot(`pinned-message-banner-4-${msg}.png`);
+            await util.getBanner().click();
+        }
+    });
+
+    test("should open the pinned messages list from the banner", async ({ page, app, room1, util }) => {
+        await util.goTo(room1);
+        await util.receiveMessages(room1, ["Msg1", "Msg2"]);
+        await util.pinMessages(["Msg1", "Msg2"]);
+
+        await util.getViewAllButton().click();
+        await util.assertPinnedMessagesList(["Msg1", "Msg2"]);
+        await expect(util.getRightPanel()).toMatchScreenshot("pinned-message-banner-2.png");
+
+        await expect(util.getCloseListButton()).toBeVisible();
+    });
+
+    test("banner should listen to pinned message list", async ({ page, app, room1, util }) => {
+        await util.goTo(room1);
+        await util.receiveMessages(room1, ["Msg1", "Msg2"]);
+        await util.pinMessages(["Msg1", "Msg2"]);
+
+        await expect(util.getViewAllButton()).toBeVisible();
+
+        await util.openRoomInfo();
+        await util.openPinnedMessagesList();
+        await expect(util.getCloseListButton()).toBeVisible();
+    });
 });
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-1-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-1-Msg1-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..d6892c5bbff22d3ae7c10bd62f3de57ebbc44189
Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-1-Msg1-linux.png differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg1-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..153ad2d07f0b9febf69e9c46a38004a2b7cd7516
Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg1-linux.png differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg2-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..98396be1f829c1fbc0cca1ee16595c6979919759
Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg2-linux.png differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..f583649aaa95f77fb5d5072f6b83e36f771a78b6
Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-linux.png differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg1-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..8169ca9cfd14b9ae5ab628bc8fd8c2c281e3086e
Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg1-linux.png differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg2-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..7b6c9948212df8b8cdcd71027b06ae0b4debdcd4
Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg2-linux.png differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg3-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg3-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..dfec23cb59097a8272ed2f0b0bb50bada67f921c
Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg3-linux.png differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg4-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg4-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..72abbbb2606cd0b0b5670ae7e146ff8e7d436bd9
Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg4-linux.png differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-2-linux.png
deleted file mode 100644
index 82666b0d95ff5d0728af128f5f1e66757199a8df..0000000000000000000000000000000000000000
Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-2-linux.png and /dev/null differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-3-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-3-linux.png
deleted file mode 100644
index 98e804d897d1de7194d1c924ef1f3170f44e81f2..0000000000000000000000000000000000000000
Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-3-linux.png and /dev/null differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-pin-3-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-pin-3-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..b3176cc698a0d319735348abd2845df74a28b680
Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-pin-3-linux.png differ
diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-unpin-2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-unpin-2-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..4eede005d886b1dc170f92b78cc32ae3692362ab
Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-unpin-2-linux.png differ
diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index 96c285bc0a6c320f117d39ecb06768719da0dddb..bfcab19879d104c0bd4033e668df8d4fad03c085 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -298,6 +298,7 @@
 @import "./views/rooms/_NewRoomIntro.pcss";
 @import "./views/rooms/_NotificationBadge.pcss";
 @import "./views/rooms/_PinnedEventTile.pcss";
+@import "./views/rooms/_PinnedMessageBanner.pcss";
 @import "./views/rooms/_PresenceLabel.pcss";
 @import "./views/rooms/_ReadReceiptGroup.pcss";
 @import "./views/rooms/_ReplyPreview.pcss";
diff --git a/res/css/views/rooms/_PinnedMessageBanner.pcss b/res/css/views/rooms/_PinnedMessageBanner.pcss
new file mode 100644
index 0000000000000000000000000000000000000000..c6889aba757a668cd05b5ba7bd254028e59bd0ab
--- /dev/null
+++ b/res/css/views/rooms/_PinnedMessageBanner.pcss
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2024 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.
+ */
+
+.mx_PinnedMessageBanner {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: var(--cpd-space-4x);
+    /* 80px = 79px + 1px from the bottom border */
+    height: 79px;
+    padding: 0 var(--cpd-space-4x);
+
+    background-color: var(--cpd-color-bg-canvas-default);
+    border-bottom: 1px solid var(--cpd-color-gray-400);
+
+    /* From figma */
+    box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgba(27, 29, 34, 0.1);
+
+    .mx_PinnedMessageBanner_main {
+        background: transparent;
+        border: none;
+        text-align: start;
+        cursor: pointer;
+
+        height: 100%;
+        flex-grow: 1;
+        display: flex;
+        align-items: center;
+
+        .mx_PinnedMessageBanner_content {
+            display: grid;
+            grid-template:
+                "indicators pinIcon title" auto
+                "indicators pinIcon message" auto;
+            column-gap: var(--cpd-space-2x);
+        }
+
+        .mx_PinnedMessageBanner_Indicators {
+            grid-area: indicators;
+            display: flex;
+            flex-direction: column;
+            gap: var(--cpd-space-0-5x);
+            height: 100%;
+
+            .mx_PinnedMessageBanner_Indicator {
+                width: var(--cpd-space-0-5x);
+                background-color: var(--cpd-color-gray-600);
+                height: 100%;
+            }
+
+            .mx_PinnedMessageBanner_Indicator--active {
+                background-color: var(--cpd-color-icon-accent-primary);
+            }
+
+            .mx_PinnedMessageBanner_Indicator--hidden {
+                background-color: transparent;
+            }
+        }
+
+        .mx_PinnedMessageBanner_PinIcon {
+            grid-area: pinIcon;
+            align-self: center;
+            fill: var(--cpd-color-icon-secondary-alpha);
+        }
+
+        .mx_PinnedMessageBanner_title {
+            grid-area: title;
+            font: var(--cpd-font-body-sm-regular);
+            color: var(--cpd-color-text-action-accent);
+            height: 20px;
+
+            .mx_PinnedMessageBanner_title_counter {
+                font: var(--cpd-font-body-sm-semibold);
+            }
+        }
+
+        .mx_PinnedMessageBanner_message {
+            grid-area: message;
+            font: var(--cpd-font-body-sm-regular);
+            height: 20px;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+        }
+
+        .mx_PinnedMessageBanner_redactedMessage {
+            grid-area: message;
+            height: 20px;
+            display: flex;
+            align-items: center;
+        }
+    }
+
+    .mx_PinnedMessageBanner_actions {
+        white-space: nowrap;
+    }
+}
+
+.mx_PinnedMessageBanner[data-single-message="true"] {
+    /* 64px = 63px + 1px from the bottom border */
+    height: 63px;
+
+    .mx_PinnedMessageBanner_content {
+        grid-template: "pinIcon message" auto;
+    }
+}
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index 70ee16b542a524f5863121922da51cc34733f591..9c7469346dcca9b4ac93e28820b1e0e300a9c233 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -133,6 +133,7 @@ import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoi
 import RightPanelStore from "../../stores/right-panel/RightPanelStore";
 import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
 import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
+import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner";
 
 const DEBUG = false;
 const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@@ -2409,6 +2410,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
             </AuxPanel>
         );
 
+        const isPinningEnabled = SettingsStore.getValue<boolean>("feature_pinning");
+        let pinnedMessageBanner;
+        if (isPinningEnabled) {
+            pinnedMessageBanner = (
+                <PinnedMessageBanner room={this.state.room} permalinkCreator={this.permalinkCreator} />
+            );
+        }
+
         let messageComposer;
         const showComposer =
             // joined and not showing search results
@@ -2537,6 +2546,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
                             <Measured sensor={this.roomViewBody.current} onMeasurement={this.onMeasurement} />
                         )}
                         {auxPanel}
+                        {pinnedMessageBanner}
                         <main className={timelineClasses}>
                             <FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
                             {topUnreadMessagesBar}
diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx
index 0dbd4b5395adbd4f42d307a1fd325cd179a3ef20..6c105dd0ccde26466cb424e13de626ae8558b086 100644
--- a/src/components/views/context_menus/RoomContextMenu.tsx
+++ b/src/components/views/context_menus/RoomContextMenu.tsx
@@ -35,7 +35,6 @@ import { RoomNotifState } from "../../../RoomNotifs";
 import Modal from "../../../Modal";
 import ExportDialog from "../dialogs/ExportDialog";
 import { useFeatureEnabled } from "../../../hooks/useSettings";
-import { usePinnedEvents } from "../right_panel/PinnedMessagesCard";
 import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
 import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog";
 import { useEventEmitterState } from "../../../hooks/useEventEmitter";
@@ -53,6 +52,7 @@ import { UIComponent } from "../../../settings/UIFeature";
 import { DeveloperToolsOption } from "./DeveloperToolsOption";
 import { tagRoom } from "../../../utils/room/tagRoom";
 import { useIsVideoRoom } from "../../../utils/video-rooms";
+import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
 
 interface IProps extends IContextMenuProps {
     room: Room;
diff --git a/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx b/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx
index 207c97ec7b3545a222f7c81e5d1130cd6f0b7e7b..c3fc1fe95d7e4392969c02d7e98a394dcde56b1b 100644
--- a/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx
+++ b/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx
@@ -28,7 +28,6 @@ import HeaderButtons, { HeaderKind } from "./HeaderButtons";
 import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
 import { ActionPayload } from "../../../dispatcher/payloads";
 import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
-import { useReadPinnedEvents, usePinnedEvents } from "./PinnedMessagesCard";
 import { showThreadPanel } from "../../../dispatcher/dispatch-actions/threads";
 import SettingsStore from "../../../settings/SettingsStore";
 import {
@@ -40,6 +39,7 @@ import { SummarizedNotificationState } from "../../../stores/notifications/Summa
 import PosthogTrackers from "../../../PosthogTrackers";
 import { ButtonEvent } from "../elements/AccessibleButton";
 import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread";
+import { usePinnedEvents, useReadPinnedEvents } from "../../../hooks/usePinnedEvents";
 
 const ROOM_INFO_PHASES = [
     RightPanelPhases.RoomSummary,
diff --git a/src/components/views/right_panel/PinnedMessagesCard.tsx b/src/components/views/right_panel/PinnedMessagesCard.tsx
index 85be2e6d034ebdae7ab3311d6de5324daad4ec34..0f1f856786e7640141e5b9b2faf4a0c6a44df5aa 100644
--- a/src/components/views/right_panel/PinnedMessagesCard.tsx
+++ b/src/components/views/right_panel/PinnedMessagesCard.tsx
@@ -14,17 +14,8 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, { useCallback, useEffect, useState, JSX } from "react";
-import {
-    Room,
-    RoomEvent,
-    RoomStateEvent,
-    MatrixEvent,
-    EventType,
-    RelationType,
-    EventTimeline,
-} from "matrix-js-sdk/src/matrix";
-import { logger } from "matrix-js-sdk/src/logger";
+import React, { useCallback, useEffect, JSX } from "react";
+import { Room, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
 import { Button, Separator } from "@vector-im/compound-web";
 import classNames from "classnames";
 import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin";
@@ -33,9 +24,6 @@ import { _t } from "../../../languageHandler";
 import BaseCard from "./BaseCard";
 import Spinner from "../elements/Spinner";
 import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
-import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
-import PinningUtils from "../../../utils/PinningUtils";
-import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
 import { PinnedEventTile } from "../rooms/PinnedEventTile";
 import { useRoomState } from "../../../hooks/useRoomState";
 import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext";
@@ -46,155 +34,7 @@ import { filterBoolean } from "../../../utils/arrays";
 import Modal from "../../../Modal";
 import { UnpinAllDialog } from "../dialogs/UnpinAllDialog";
 import EmptyState from "./EmptyState";
-
-/**
- * Get the pinned event IDs from a room.
- * @param room
- */
-function getPinnedEventIds(room?: Room): string[] {
-    return (
-        room
-            ?.getLiveTimeline()
-            .getState(EventTimeline.FORWARDS)
-            ?.getStateEvents(EventType.RoomPinnedEvents, "")
-            ?.getContent()?.pinned ?? []
-    );
-}
-
-/**
- * Get the pinned event IDs from a room.
- * @param room
- */
-export const usePinnedEvents = (room?: Room): string[] => {
-    const [pinnedEvents, setPinnedEvents] = useState<string[]>(getPinnedEventIds(room));
-
-    // Update the pinned events when the room state changes
-    // Filter out events that are not pinned events
-    const update = useCallback(
-        (ev?: MatrixEvent) => {
-            if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
-            setPinnedEvents(getPinnedEventIds(room));
-        },
-        [room],
-    );
-
-    useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update);
-    useEffect(() => {
-        setPinnedEvents(getPinnedEventIds(room));
-        return () => {
-            setPinnedEvents([]);
-        };
-    }, [room]);
-    return pinnedEvents;
-};
-
-/**
- * Get the read pinned event IDs from a room.
- * @param room
- */
-function getReadPinnedEventIds(room?: Room): Set<string> {
-    return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []);
-}
-
-/**
- * Get the read pinned event IDs from a room.
- * @param room
- */
-export const useReadPinnedEvents = (room?: Room): Set<string> => {
-    const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
-
-    // Update the read pinned events when the room state changes
-    // Filter out events that are not read pinned events
-    const update = useCallback(
-        (ev?: MatrixEvent) => {
-            if (ev && ev.getType() !== ReadPinsEventId) return;
-            setReadPinnedEvents(getReadPinnedEventIds(room));
-        },
-        [room],
-    );
-
-    useTypedEventEmitter(room, RoomEvent.AccountData, update);
-    useEffect(() => {
-        setReadPinnedEvents(getReadPinnedEventIds(room));
-        return () => {
-            setReadPinnedEvents(new Set());
-        };
-    }, [room]);
-    return readPinnedEvents;
-};
-
-/**
- * Fetch the pinned events
- * @param room
- * @param pinnedEventIds
- */
-function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array<MatrixEvent | null> | null {
-    const cli = useMatrixClientContext();
-
-    return useAsyncMemo(
-        () => {
-            const promises = pinnedEventIds.map(async (eventId): Promise<MatrixEvent | null> => {
-                const timelineSet = room.getUnfilteredTimelineSet();
-                // Get the event from the local timeline
-                const localEvent = timelineSet
-                    ?.getTimelineForEvent(eventId)
-                    ?.getEvents()
-                    .find((e) => e.getId() === eventId);
-
-                // Decrypt the event if it's encrypted
-                // Can happen when the tab is refreshed and the pinned events card is opened directly
-                if (localEvent?.isEncrypted()) {
-                    await cli.decryptEventIfNeeded(localEvent);
-                }
-
-                // If the event is available locally, return it if it's pinnable
-                // Otherwise, return null
-                if (localEvent) return PinningUtils.isPinnable(localEvent) ? localEvent : null;
-
-                try {
-                    // The event is not available locally, so we fetch the event and latest edit in parallel
-                    const [
-                        evJson,
-                        {
-                            events: [edit],
-                        },
-                    ] = await Promise.all([
-                        cli.fetchRoomEvent(room.roomId, eventId),
-                        cli.relations(room.roomId, eventId, RelationType.Replace, null, { limit: 1 }),
-                    ]);
-
-                    const event = new MatrixEvent(evJson);
-
-                    // Decrypt the event if it's encrypted
-                    if (event.isEncrypted()) {
-                        await cli.decryptEventIfNeeded(event);
-                    }
-
-                    // Handle poll events
-                    await room.processPollEvents([event]);
-
-                    const senderUserId = event.getSender();
-                    if (senderUserId && PinningUtils.isPinnable(event)) {
-                        // Inject sender information
-                        event.sender = room.getMember(senderUserId);
-                        // Also inject any edits we've found
-                        if (edit) event.makeReplaced(edit);
-
-                        return event;
-                    }
-                } catch (err) {
-                    logger.error("Error looking up pinned event " + eventId + " in room " + room.roomId);
-                    logger.error(err);
-                }
-                return null;
-            });
-
-            return Promise.all(promises);
-        },
-        [cli, room, pinnedEventIds],
-        null,
-    );
-}
+import { useFetchedPinnedEvents, usePinnedEvents, useReadPinnedEvents } from "../../../hooks/usePinnedEvents";
 
 /**
  * List the pinned messages in a room inside a Card.
diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx
index 9dfe049a52d6183cd5c6f52e15c13ce857188559..ece150495e42478baded08dcdb8712518132e3d1 100644
--- a/src/components/views/right_panel/RoomSummaryCard.tsx
+++ b/src/components/views/right_panel/RoomSummaryCard.tsx
@@ -58,7 +58,6 @@ import { E2EStatus } from "../../../utils/ShieldUtils";
 import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
 import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
 import { useFeatureEnabled } from "../../../hooks/useSettings";
-import { usePinnedEvents } from "./PinnedMessagesCard";
 import RoomName from "../elements/RoomName";
 import ExportDialog from "../dialogs/ExportDialog";
 import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
@@ -81,6 +80,7 @@ import { Action } from "../../../dispatcher/actions";
 import { Key } from "../../../Keyboard";
 import { useTransition } from "../../../hooks/useTransition";
 import { useIsVideoRoom } from "../../../utils/video-rooms";
+import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
 
 interface IProps {
     room: Room;
diff --git a/src/components/views/rooms/PinnedMessageBanner.tsx b/src/components/views/rooms/PinnedMessageBanner.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f7010b8838021674bdac77609d720583afa77ab6
--- /dev/null
+++ b/src/components/views/rooms/PinnedMessageBanner.tsx
@@ -0,0 +1,252 @@
+/*
+ * Copyright 2024 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, { JSX, useEffect, useMemo, useState } from "react";
+import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin-solid.svg";
+import { Button } from "@vector-im/compound-web";
+import { Room } from "matrix-js-sdk/src/matrix";
+import classNames from "classnames";
+
+import { usePinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents";
+import { _t } from "../../../languageHandler";
+import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
+import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
+import { useEventEmitter } from "../../../hooks/useEventEmitter";
+import { UPDATE_EVENT } from "../../../stores/AsyncStore";
+import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
+import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
+import dis from "../../../dispatcher/dispatcher";
+import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
+import { Action } from "../../../dispatcher/actions";
+import MessageEvent from "../messages/MessageEvent";
+
+/**
+ * The props for the {@link PinnedMessageBanner} component.
+ */
+interface PinnedMessageBannerProps {
+    /**
+     * The permalink creator to use.
+     */
+    permalinkCreator: RoomPermalinkCreator;
+    /**
+     * The room where the banner is displayed
+     */
+    room: Room;
+}
+
+/**
+ * A banner that displays the pinned messages in a room.
+ */
+export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBannerProps): JSX.Element | null {
+    const pinnedEventIds = usePinnedEvents(room);
+    const pinnedEvents = useSortedFetchedPinnedEvents(room, pinnedEventIds);
+    const eventCount = pinnedEvents.length;
+    const isSinglePinnedEvent = eventCount === 1;
+
+    const [currentEventIndex, setCurrentEventIndex] = useState(eventCount - 1);
+    // If the list of pinned events changes, we need to make sure the current index isn't out of bound
+    useEffect(() => {
+        setCurrentEventIndex((currentEventIndex) => {
+            // If the current index is out of bound, we set it to the last index
+            if (currentEventIndex < 0 || currentEventIndex >= eventCount) return eventCount - 1;
+            return currentEventIndex;
+        });
+    }, [eventCount]);
+
+    const pinnedEvent = pinnedEvents[currentEventIndex];
+    // Generate a preview for the pinned event
+    const eventPreview = useMemo(() => {
+        if (!pinnedEvent || pinnedEvent.isRedacted() || pinnedEvent.isDecryptionFailure()) return null;
+        return MessagePreviewStore.instance.generatePreviewForEvent(pinnedEvent);
+    }, [pinnedEvent]);
+
+    if (!pinnedEvent) return null;
+
+    const shouldUseMessageEvent = pinnedEvent.isRedacted() || pinnedEvent.isDecryptionFailure();
+
+    const onBannerClick = (): void => {
+        // Scroll to the pinned message
+        dis.dispatch<ViewRoomPayload>({
+            action: Action.ViewRoom,
+            event_id: pinnedEvent.getId(),
+            highlighted: true,
+            room_id: room.roomId,
+            metricsTrigger: undefined, // room doesn't change
+        });
+
+        // Cycle through the pinned messages
+        // When we reach the first message, we go back to the last message
+        setCurrentEventIndex((currentEventIndex) => (--currentEventIndex === -1 ? eventCount - 1 : currentEventIndex));
+    };
+
+    return (
+        <div
+            className="mx_PinnedMessageBanner"
+            data-single-message={isSinglePinnedEvent}
+            aria-label={_t("room|pinned_message_banner|description")}
+            data-testid="pinned-message-banner"
+        >
+            <button
+                aria-label={_t("room|pinned_message_banner|go_to_message")}
+                type="button"
+                className="mx_PinnedMessageBanner_main"
+                onClick={onBannerClick}
+            >
+                <div className="mx_PinnedMessageBanner_content">
+                    {!isSinglePinnedEvent && <Indicators count={eventCount} currentIndex={currentEventIndex} />}
+                    <PinIcon width="20" className="mx_PinnedMessageBanner_PinIcon" />
+                    {!isSinglePinnedEvent && (
+                        <div className="mx_PinnedMessageBanner_title" data-testid="banner-counter">
+                            {_t(
+                                "room|pinned_message_banner|title",
+                                {
+                                    index: currentEventIndex + 1,
+                                    length: eventCount,
+                                },
+                                { bold: (sub) => <span className="mx_PinnedMessageBanner_title_counter">{sub}</span> },
+                            )}
+                        </div>
+                    )}
+                    {eventPreview && <span className="mx_PinnedMessageBanner_message">{eventPreview}</span>}
+                    {/* In case of redacted event, we want to display the nice sentence of the message event like in the timeline or in the pinned message list */}
+                    {shouldUseMessageEvent && (
+                        <div className="mx_PinnedMessageBanner_redactedMessage">
+                            <MessageEvent
+                                mxEvent={pinnedEvent}
+                                maxImageHeight={20}
+                                permalinkCreator={permalinkCreator}
+                                replacingEventId={pinnedEvent.replacingEventId()}
+                            />
+                        </div>
+                    )}
+                </div>
+            </button>
+            {!isSinglePinnedEvent && <BannerButton room={room} />}
+        </div>
+    );
+}
+
+const MAX_INDICATORS = 3;
+
+/**
+ * The props for the {@link IndicatorsProps} component.
+ */
+interface IndicatorsProps {
+    /**
+     * The number of messages pinned
+     */
+    count: number;
+    /**
+     * The current index of the pinned message
+     */
+    currentIndex: number;
+}
+
+/**
+ * A component that displays vertical indicators for the pinned messages.
+ */
+function Indicators({ count, currentIndex }: IndicatorsProps): JSX.Element {
+    // We only display a maximum of 3 indicators at one time.
+    // When there is more than 3 messages pinned, we will cycle through the indicators
+
+    // If there is only 2 messages pinned, we will display 2 indicators
+    // In case of 1 message pinned, the indicators are not displayed, see {@link PinnedMessageBanner} logic.
+    const numberOfIndicators = Math.min(count, MAX_INDICATORS);
+    // The index of the active indicator
+    const index = currentIndex % numberOfIndicators;
+
+    // We hide the indicators when we are on the last cycle and there are less than 3 remaining messages pinned
+    const numberOfCycles = Math.ceil(count / numberOfIndicators);
+    // If the current index is greater than the last cycle index, we are on the last cycle
+    const isLastCycle = currentIndex >= (numberOfCycles - 1) * MAX_INDICATORS;
+    // The index of the last message in the last cycle
+    const lastCycleIndex = numberOfIndicators - (numberOfCycles * numberOfIndicators - count);
+
+    return (
+        <div className="mx_PinnedMessageBanner_Indicators">
+            {Array.from({ length: numberOfIndicators }).map((_, i) => (
+                <Indicator key={i} active={i === index} hidden={isLastCycle && lastCycleIndex <= i} />
+            ))}
+        </div>
+    );
+}
+
+/**
+ * The props for the {@link Indicator} component.
+ */
+interface IndicatorProps {
+    /**
+     * Whether the indicator is active
+     */
+    active: boolean;
+    /**
+     * Whether the indicator is hidden
+     */
+    hidden: boolean;
+}
+
+/**
+ * A component that displays a vertical indicator for a pinned message.
+ */
+function Indicator({ active, hidden }: IndicatorProps): JSX.Element {
+    return (
+        <div
+            data-testid="banner-indicator"
+            className={classNames("mx_PinnedMessageBanner_Indicator", {
+                "mx_PinnedMessageBanner_Indicator--active": active,
+                "mx_PinnedMessageBanner_Indicator--hidden": hidden,
+            })}
+        />
+    );
+}
+
+function getRightPanelPhase(roomId: string): RightPanelPhases | null {
+    if (!RightPanelStore.instance.isOpenForRoom(roomId)) return null;
+    return RightPanelStore.instance.currentCard.phase;
+}
+
+/**
+ * The props for the {@link BannerButton} component.
+ */
+interface BannerButtonProps {
+    /**
+     * The room where the banner is displayed
+     */
+    room: Room;
+}
+
+/**
+ * A button that allows the user to view or close the list of pinned messages.
+ */
+function BannerButton({ room }: BannerButtonProps): JSX.Element {
+    const [currentPhase, setCurrentPhase] = useState<RightPanelPhases | null>(getRightPanelPhase(room.roomId));
+    useEventEmitter(RightPanelStore.instance, UPDATE_EVENT, () => setCurrentPhase(getRightPanelPhase(room.roomId)));
+    const isPinnedMessagesPhase = currentPhase === RightPanelPhases.PinnedMessages;
+
+    return (
+        <Button
+            className="mx_PinnedMessageBanner_actions"
+            kind="tertiary"
+            onClick={() => {
+                RightPanelStore.instance.showOrHidePhase(RightPanelPhases.PinnedMessages);
+            }}
+        >
+            {isPinnedMessagesPhase
+                ? _t("room|pinned_message_banner|button_close_list")
+                : _t("room|pinned_message_banner|button_view_all")}
+        </Button>
+    );
+}
diff --git a/src/hooks/usePinnedEvents.ts b/src/hooks/usePinnedEvents.ts
new file mode 100644
index 0000000000000000000000000000000000000000..eb53151138519e7753cc55bfd2056bfe5fdff7bc
--- /dev/null
+++ b/src/hooks/usePinnedEvents.ts
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2024 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 { useCallback, useEffect, useMemo, useState } from "react";
+import {
+    Room,
+    RoomEvent,
+    RoomStateEvent,
+    MatrixEvent,
+    EventType,
+    RelationType,
+    EventTimeline,
+    MatrixClient,
+} from "matrix-js-sdk/src/matrix";
+import { logger } from "matrix-js-sdk/src/logger";
+
+import { useTypedEventEmitter } from "./useEventEmitter";
+import { ReadPinsEventId } from "../components/views/right_panel/types";
+import { useMatrixClientContext } from "../contexts/MatrixClientContext";
+import { useAsyncMemo } from "./useAsyncMemo";
+import PinningUtils from "../utils/PinningUtils";
+
+/**
+ * Get the pinned event IDs from a room.
+ * @param room
+ */
+function getPinnedEventIds(room?: Room): string[] {
+    return (
+        room
+            ?.getLiveTimeline()
+            .getState(EventTimeline.FORWARDS)
+            ?.getStateEvents(EventType.RoomPinnedEvents, "")
+            ?.getContent()?.pinned ?? []
+    );
+}
+
+/**
+ * Get the pinned event IDs from a room.
+ * @param room
+ */
+export const usePinnedEvents = (room?: Room): string[] => {
+    const [pinnedEvents, setPinnedEvents] = useState<string[]>(getPinnedEventIds(room));
+
+    // Update the pinned events when the room state changes
+    // Filter out events that are not pinned events
+    const update = useCallback(
+        (ev?: MatrixEvent) => {
+            if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
+            setPinnedEvents(getPinnedEventIds(room));
+        },
+        [room],
+    );
+
+    useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update);
+    useEffect(() => {
+        setPinnedEvents(getPinnedEventIds(room));
+        return () => {
+            setPinnedEvents([]);
+        };
+    }, [room]);
+    return pinnedEvents;
+};
+
+/**
+ * Get the read pinned event IDs from a room.
+ * @param room
+ */
+function getReadPinnedEventIds(room?: Room): Set<string> {
+    return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []);
+}
+
+/**
+ * Get the read pinned event IDs from a room.
+ * @param room
+ */
+export const useReadPinnedEvents = (room?: Room): Set<string> => {
+    const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
+
+    // Update the read pinned events when the room state changes
+    // Filter out events that are not read pinned events
+    const update = useCallback(
+        (ev?: MatrixEvent) => {
+            if (ev && ev.getType() !== ReadPinsEventId) return;
+            setReadPinnedEvents(getReadPinnedEventIds(room));
+        },
+        [room],
+    );
+
+    useTypedEventEmitter(room, RoomEvent.AccountData, update);
+    useEffect(() => {
+        setReadPinnedEvents(getReadPinnedEventIds(room));
+        return () => {
+            setReadPinnedEvents(new Set());
+        };
+    }, [room]);
+    return readPinnedEvents;
+};
+
+/**
+ * Fetch the pinned event
+ * @param room
+ * @param pinnedEventId
+ * @param cli
+ */
+async function fetchPinnedEvent(room: Room, pinnedEventId: string, cli: MatrixClient): Promise<MatrixEvent | null> {
+    const timelineSet = room.getUnfilteredTimelineSet();
+    // Get the event from the local timeline
+    const localEvent = timelineSet
+        ?.getTimelineForEvent(pinnedEventId)
+        ?.getEvents()
+        .find((e) => e.getId() === pinnedEventId);
+
+    // Decrypt the event if it's encrypted
+    // Can happen when the tab is refreshed and the pinned events card is opened directly
+    if (localEvent?.isEncrypted()) {
+        await cli.decryptEventIfNeeded(localEvent);
+    }
+
+    // If the event is available locally, return it if it's pinnable
+    // or if it's redacted (to show the redacted event and to be able to unpin it)
+    // Otherwise, return null
+    if (localEvent) return PinningUtils.isUnpinnable(localEvent) ? localEvent : null;
+
+    try {
+        // The event is not available locally, so we fetch the event and latest edit in parallel
+        const [
+            evJson,
+            {
+                events: [edit],
+            },
+        ] = await Promise.all([
+            cli.fetchRoomEvent(room.roomId, pinnedEventId),
+            cli.relations(room.roomId, pinnedEventId, RelationType.Replace, null, { limit: 1 }),
+        ]);
+
+        const event = new MatrixEvent(evJson);
+
+        // Decrypt the event if it's encrypted
+        if (event.isEncrypted()) {
+            await cli.decryptEventIfNeeded(event);
+        }
+
+        // Handle poll events
+        await room.processPollEvents([event]);
+
+        const senderUserId = event.getSender();
+        if (senderUserId && PinningUtils.isUnpinnable(event)) {
+            // Inject sender information
+            event.sender = room.getMember(senderUserId);
+            // Also inject any edits we've found
+            if (edit) event.makeReplaced(edit);
+
+            return event;
+        }
+    } catch (err) {
+        logger.error(`Error looking up pinned event ${pinnedEventId} in room ${room.roomId}`);
+        logger.error(err);
+    }
+    return null;
+}
+
+/**
+ * Fetch the pinned events
+ * @param room
+ * @param pinnedEventIds
+ */
+export function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array<MatrixEvent | null> | null {
+    const cli = useMatrixClientContext();
+
+    return useAsyncMemo(
+        () =>
+            Promise.all(
+                pinnedEventIds.map(
+                    async (eventId): Promise<MatrixEvent | null> => fetchPinnedEvent(room, eventId, cli),
+                ),
+            ),
+        [cli, room, pinnedEventIds],
+        null,
+    );
+}
+
+/**
+ * Fetch the pinned events and sort them by from the oldest to the newest
+ * The order is determined by the event timestamp
+ * @param room
+ * @param pinnedEventIds
+ */
+export function useSortedFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array<MatrixEvent | null> {
+    const pinnedEvents = useFetchedPinnedEvents(room, pinnedEventIds);
+    return useMemo(() => {
+        if (!pinnedEvents) return [];
+
+        return pinnedEvents.sort((a, b) => {
+            if (!a) return -1;
+            if (!b) return 1;
+            return a.getTs() - b.getTs();
+        });
+    }, [pinnedEvents]);
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 889fc157e9e216a4d285ab337c51826659b570b4..352ab4376294db3e728dfe77224f49bdbaca8e49 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -2048,6 +2048,13 @@
         "not_found_title": "This room or space does not exist.",
         "not_found_title_name": "%(roomName)s does not exist.",
         "peek_join_prompt": "You're previewing %(roomName)s. Want to join it?",
+        "pinned_message_banner": {
+            "button_close_list": "Close list",
+            "button_view_all": "View all",
+            "description": "This room has pinned messages. Click to view them.",
+            "go_to_message": "View the pinned message in the timeline.",
+            "title": "<bold>%(index)s of %(length)s</bold> Pinned messages"
+        },
         "read_topic": "Click to read topic",
         "rejecting": "Rejecting invite…",
         "rejoin_button": "Re-join",
diff --git a/src/utils/PinningUtils.ts b/src/utils/PinningUtils.ts
index 22db64a6f17bd2480e25adb4b166c3aa002767d9..9a20a721b969302508ce42e40ae35cc3945b56b8 100644
--- a/src/utils/PinningUtils.ts
+++ b/src/utils/PinningUtils.ts
@@ -37,11 +37,19 @@ export default class PinningUtils {
      * @return {boolean} True if the event may be pinned, false otherwise.
      */
     public static isPinnable(event: MatrixEvent): boolean {
-        if (!event) return false;
-        if (!this.PINNABLE_EVENT_TYPES.includes(event.getType())) return false;
         if (event.isRedacted()) return false;
+        return PinningUtils.isUnpinnable(event);
+    }
 
-        return true;
+    /**
+     * Determines if the given event may be unpinned.
+     * @param {MatrixEvent} event The event to check.
+     * @return {boolean} True if the event may be unpinned, false otherwise.
+     */
+    public static isUnpinnable(event: MatrixEvent): boolean {
+        if (!event) return false;
+        if (event.isRedacted()) return true;
+        return this.PINNABLE_EVENT_TYPES.includes(event.getType());
     }
 
     /**
diff --git a/test/components/views/right_panel/PinnedMessagesCard-test.tsx b/test/components/views/right_panel/PinnedMessagesCard-test.tsx
index 64961ca1447b4a0b5261c63e473d773aa0f7fbe9..cfa32fa490078e7169a684f27b0b17b0419d70e3 100644
--- a/test/components/views/right_panel/PinnedMessagesCard-test.tsx
+++ b/test/components/views/right_panel/PinnedMessagesCard-test.tsx
@@ -20,7 +20,6 @@ import { mocked, MockedObject } from "jest-mock";
 import {
     MatrixEvent,
     RoomStateEvent,
-    IEvent,
     Room,
     IMinimalEvent,
     EventType,
@@ -266,9 +265,8 @@ describe("<PinnedMessagesCard />", () => {
             // Redacted messages are unpinnable
             const pin = mkEvent({
                 event: true,
-                type: EventType.RoomMessage,
+                type: EventType.RoomCreate,
                 content: {},
-                unsigned: { redacted_because: {} as unknown as IEvent },
                 room: "!room:example.org",
                 user: "@alice:example.org",
             });
@@ -280,9 +278,8 @@ describe("<PinnedMessagesCard />", () => {
             // Redacted messages are unpinnable
             const pin = mkEvent({
                 event: true,
-                type: EventType.RoomMessage,
+                type: EventType.RoomCreate,
                 content: {},
-                unsigned: { redacted_because: {} as unknown as IEvent },
                 room: "!room:example.org",
                 user: "@alice:example.org",
             });
diff --git a/test/components/views/rooms/PinnedMessageBanner-test.tsx b/test/components/views/rooms/PinnedMessageBanner-test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4df0127d824ada08f141a3a0c6277702785f3a98
--- /dev/null
+++ b/test/components/views/rooms/PinnedMessageBanner-test.tsx
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2024 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 { act, screen, render } from "@testing-library/react";
+import React from "react";
+import { EventType, IEvent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
+import userEvent from "@testing-library/user-event";
+
+import * as pinnedEventHooks from "../../../../src/hooks/usePinnedEvents";
+import { PinnedMessageBanner } from "../../../../src/components/views/rooms/PinnedMessageBanner";
+import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
+import { stubClient } from "../../../test-utils";
+import dis from "../../../../src/dispatcher/dispatcher";
+import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore";
+import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases";
+import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore";
+import { Action } from "../../../../src/dispatcher/actions";
+
+describe("<PinnedMessageBanner />", () => {
+    const userId = "@alice:server.org";
+    const roomId = "!room:server.org";
+
+    let mockClient: MatrixClient;
+    let room: Room;
+    let permalinkCreator: RoomPermalinkCreator;
+    beforeEach(() => {
+        mockClient = stubClient();
+        room = new Room(roomId, mockClient, userId);
+        permalinkCreator = new RoomPermalinkCreator(room);
+        jest.spyOn(dis, "dispatch").mockReturnValue(undefined);
+    });
+
+    afterEach(() => {
+        jest.restoreAllMocks();
+    });
+
+    /**
+     * Create a pinned event with the given content.
+     * @param content
+     */
+    function makePinEvent(content?: Partial<IEvent>) {
+        return new MatrixEvent({
+            type: EventType.RoomMessage,
+            sender: userId,
+            content: {
+                body: "First pinned message",
+                msgtype: "m.text",
+            },
+            room_id: roomId,
+            origin_server_ts: 0,
+            event_id: "$eventId",
+            ...content,
+        });
+    }
+
+    const event1 = makePinEvent();
+    const event2 = makePinEvent({
+        event_id: "$eventId2",
+        content: { body: "Second pinned message" },
+    });
+    const event3 = makePinEvent({
+        event_id: "$eventId3",
+        content: { body: "Third pinned message" },
+    });
+    const event4 = makePinEvent({
+        event_id: "$eventId4",
+        content: { body: "Fourth pinned message" },
+    });
+
+    /**
+     * Render the banner
+     */
+    function renderBanner() {
+        return render(<PinnedMessageBanner permalinkCreator={permalinkCreator} room={room} />);
+    }
+
+    it("should render nothing when there are no pinned events", async () => {
+        jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([]);
+        jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([]);
+        const { container } = renderBanner();
+        expect(container).toBeEmptyDOMElement();
+    });
+
+    it("should render a single pinned event", async () => {
+        jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!]);
+        jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1]);
+
+        const { asFragment } = renderBanner();
+
+        expect(screen.getByText("First pinned message")).toBeVisible();
+        expect(screen.queryByRole("button", { name: "View all" })).toBeNull();
+        expect(asFragment()).toMatchSnapshot();
+    });
+
+    it("should render 2 pinned event", async () => {
+        jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]);
+        jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]);
+
+        const { asFragment } = renderBanner();
+
+        expect(screen.getByText("Second pinned message")).toBeVisible();
+        expect(screen.getByTestId("banner-counter")).toHaveTextContent("2 of 2 Pinned messages");
+        expect(screen.getAllByTestId("banner-indicator")).toHaveLength(2);
+        expect(screen.queryByRole("button", { name: "View all" })).toBeVisible();
+        expect(asFragment()).toMatchSnapshot();
+    });
+
+    it("should render 4 pinned event", async () => {
+        jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([
+            event1.getId()!,
+            event2.getId()!,
+            event3.getId()!,
+            event4.getId()!,
+        ]);
+        jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2, event3, event4]);
+
+        const { asFragment } = renderBanner();
+
+        expect(screen.getByText("Fourth pinned message")).toBeVisible();
+        expect(screen.getByTestId("banner-counter")).toHaveTextContent("4 of 4 Pinned messages");
+        expect(screen.getAllByTestId("banner-indicator")).toHaveLength(3);
+        expect(screen.queryByRole("button", { name: "View all" })).toBeVisible();
+        expect(asFragment()).toMatchSnapshot();
+    });
+
+    it("should rotate the pinned events when the banner is clicked", async () => {
+        jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]);
+        jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]);
+
+        renderBanner();
+        expect(screen.getByText("Second pinned message")).toBeVisible();
+
+        await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." }));
+        expect(screen.getByText("First pinned message")).toBeVisible();
+        expect(screen.getByTestId("banner-counter")).toHaveTextContent("1 of 2 Pinned messages");
+        expect(dis.dispatch).toHaveBeenCalledWith({
+            action: Action.ViewRoom,
+            event_id: event2.getId(),
+            highlighted: true,
+            room_id: room.roomId,
+            metricsTrigger: undefined, // room doesn't change
+        });
+
+        await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." }));
+        expect(screen.getByText("Second pinned message")).toBeVisible();
+        expect(screen.getByTestId("banner-counter")).toHaveTextContent("2 of 2 Pinned messages");
+        expect(dis.dispatch).toHaveBeenCalledWith({
+            action: Action.ViewRoom,
+            event_id: event1.getId(),
+            highlighted: true,
+            room_id: room.roomId,
+            metricsTrigger: undefined, // room doesn't change
+        });
+    });
+
+    describe("Right button", () => {
+        beforeEach(() => {
+            jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]);
+            jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]);
+        });
+
+        it("should display View all button if the right panel is closed", async () => {
+            // The Right panel is closed
+            jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(false);
+
+            renderBanner();
+            expect(screen.getByRole("button", { name: "View all" })).toBeVisible();
+        });
+
+        it("should display View all button if the right panel is not opened on the pinned message list", async () => {
+            // The Right panel is opened on another card
+            jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
+            jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
+                phase: RightPanelPhases.RoomMemberList,
+            });
+
+            renderBanner();
+            expect(screen.getByRole("button", { name: "View all" })).toBeVisible();
+        });
+
+        it("should display Close list button if the message pinning list is displayed", async () => {
+            // The Right panel is closed
+            jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
+            jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
+                phase: RightPanelPhases.PinnedMessages,
+            });
+
+            renderBanner();
+            expect(screen.getByRole("button", { name: "Close list" })).toBeVisible();
+        });
+
+        it("should open or close the message pinning list", async () => {
+            // The Right panel is closed
+            jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
+            jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
+                phase: RightPanelPhases.PinnedMessages,
+            });
+            jest.spyOn(RightPanelStore.instance, "showOrHidePhase").mockReturnValue();
+
+            renderBanner();
+            await userEvent.click(screen.getByRole("button", { name: "Close list" }));
+            expect(RightPanelStore.instance.showOrHidePhase).toHaveBeenCalledWith(RightPanelPhases.PinnedMessages);
+        });
+
+        it("should listen to the right panel", async () => {
+            // The Right panel is closed
+            jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
+            jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
+                phase: RightPanelPhases.PinnedMessages,
+            });
+
+            renderBanner();
+            expect(screen.getByRole("button", { name: "Close list" })).toBeVisible();
+
+            jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(false);
+            act(() => {
+                RightPanelStore.instance.emit(UPDATE_EVENT);
+            });
+            expect(screen.getByRole("button", { name: "View all" })).toBeVisible();
+        });
+    });
+});
diff --git a/test/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap b/test/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..fa4c793d909e2806144d5f3483ca1e52abc6b279
--- /dev/null
+++ b/test/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap
@@ -0,0 +1,166 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`<PinnedMessageBanner /> should render 2 pinned event 1`] = `
+<DocumentFragment>
+  <div
+    aria-label="This room has pinned messages. Click to view them."
+    class="mx_PinnedMessageBanner"
+    data-single-message="false"
+    data-testid="pinned-message-banner"
+  >
+    <button
+      aria-label="View the pinned message in the timeline."
+      class="mx_PinnedMessageBanner_main"
+      type="button"
+    >
+      <div
+        class="mx_PinnedMessageBanner_content"
+      >
+        <div
+          class="mx_PinnedMessageBanner_Indicators"
+        >
+          <div
+            class="mx_PinnedMessageBanner_Indicator"
+            data-testid="banner-indicator"
+          />
+          <div
+            class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
+            data-testid="banner-indicator"
+          />
+        </div>
+        <div
+          class="mx_PinnedMessageBanner_PinIcon"
+          width="20"
+        />
+        <div
+          class="mx_PinnedMessageBanner_title"
+          data-testid="banner-counter"
+        >
+          <span>
+            <span
+              class="mx_PinnedMessageBanner_title_counter"
+            >
+              2 of 2
+            </span>
+             Pinned messages
+          </span>
+        </div>
+        <span
+          class="mx_PinnedMessageBanner_message"
+        >
+          Second pinned message
+        </span>
+      </div>
+    </button>
+    <button
+      class="_button_zt6rp_17 mx_PinnedMessageBanner_actions"
+      data-kind="tertiary"
+      data-size="lg"
+      role="button"
+      tabindex="0"
+    >
+      View all
+    </button>
+  </div>
+</DocumentFragment>
+`;
+
+exports[`<PinnedMessageBanner /> should render 4 pinned event 1`] = `
+<DocumentFragment>
+  <div
+    aria-label="This room has pinned messages. Click to view them."
+    class="mx_PinnedMessageBanner"
+    data-single-message="false"
+    data-testid="pinned-message-banner"
+  >
+    <button
+      aria-label="View the pinned message in the timeline."
+      class="mx_PinnedMessageBanner_main"
+      type="button"
+    >
+      <div
+        class="mx_PinnedMessageBanner_content"
+      >
+        <div
+          class="mx_PinnedMessageBanner_Indicators"
+        >
+          <div
+            class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
+            data-testid="banner-indicator"
+          />
+          <div
+            class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--hidden"
+            data-testid="banner-indicator"
+          />
+          <div
+            class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--hidden"
+            data-testid="banner-indicator"
+          />
+        </div>
+        <div
+          class="mx_PinnedMessageBanner_PinIcon"
+          width="20"
+        />
+        <div
+          class="mx_PinnedMessageBanner_title"
+          data-testid="banner-counter"
+        >
+          <span>
+            <span
+              class="mx_PinnedMessageBanner_title_counter"
+            >
+              4 of 4
+            </span>
+             Pinned messages
+          </span>
+        </div>
+        <span
+          class="mx_PinnedMessageBanner_message"
+        >
+          Fourth pinned message
+        </span>
+      </div>
+    </button>
+    <button
+      class="_button_zt6rp_17 mx_PinnedMessageBanner_actions"
+      data-kind="tertiary"
+      data-size="lg"
+      role="button"
+      tabindex="0"
+    >
+      View all
+    </button>
+  </div>
+</DocumentFragment>
+`;
+
+exports[`<PinnedMessageBanner /> should render a single pinned event 1`] = `
+<DocumentFragment>
+  <div
+    aria-label="This room has pinned messages. Click to view them."
+    class="mx_PinnedMessageBanner"
+    data-single-message="true"
+    data-testid="pinned-message-banner"
+  >
+    <button
+      aria-label="View the pinned message in the timeline."
+      class="mx_PinnedMessageBanner_main"
+      type="button"
+    >
+      <div
+        class="mx_PinnedMessageBanner_content"
+      >
+        <div
+          class="mx_PinnedMessageBanner_PinIcon"
+          width="20"
+        />
+        <span
+          class="mx_PinnedMessageBanner_message"
+        >
+          First pinned message
+        </span>
+      </div>
+    </button>
+  </div>
+</DocumentFragment>
+`;
diff --git a/test/utils/PinningUtils-test.ts b/test/utils/PinningUtils-test.ts
index 47434c4fca3e881ab026cd6f90364c3b3cba4f3f..adfd268bf1bff7e9e79bd32602bb7fdfc8e27b5b 100644
--- a/test/utils/PinningUtils-test.ts
+++ b/test/utils/PinningUtils-test.ts
@@ -73,15 +73,27 @@ describe("PinningUtils", () => {
         ).mockReturnValue(true);
     });
 
-    describe("isPinnable", () => {
+    describe("isUnpinnable", () => {
         test.each(PinningUtils.PINNABLE_EVENT_TYPES)("should return true for pinnable event types", (eventType) => {
             const event = makePinEvent({ type: eventType });
-            expect(PinningUtils.isPinnable(event)).toBe(true);
+            expect(PinningUtils.isUnpinnable(event)).toBe(true);
         });
 
         test("should return false for a non pinnable event type", () => {
             const event = makePinEvent({ type: EventType.RoomCreate });
-            expect(PinningUtils.isPinnable(event)).toBe(false);
+            expect(PinningUtils.isUnpinnable(event)).toBe(false);
+        });
+
+        test("should return true for a redacted event", () => {
+            const event = makePinEvent({ unsigned: { redacted_because: "because" as unknown as IEvent } });
+            expect(PinningUtils.isUnpinnable(event)).toBe(true);
+        });
+    });
+
+    describe("isPinnable", () => {
+        test.each(PinningUtils.PINNABLE_EVENT_TYPES)("should return true for pinnable event types", (eventType) => {
+            const event = makePinEvent({ type: eventType });
+            expect(PinningUtils.isPinnable(event)).toBe(true);
         });
 
         test("should return false for a redacted event", () => {