diff --git a/apps/web/playwright/e2e/read-receipts/index.ts b/apps/web/playwright/e2e/read-receipts/index.ts
index bd04be36a8..6ff5789795 100644
--- a/apps/web/playwright/e2e/read-receipts/index.ts
+++ b/apps/web/playwright/e2e/read-receipts/index.ts
@@ -557,7 +557,7 @@ class Helpers {
*/
async assertReadThread(rootMessage: string) {
const tile = await this.getThreadListTile(rootMessage);
- await expect(tile.locator(".mx_NotificationBadge")).not.toBeVisible();
+ await expect(tile.getByTestId("notification-badge")).not.toBeVisible();
}
/**
@@ -566,7 +566,7 @@ class Helpers {
*/
async assertUnreadThread(rootMessage: string) {
const tile = await this.getThreadListTile(rootMessage);
- await expect(tile.locator(".mx_NotificationBadge")).toBeVisible();
+ await expect(tile.getByTestId("notification-badge")).toBeVisible();
}
/**
diff --git a/apps/web/playwright/e2e/threads/threads.spec.ts b/apps/web/playwright/e2e/threads/threads.spec.ts
index b143c4a83a..1ace1c4499 100644
--- a/apps/web/playwright/e2e/threads/threads.spec.ts
+++ b/apps/web/playwright/e2e/threads/threads.spec.ts
@@ -269,7 +269,9 @@ test.describe("Threads", () => {
// Check the number of the replies
await expect(locator.locator(".mx_ThreadPanel_replies_amount").getByText("2")).toBeAttached();
// Make sure the notification dot is visible
- await expect(locator.locator(".mx_NotificationBadge_visible")).toBeVisible();
+ const notificationBadge = locator.getByTestId("notification-badge");
+ await expect(notificationBadge).toBeVisible();
+ await expect(notificationBadge).toHaveAttribute("data-badge-type", "dot");
// User opens thread via threads list
await locator.locator(".mx_EventTile_line").click();
diff --git a/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx b/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx
index 0677278054..8e3912bf1c 100644
--- a/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx
+++ b/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx
@@ -7,10 +7,10 @@ Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
-import React, { type JSX } from "react";
+import React, { type JSX, useEffect } from "react";
+import { NotificationBadgeView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
-import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications";
-import { StatelessNotificationBadge } from "./StatelessNotificationBadge";
+import { UnreadNotificationBadgeViewModel } from "../../../../viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel";
interface Props {
room?: Room;
@@ -23,7 +23,26 @@ interface Props {
}
export function UnreadNotificationBadge({ room, threadId, forceDot }: Props): JSX.Element {
- const { symbol, count, level } = useUnreadNotifications(room, threadId);
+ const vm = useCreateAutoDisposedViewModel(
+ () =>
+ new UnreadNotificationBadgeViewModel({
+ room,
+ threadId,
+ forceDot,
+ }),
+ );
- return ;
+ useEffect(() => {
+ vm.setRoom(room);
+ }, [room, vm]);
+
+ useEffect(() => {
+ vm.setThreadId(threadId);
+ }, [threadId, vm]);
+
+ useEffect(() => {
+ vm.setForceDot(forceDot);
+ }, [forceDot, vm]);
+
+ return ;
}
diff --git a/apps/web/src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel.ts b/apps/web/src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel.ts
new file mode 100644
index 0000000000..bc02f3e1eb
--- /dev/null
+++ b/apps/web/src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel.ts
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import { type NotificationCount, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
+import {
+ BaseViewModel,
+ type NotificationBadgeType,
+ type NotificationBadgeViewSnapshot,
+ type NotificationBadgeViewModel as NotificationBadgeViewModelInterface,
+} from "@element-hq/web-shared-components";
+
+import { determineUnreadState } from "../../../RoomNotifs";
+import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
+import SettingsStore from "../../../settings/SettingsStore";
+import { formatCount } from "../../../utils/FormattingUtils";
+
+const notificationChangeEvents = [
+ RoomEvent.Receipt,
+ RoomEvent.Timeline,
+ RoomEvent.Redaction,
+ RoomEvent.LocalEchoUpdated,
+ RoomEvent.MyMembership,
+];
+
+function getBadgeType(level: NotificationLevel, symbol: string | null, forceDot?: boolean): NotificationBadgeType {
+ if (forceDot || level <= NotificationLevel.Activity) {
+ return "dot";
+ }
+
+ return symbol && symbol.length >= 3 ? "badge_3char" : "badge_2char";
+}
+
+export interface UnreadNotificationBadgeViewModelProps {
+ /**
+ * Room whose unread state should be represented by the badge.
+ */
+ room?: Room;
+ /**
+ * Thread to read unread state from. Omit for room-level unread state.
+ */
+ threadId?: string;
+ /**
+ * Show a dot instead of a numeric/symbol badge whenever the badge would otherwise render.
+ */
+ forceDot?: boolean;
+}
+
+interface InternalProps extends UnreadNotificationBadgeViewModelProps {
+ hideBold: boolean;
+}
+
+export class UnreadNotificationBadgeViewModel
+ extends BaseViewModel
+ implements NotificationBadgeViewModelInterface
+{
+ private listenerCleanups: Array<() => void> = [];
+
+ private static readonly computeSnapshot = (props: InternalProps): NotificationBadgeViewSnapshot => {
+ const { symbol, count, level } = determineUnreadState(props.room, props.threadId, false);
+ const displaySymbol = symbol ?? (count > 0 ? formatCount(count) : null);
+ const hasUnreadCount = level >= NotificationLevel.Notification && (count > 0 || symbol !== null);
+ const isSuppressedActivity = props.hideBold && level === NotificationLevel.Activity;
+
+ return {
+ shouldRender: level !== NotificationLevel.None && !isSuppressedActivity,
+ isVisible: symbol === null && count === 0 ? true : hasUnreadCount,
+ isNotification: level === NotificationLevel.Notification,
+ isHighlight: level >= NotificationLevel.Highlight,
+ isKnocked: false,
+ badgeType: getBadgeType(level, displaySymbol, props.forceDot),
+ symbol: displaySymbol,
+ };
+ };
+
+ public constructor(props: UnreadNotificationBadgeViewModelProps) {
+ const internalProps: InternalProps = {
+ ...props,
+ hideBold: SettingsStore.getValue("feature_hidebold"),
+ };
+
+ super(internalProps, UnreadNotificationBadgeViewModel.computeSnapshot(internalProps));
+
+ this.setupListeners();
+
+ const hideBoldWatcherRef = SettingsStore.watchSetting("feature_hidebold", null, this.onHideBoldSettingChanged);
+ this.disposables.track(() => SettingsStore.unwatchSetting(hideBoldWatcherRef));
+ }
+
+ public dispose(): void {
+ this.teardownListeners();
+ super.dispose();
+ }
+
+ public setRoom(room?: Room): void {
+ if (this.props.room === room) return;
+
+ this.props = {
+ ...this.props,
+ room,
+ };
+ this.setupListeners();
+ this.updateSnapshotFromProps();
+ }
+
+ public setThreadId(threadId?: string): void {
+ if (this.props.threadId === threadId) return;
+
+ this.props = {
+ ...this.props,
+ threadId,
+ };
+ this.updateSnapshotFromProps();
+ }
+
+ public setForceDot(forceDot?: boolean): void {
+ if (this.props.forceDot === forceDot) return;
+
+ this.props = {
+ ...this.props,
+ forceDot,
+ };
+ this.updateSnapshotFromProps();
+ }
+
+ private setHideBold(hideBold: boolean): void {
+ if (this.props.hideBold === hideBold) return;
+
+ this.props = {
+ ...this.props,
+ hideBold,
+ };
+ this.updateSnapshotFromProps();
+ }
+
+ private updateSnapshotFromProps(): void {
+ this.snapshot.merge(UnreadNotificationBadgeViewModel.computeSnapshot(this.props));
+ }
+
+ private setupListeners(): void {
+ this.teardownListeners();
+
+ const { room } = this.props;
+ if (!room) return;
+
+ room.on(RoomEvent.UnreadNotifications, this.onRoomUnreadNotifications);
+ for (const eventName of notificationChangeEvents) {
+ room.on(eventName, this.onNotificationChanged);
+ }
+
+ this.listenerCleanups.push(() => {
+ room.off(RoomEvent.UnreadNotifications, this.onRoomUnreadNotifications);
+ for (const eventName of notificationChangeEvents) {
+ room.off(eventName, this.onNotificationChanged);
+ }
+ });
+ }
+
+ private teardownListeners(): void {
+ for (const cleanup of this.listenerCleanups) {
+ cleanup();
+ }
+ this.listenerCleanups = [];
+ }
+
+ private readonly onRoomUnreadNotifications = (
+ _unreadNotifications?: NotificationCount,
+ evtThreadId?: string,
+ ): void => {
+ if (this.props.threadId && this.props.threadId !== evtThreadId) return;
+ this.updateSnapshotFromProps();
+ };
+
+ private readonly onNotificationChanged = (): void => {
+ this.updateSnapshotFromProps();
+ };
+
+ private readonly onHideBoldSettingChanged = (): void => {
+ this.setHideBold(SettingsStore.getValue("feature_hidebold"));
+ };
+}
diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx
index 4677b6bdea..f7ea551ecd 100644
--- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx
+++ b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx
@@ -1007,21 +1007,25 @@ describe("EventTile", () => {
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
// By default, the thread will assume it is read.
- expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0);
+ expect(container.querySelectorAll('[data-testid="notification-badge"]')).toHaveLength(0);
act(() => {
room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Total, 3);
});
- expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
- expect(container.getElementsByClassName("mx_NotificationBadge_level_highlight")).toHaveLength(0);
+ let badges = container.querySelectorAll('[data-testid="notification-badge"]');
+ expect(badges).toHaveLength(1);
+ expect(badges[0]).toHaveAttribute("data-badge-type", "dot");
+ expect(badges[0]).toHaveAttribute("data-notification-level", "notification");
act(() => {
room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Highlight, 1);
});
- expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
- expect(container.getElementsByClassName("mx_NotificationBadge_level_highlight")).toHaveLength(1);
+ badges = container.querySelectorAll('[data-testid="notification-badge"]');
+ expect(badges).toHaveLength(1);
+ expect(badges[0]).toHaveAttribute("data-badge-type", "dot");
+ expect(badges[0]).toHaveAttribute("data-notification-level", "highlight");
});
});
diff --git a/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx b/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx
index 6628889dc7..b1e9a2835c 100644
--- a/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx
+++ b/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx
@@ -38,6 +38,10 @@ describe("UnreadNotificationBadge", () => {
return ;
}
+ function getBadge(container: HTMLElement): Element | null {
+ return container.querySelector('[data-testid="notification-badge"]');
+ }
+
beforeEach(() => {
client = stubClient();
client.supportsThreads = () => true;
@@ -84,27 +88,27 @@ describe("UnreadNotificationBadge", () => {
it("renders unread notification badge", () => {
const { container } = render(getComponent());
- expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
- expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeFalsy();
+ expect(getBadge(container)).toHaveTextContent("1");
+ expect(getBadge(container)).toHaveAttribute("data-notification-level", "notification");
act(() => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
});
- expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeTruthy();
+ expect(getBadge(container)).toHaveAttribute("data-notification-level", "highlight");
});
it("renders unread thread notification badge", () => {
const { container } = render(getComponent(THREAD_ID));
- expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
- expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeFalsy();
+ expect(getBadge(container)).toHaveTextContent("1");
+ expect(getBadge(container)).toHaveAttribute("data-notification-level", "notification");
act(() => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1);
});
- expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeTruthy();
+ expect(getBadge(container)).toHaveAttribute("data-notification-level", "highlight");
});
it("hides unread notification badge", () => {
@@ -112,7 +116,7 @@ describe("UnreadNotificationBadge", () => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
const { container } = render(getComponent(THREAD_ID));
- expect(container.querySelector(".mx_NotificationBadge_visible")).toBeFalsy();
+ expect(getBadge(container)).toBeNull();
});
});
@@ -142,7 +146,7 @@ describe("UnreadNotificationBadge", () => {
muteRoom(room);
const { container } = render(getComponent());
- expect(container.querySelector(".mx_NotificationBadge")).toBeNull();
+ expect(getBadge(container)).toBeNull();
});
it("activity renders unread notification badge", () => {
@@ -168,8 +172,8 @@ describe("UnreadNotificationBadge", () => {
room.addLiveEvents([event], { addToState: true });
const { container } = render(getComponent(THREAD_ID));
- expect(container.querySelector(".mx_NotificationBadge_dot")).toBeTruthy();
- expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
- expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeFalsy();
+ expect(getBadge(container)).toBeInTheDocument();
+ expect(getBadge(container)).toHaveAttribute("data-badge-type", "dot");
+ expect(getBadge(container)).not.toHaveAttribute("data-notification-level", "highlight");
});
});
diff --git a/apps/web/test/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel-test.ts b/apps/web/test/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel-test.ts
new file mode 100644
index 0000000000..05b3497cad
--- /dev/null
+++ b/apps/web/test/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel-test.ts
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import { NotificationCountType, PendingEventOrdering, Room, RoomEvent } from "matrix-js-sdk/src/matrix";
+
+import type { MatrixClient } from "matrix-js-sdk/src/matrix";
+import { stubClient } from "../../../test-utils/test-utils";
+import { UnreadNotificationBadgeViewModel } from "../../../../src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel";
+
+describe("UnreadNotificationBadgeViewModel", () => {
+ let client: MatrixClient;
+ let room: Room;
+ const trackedRoomEvents = [
+ RoomEvent.UnreadNotifications,
+ RoomEvent.Receipt,
+ RoomEvent.Timeline,
+ RoomEvent.Redaction,
+ RoomEvent.LocalEchoUpdated,
+ RoomEvent.MyMembership,
+ ];
+
+ beforeEach(() => {
+ client = stubClient();
+ room = new Room("!room:example.org", client, "@user:example.org", {
+ pendingEventOrdering: PendingEventOrdering.Detached,
+ });
+ });
+
+ function setUnreads(greys: number, reds: number): void {
+ room.setUnreadNotificationCount(NotificationCountType.Total, greys);
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, reds);
+ }
+
+ it("computes the initial snapshot from unread state", () => {
+ setUnreads(12, 0);
+
+ const vm = new UnreadNotificationBadgeViewModel({ room });
+
+ expect(vm.getSnapshot()).toMatchObject({
+ shouldRender: true,
+ isVisible: true,
+ isNotification: true,
+ isHighlight: false,
+ badgeType: "badge_2char",
+ symbol: "12",
+ });
+
+ vm.dispose();
+ });
+
+ it("updates when the room unread state changes", () => {
+ const vm = new UnreadNotificationBadgeViewModel({ room });
+ const listener = jest.fn();
+ vm.subscribe(listener);
+
+ setUnreads(0, 2);
+
+ expect(vm.getSnapshot()).toMatchObject({
+ isHighlight: true,
+ symbol: "2",
+ });
+ expect(listener).toHaveBeenCalled();
+
+ vm.dispose();
+ });
+
+ it("skips unchanged force-dot setter updates", () => {
+ setUnreads(1, 0);
+ const vm = new UnreadNotificationBadgeViewModel({ room, forceDot: false });
+ const listener = jest.fn();
+ vm.subscribe(listener);
+
+ vm.setForceDot(false);
+
+ expect(listener).not.toHaveBeenCalled();
+
+ vm.setForceDot(true);
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(vm.getSnapshot().badgeType).toBe("dot");
+
+ vm.dispose();
+ });
+
+ it("moves room event listeners when the room changes", () => {
+ const nextRoom = new Room("!next:example.org", client, "@user:example.org", {
+ pendingEventOrdering: PendingEventOrdering.Detached,
+ });
+ const initialRoomListenerCounts = new Map(
+ trackedRoomEvents.map((eventName) => [eventName, room.listenerCount(eventName)]),
+ );
+ const initialNextRoomListenerCounts = new Map(
+ trackedRoomEvents.map((eventName) => [eventName, nextRoom.listenerCount(eventName)]),
+ );
+ const vm = new UnreadNotificationBadgeViewModel({ room });
+
+ for (const eventName of trackedRoomEvents) {
+ expect(room.listenerCount(eventName)).toBe(initialRoomListenerCounts.get(eventName)! + 1);
+ }
+
+ vm.setRoom(nextRoom);
+
+ for (const eventName of trackedRoomEvents) {
+ expect(room.listenerCount(eventName)).toBe(initialRoomListenerCounts.get(eventName));
+ expect(nextRoom.listenerCount(eventName)).toBe(initialNextRoomListenerCounts.get(eventName)! + 1);
+ }
+
+ vm.dispose();
+ for (const eventName of trackedRoomEvents) {
+ expect(nextRoom.listenerCount(eventName)).toBe(initialNextRoomListenerCounts.get(eventName));
+ }
+ });
+});
diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/dot-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/dot-auto.png
new file mode 100644
index 0000000000..d00dcb7348
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/dot-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/hidden-auto.png
new file mode 100644
index 0000000000..17fe39394f
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/hidden-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/highlight-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/highlight-auto.png
new file mode 100644
index 0000000000..4182c6511a
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/highlight-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/knocked-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/knocked-auto.png
new file mode 100644
index 0000000000..d8b0b93f7e
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/knocked-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/notification-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/notification-auto.png
new file mode 100644
index 0000000000..7aa23cf638
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/notification-auto.png differ
diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts
index 8b22018de6..fd59874ba2 100644
--- a/packages/shared-components/src/index.ts
+++ b/packages/shared-components/src/index.ts
@@ -16,6 +16,7 @@ export * from "./room/composer/Banner";
export * from "./room/composer/UploadButton";
export * from "./crypto/SasEmoji";
export * from "./menus/UserMenu";
+export * from "./notifications/NotificationBadgeView";
export * from "./room/timeline/ReadMarker";
export * from "./room/timeline/EventPresentation";
export * from "./room/timeline/event-tile/body/EventContentBodyView";
diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.module.css b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.module.css
new file mode 100644
index 0000000000..a205f9cdd0
--- /dev/null
+++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.module.css
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+.notificationBadge:not(.visible) {
+ display: none;
+}
+
+.notificationBadge.visible {
+ background-color: var(--roomtile-default-badge-bg-color, var(--cpd-color-icon-secondary));
+ color: var(--cpd-color-text-on-solid-primary);
+ outline: 1px solid transparent;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.notificationBadge.visible.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 8px;
+ background-color: var(--cpd-color-icon-primary);
+}
+
+.notificationBadge.visible.dot .count {
+ display: none;
+}
+
+.notificationBadge.visible.dot.notification {
+ background-color: var(--cpd-color-icon-success-primary);
+}
+
+.notificationBadge.visible.highlight {
+ background-color: var(--cpd-color-icon-critical-primary);
+}
+
+.notificationBadge.visible > svg {
+ width: 16px;
+ height: 16px;
+}
+
+.notificationBadge.visible.badge2Char {
+ width: 1rem;
+ height: 1rem;
+ border-radius: 1rem;
+}
+
+.notificationBadge.visible.badge3Char {
+ width: 1.625rem;
+ height: 1rem;
+ border-radius: 1rem;
+}
+
+.notificationBadge.visible .count {
+ font-size: 0.625rem;
+ line-height: 0.875rem;
+ font-weight: var(--cpd-font-weight-semibold);
+ color: var(--cpd-color-text-on-solid-primary);
+}
diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx
new file mode 100644
index 0000000000..44a87dfe3a
--- /dev/null
+++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { type JSX } from "react";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useMockedViewModel } from "../../core/viewmodel";
+import { withViewDocs } from "../../../.storybook/withViewDocs";
+import { NotificationBadgeView, type NotificationBadgeViewSnapshot } from "./NotificationBadgeView";
+
+type WrapperProps = NotificationBadgeViewSnapshot;
+
+const NotificationBadgeViewWrapperImpl = ({ ...snapshotProps }: WrapperProps): JSX.Element => {
+ const vm = useMockedViewModel(snapshotProps, {});
+
+ return ;
+};
+
+const NotificationBadgeViewWrapper = withViewDocs(NotificationBadgeViewWrapperImpl, NotificationBadgeView);
+
+const meta = {
+ title: "Notifications/NotificationBadgeView",
+ component: NotificationBadgeViewWrapper,
+ tags: ["autodocs"],
+ args: {
+ shouldRender: true,
+ isVisible: true,
+ isNotification: false,
+ isHighlight: false,
+ isKnocked: false,
+ badgeType: "badge_2char",
+ symbol: "3",
+ knockLabel: "Request to join sent",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Notification: Story = {};
+
+export const Dot: Story = {
+ args: {
+ badgeType: "dot",
+ symbol: null,
+ isNotification: true,
+ },
+};
+
+export const Highlight: Story = {
+ args: {
+ isHighlight: true,
+ symbol: "!",
+ },
+};
+
+export const Knocked: Story = {
+ args: {
+ isKnocked: true,
+ symbol: "!",
+ },
+};
+
+export const Hidden: Story = {
+ args: {
+ shouldRender: false,
+ },
+};
diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.test.tsx b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.test.tsx
new file mode 100644
index 0000000000..35f8753631
--- /dev/null
+++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.test.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import { render, screen } from "@test-utils";
+import { composeStories } from "@storybook/react-vite";
+import React from "react";
+import { describe, expect, it } from "vitest";
+
+import * as stories from "./NotificationBadgeView.stories";
+
+const { Notification, Dot, Knocked, Hidden } = composeStories(stories);
+
+describe("NotificationBadgeView", () => {
+ it("renders the default notification badge", () => {
+ const { container } = render();
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders a dot badge", () => {
+ render();
+
+ expect(screen.getByTestId("notification-badge")).toHaveAttribute("data-badge-type", "dot");
+ });
+
+ it("renders a knocked badge icon", () => {
+ render();
+
+ expect(screen.getByLabelText("Request to join sent")).toBeInTheDocument();
+ });
+
+ it("does not render when hidden", () => {
+ const { container } = render();
+
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.tsx b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.tsx
new file mode 100644
index 0000000000..52b558840e
--- /dev/null
+++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.tsx
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { type JSX } from "react";
+import classNames from "classnames";
+import { AskToJoinIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
+
+import { type ViewModel, useViewModel } from "../../core/viewmodel";
+import styles from "./NotificationBadgeView.module.css";
+
+export type NotificationBadgeType = "dot" | "badge_2char" | "badge_3char";
+
+export interface NotificationBadgeViewSnapshot {
+ /**
+ * Controls whether the badge root should render.
+ */
+ shouldRender: boolean;
+ /**
+ * Controls whether the badge receives the visible styling class.
+ */
+ isVisible: boolean;
+ /**
+ * Marks the badge as a regular notification.
+ */
+ isNotification: boolean;
+ /**
+ * Marks the badge as a highlight notification.
+ */
+ isHighlight: boolean;
+ /**
+ * Marks the badge as representing a knock request.
+ */
+ isKnocked: boolean;
+ /**
+ * Controls the visual badge shape.
+ */
+ badgeType: NotificationBadgeType;
+ /**
+ * Display text for non-knock badges.
+ */
+ symbol: string | null;
+ /**
+ * Accessible label for the knock icon.
+ */
+ knockLabel?: string;
+}
+
+export type NotificationBadgeViewModel = ViewModel;
+
+interface NotificationBadgeViewProps {
+ vm: NotificationBadgeViewModel;
+}
+
+export function NotificationBadgeView({ vm }: Readonly): JSX.Element {
+ const { shouldRender, isVisible, isNotification, isHighlight, isKnocked, badgeType, symbol, knockLabel } =
+ useViewModel(vm);
+
+ if (!shouldRender) {
+ return <>>;
+ }
+
+ const classes = classNames(styles.notificationBadge, {
+ [styles.visible]: isVisible,
+ [styles.notification]: isNotification,
+ [styles.highlight]: isHighlight,
+ [styles.dot]: badgeType === "dot",
+ [styles.badge2Char]: badgeType === "badge_2char",
+ [styles.badge3Char]: badgeType === "badge_3char",
+ // Badges with text should always use light colors.
+ "cpd-theme-light": badgeType !== "dot",
+ });
+ const notificationLevel = isHighlight ? "highlight" : isNotification ? "notification" : undefined;
+
+ const content =
+ isKnocked && knockLabel ? (
+
+ ) : (
+ {symbol}
+ );
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/__snapshots__/NotificationBadgeView.test.tsx.snap b/packages/shared-components/src/notifications/NotificationBadgeView/__snapshots__/NotificationBadgeView.test.tsx.snap
new file mode 100644
index 0000000000..2f5ce14acc
--- /dev/null
+++ b/packages/shared-components/src/notifications/NotificationBadgeView/__snapshots__/NotificationBadgeView.test.tsx.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`NotificationBadgeView > renders the default notification badge 1`] = `
+
+`;
diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/index.tsx b/packages/shared-components/src/notifications/NotificationBadgeView/index.tsx
new file mode 100644
index 0000000000..fbb2fef1ee
--- /dev/null
+++ b/packages/shared-components/src/notifications/NotificationBadgeView/index.tsx
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+export {
+ NotificationBadgeView,
+ type NotificationBadgeType,
+ type NotificationBadgeViewModel,
+ type NotificationBadgeViewSnapshot,
+} from "./NotificationBadgeView";