Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    Templates

    Notification centre

    The bell in the top bar; an unread count and a panel with All and Unread, mark as read and an empty state.

    The notification centre shows what happened for the person without leaving the page. The bell carries the unread count; the panel is a Popover and closes on an outside press or Escape. Use a Toast for an immediate result and this panel for the lasting history (Notifications).

    Press the bell. Pressing a row marks that notification read and the count drops; pick “Unread” to see only new ones; “Mark all as read” clears them all. The brand and mode switches in the page header restyle the preview too.

    Live example, StorybookOpen in Storybook
    Live example, StorybookOpen in Storybook
    Live example, StorybookOpen in Storybook
    Live example, StorybookOpen in Storybook
    Terminal window
    pnpm add @bigbang/ui
    Data Type Note
    Notifications { id, icon, title, meta, unread }[] Newest first; pressing a row turns unread off.
    state 'some' | 'empty' empty starts with the empty state.
    defaultOpen boolean Starts with the panel open.

    The AppShell template puts this component in the top bar, between search and the account menu.

    import { Badge, Button, EmptyState, Icon, IconButton, Inline, Link, ListItem, Popover, SegmentedControl, Stack, VisuallyHidden } from '@bigbang/ui';
    import type { IconName } from '@bigbang/ui';
    import { useState } from 'react';
    import type { Lang } from '../../patterns/lang';
    const copy = {
    tr: {
    title: 'Bildirimler',
    trigger: (unread: number) => (unread > 0 ? `Bildirimler, ${unread} okunmamış` : 'Bildirimler'),
    show: 'Göster',
    all: 'Tümü',
    unreadOnly: 'Okunmamış',
    unread: 'Okunmamış',
    markAll: 'Tümünü okundu işaretle',
    markedAll: 'Tüm bildirimler okundu olarak işaretlendi.',
    seeAll: 'Tüm bildirimler',
    emptyTitle: 'Yeni bildirim yok',
    emptyText: 'Size atanan görevler ve ekipten gelen yanıtlar burada görünür.',
    unreadEmptyTitle: 'Okunmamış bildirim yok',
    unreadEmptyText: 'Hepsini okudunuz. Eski bildirimler "Tümü" altında.',
    items: [
    { icon: 'user', title: 'Kullanıcı B sizi Proje 1 ekibine ekledi', meta: '5 dk önce' },
    { icon: 'pencil', title: 'Kayıt 12 üzerinde yeni yorum var', meta: '1 saat önce' },
    { icon: 'clock', title: 'Görev 3 için son gün yarın', meta: '3 saat önce' },
    { icon: 'circle-check', title: 'Dışa aktarma tamamlandı', meta: 'Dün' },
    { icon: 'settings', title: 'Çalışma alanı ayarları güncellendi', meta: '2 gün önce' },
    ],
    },
    en: {
    title: 'Notifications',
    trigger: (unread: number) => (unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'),
    show: 'Show',
    all: 'All',
    unreadOnly: 'Unread',
    unread: 'Unread',
    markAll: 'Mark all as read',
    markedAll: 'All notifications marked as read.',
    seeAll: 'All notifications',
    emptyTitle: 'No new notifications',
    emptyText: 'Tasks assigned to you and replies from your team show up here.',
    unreadEmptyTitle: 'No unread notifications',
    unreadEmptyText: 'You have read them all. Older ones are under "All".',
    items: [
    { icon: 'user', title: 'User B added you to the Project 1 team', meta: '5 min ago' },
    { icon: 'pencil', title: 'New comment on Record 12', meta: '1 hour ago' },
    { icon: 'clock', title: 'Task 3 is due tomorrow', meta: '3 hours ago' },
    { icon: 'circle-check', title: 'Export finished', meta: 'Yesterday' },
    { icon: 'settings', title: 'Workspace settings updated', meta: '2 days ago' },
    ],
    },
    };
    type Item = { id: number; icon: IconName; title: string; meta: string; unread: boolean };
    export type NotificationsCentreProps = {
    lang?: Lang;
    /** `empty` starts with no notifications. */
    state?: 'some' | 'empty';
    /** Opens the panel on first render. */
    defaultOpen?: boolean;
    };
    /**
    * The notification bell of the app header: an unread count on the button and a Popover with an
    * All / Unread switch, a list where pressing a row marks it read, "Mark all as read" and an empty state.
    */
    export function NotificationsCentre({ lang = 'tr', state = 'some', defaultOpen = false }: NotificationsCentreProps) {
    const t = copy[lang];
    const [items, setItems] = useState<Item[]>(() =>
    state === 'empty' ? [] : t.items.map((item, index) => ({ ...item, id: index, icon: item.icon as IconName, unread: index < 3 })),
    );
    const [filter, setFilter] = useState('all');
    const [announcement, setAnnouncement] = useState('');
    const unread = items.filter((item) => item.unread).length;
    const shown = filter === 'unread' ? items.filter((item) => item.unread) : items;
    const markRead = (id: number) => setItems((list) => list.map((item) => (item.id === id ? { ...item, unread: false } : item)));
    const markAll = () => {
    setItems((list) => list.map((item) => ({ ...item, unread: false })));
    setAnnouncement(t.markedAll);
    };
    return (
    <span className="relative inline-flex">
    <Popover
    title={t.title}
    side="bottom"
    align="end"
    size="lg"
    defaultOpen={defaultOpen}
    onOpenChange={(open) => {
    if (!open) setAnnouncement('');
    }}
    trigger={<IconButton icon="bell" label={t.trigger(unread)} variant="tertiary" size="sm" tooltip={false} />}
    >
    <Stack gap="md">
    {items.length > 0 ? (
    <SegmentedControl
    label={t.show}
    size="sm"
    fullWidth
    value={filter}
    onValueChange={setFilter}
    items={[
    { value: 'all', label: t.all },
    { value: 'unread', label: unread > 0 ? `${t.unreadOnly} (${unread})` : t.unreadOnly },
    ]}
    />
    ) : null}
    {shown.length > 0 ? (
    <ListItem.List divided animate aria-label={t.title} className="-mx-sm max-h-80 overflow-y-auto">
    {shown.map((item) => (
    <ListItem
    key={item.id}
    size="sm"
    render={<button type="button" />}
    onClick={() => markRead(item.id)}
    leading={<Icon name={item.icon} tone={item.unread ? 'brand' : 'subtle'} />}
    title={
    <>
    {/* The space sits outside the hidden span so the accessible name keeps it. */}
    {item.unread ? (
    <>
    <VisuallyHidden>{`${t.unread}:`}</VisuallyHidden>{' '}
    </>
    ) : null}
    <span className={item.unread ? 'font-semibold' : undefined}>{item.title}</span>
    </>
    }
    subtitle={item.meta}
    trailing={item.unread ? <span aria-hidden="true" className="block size-2 rounded-full bg-status-brand-emphasis" /> : null}
    />
    ))}
    </ListItem.List>
    ) : (
    <EmptyState
    size="sm"
    headingLevel={3}
    icon={items.length === 0 ? 'bell' : 'circle-check'}
    title={items.length === 0 ? t.emptyTitle : t.unreadEmptyTitle}
    description={items.length === 0 ? t.emptyText : t.unreadEmptyText}
    />
    )}
    <Inline justify="between" align="center" gap="sm" wrap>
    {items.length > 0 ? (
    <Button variant="tertiary" size="sm" startIcon={<Icon name="check" />} onClick={markAll} disabled={unread === 0} focusableWhenDisabled>
    {t.markAll}
    </Button>
    ) : (
    <span />
    )}
    <Link href="#" size="sm">
    {t.seeAll}
    </Link>
    </Inline>
    <VisuallyHidden role="status">{announcement}</VisuallyHidden>
    </Stack>
    </Popover>
    {unread > 0 ? (
    // The count is part of the button's name; the badge only shows it.
    <Badge aria-hidden="true" tone="brand" variant="bold" size="sm" className="pointer-events-none absolute -end-xs -top-xs min-w-5 justify-center">
    {unread}
    </Badge>
    ) : null}
    </span>
    );
    }
    1. A bell IconButton with a bold Badge on it that shows the unread count.
    2. A Popover (size="lg", align="end") titled “Notifications”.
    3. A SegmentedControl “Show”: All / Unread (with the count).
    4. A ListItem list of button rows: icon, title and time; unread ones bold with a dot at the end.
    5. A small EmptyState with an h3 when the list is empty.
    6. Footer: “Mark all as read” and an “All notifications” Link.

    The panel fades and scales in under the bell with motion.popover. Rows enter with a short stagger from motion.listItem. With reduced motion, scaling and sliding stop and only a short fade stays.

    • The bell’s name holds the count: “Notifications, 3 unread”. The badge is aria-hidden, so the count is not read twice and not shown by colour alone.
    • The panel is a dialog named by its title; on open, focus moves to its first control, and Escape closes it and returns focus to the bell.
    • Unread rows start with a hidden “Unread:” prefix; bold text and the dot never carry the meaning alone.
    • The result of “Mark all as read” is read from a polite status region in the panel. With nothing unread, the button is disabled but stays focusable.
    • The empty state title is an h3, under the page h1 and the panel title.

    Popover, IconButton, Badge, SegmentedControl, ListItem, EmptyState, Button, Link, VisuallyHidden. Patterns: Notifications, Empty states.