diff --git a/ui/src/components/NotificationBell.module.css b/ui/src/components/NotificationBell.module.css
new file mode 100644
index 00000000..cf6662a9
--- /dev/null
+++ b/ui/src/components/NotificationBell.module.css
@@ -0,0 +1,27 @@
+.bell {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ color: var(--fg);
+ text-decoration: none;
+}
+.bell:hover { background: var(--hover-bg); }
+.badge {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 8px;
+ background: var(--error);
+ color: var(--bg);
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 16px;
+ text-align: center;
+}
diff --git a/ui/src/components/NotificationBell.test.tsx b/ui/src/components/NotificationBell.test.tsx
new file mode 100644
index 00000000..0cd05ae3
--- /dev/null
+++ b/ui/src/components/NotificationBell.test.tsx
@@ -0,0 +1,47 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router';
+import type { ReactNode } from 'react';
+import { useEnvironmentStore } from '../api/environment-store';
+
+vi.mock('../api/client', () => ({ api: { GET: vi.fn() } }));
+
+import { api as apiClient } from '../api/client';
+import { NotificationBell } from './NotificationBell';
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return (
+
+ {children}
+
+ );
+}
+
+describe('NotificationBell', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useEnvironmentStore.setState({ environment: 'dev' });
+ });
+
+ it('renders bell with no badge when zero unread', async () => {
+ (apiClient.GET as any).mockResolvedValue({
+ data: { count: 0 },
+ error: null,
+ });
+ render(, { wrapper });
+ expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
+ // Badge is only rendered when count > 0; no numeric text should appear.
+ expect(screen.queryByText(/^\d+$/)).toBeNull();
+ });
+
+ it('shows unread count badge when unread alerts exist', async () => {
+ (apiClient.GET as any).mockResolvedValue({
+ data: { count: 3 },
+ error: null,
+ });
+ render(, { wrapper });
+ expect(await screen.findByText('3')).toBeInTheDocument();
+ });
+});
diff --git a/ui/src/components/NotificationBell.tsx b/ui/src/components/NotificationBell.tsx
new file mode 100644
index 00000000..d2b7738d
--- /dev/null
+++ b/ui/src/components/NotificationBell.tsx
@@ -0,0 +1,51 @@
+import { Link } from 'react-router';
+import { Bell } from 'lucide-react';
+import { useUnreadCount } from '../api/queries/alerts';
+import { useSelectedEnv } from '../api/queries/alertMeta';
+import { usePageVisible } from '../hooks/usePageVisible';
+import css from './NotificationBell.module.css';
+
+/**
+ * Global notification bell shown in the layout header. Links to the alerts
+ * inbox and renders a badge with the unread-alert count for the currently
+ * selected environment.
+ *
+ * Polling is driven by `useUnreadCount` (30s interval, paused in background
+ * via TanStack Query's `refetchIntervalInBackground: false`). The
+ * `usePageVisible` hook is retained as a defense-in-depth signal so future
+ * UI behavior (e.g. animations, live-region updates) can key off visibility
+ * without re-wiring the polling logic.
+ *
+ * TODO (spec §13): per-severity badge coloring — the backend
+ * `UnreadCountResponse` currently exposes only a scalar `count` field. To
+ * colour the badge by max unread severity (CRITICAL → error, WARNING →
+ * amber, INFO → muted) the DTO must grow a `bySeverity` map; deferred to a
+ * future task. Until then the badge uses a single `var(--error)` tint.
+ */
+export function NotificationBell() {
+ const env = useSelectedEnv();
+ // Subscribe to visibility so the component re-renders on tab focus, even
+ // though the polling pause itself is handled inside useUnreadCount.
+ usePageVisible();
+ const { data } = useUnreadCount();
+
+ const count = data?.count ?? 0;
+
+ if (!env) return null;
+
+ return (
+
+
+ {count > 0 && (
+
+ {count > 99 ? '99+' : count}
+
+ )}
+
+ );
+}
diff --git a/ui/src/hooks/usePageVisible.test.ts b/ui/src/hooks/usePageVisible.test.ts
new file mode 100644
index 00000000..488fcc28
--- /dev/null
+++ b/ui/src/hooks/usePageVisible.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { usePageVisible } from './usePageVisible';
+
+describe('usePageVisible', () => {
+ beforeEach(() => {
+ Object.defineProperty(document, 'visibilityState', {
+ value: 'visible',
+ configurable: true,
+ writable: true,
+ });
+ });
+
+ it('returns true when visible, false when hidden', () => {
+ const { result } = renderHook(() => usePageVisible());
+ expect(result.current).toBe(true);
+
+ act(() => {
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ expect(result.current).toBe(false);
+
+ act(() => {
+ Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ expect(result.current).toBe(true);
+ });
+});
diff --git a/ui/src/hooks/usePageVisible.ts b/ui/src/hooks/usePageVisible.ts
new file mode 100644
index 00000000..fbfc4654
--- /dev/null
+++ b/ui/src/hooks/usePageVisible.ts
@@ -0,0 +1,22 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * Tracks Page Visibility API state for the current document.
+ *
+ * Returns `true` when the tab is visible, `false` when hidden. Useful for
+ * pausing work (polling, animations, expensive DOM effects) while the tab
+ * is backgrounded. SSR-safe: defaults to `true` when `document` is undefined.
+ */
+export function usePageVisible(): boolean {
+ const [visible, setVisible] = useState(() =>
+ typeof document === 'undefined' ? true : document.visibilityState === 'visible',
+ );
+
+ useEffect(() => {
+ const onChange = () => setVisible(document.visibilityState === 'visible');
+ document.addEventListener('visibilitychange', onChange);
+ return () => document.removeEventListener('visibilitychange', onChange);
+ }, []);
+
+ return visible;
+}