Notification Center

Notifications 3 unread

  • Deployment succeeded. spruce-docs is live on production.

    · Deployments

  • Priya Raman mentioned you in Pricing page copy: “Can you check the plan names?”

    · Comments

  • API usage at 90%. You’ll reach the limit in about 3 days. Review the plan

    · Billing

  • Noah Fischer accepted your invitation to Marketing site.

    · Team

@use 'sprucecss/scss/spruce' as *;

.notification-center {
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  box-shadow: shadow('md');
  overflow: hidden;

  &__header {
    align-items: center;
    display: flex;
    gap: spacer('s');
    justify-content: space-between;
    padding: spacer('s') spacer('m');
  }

  &__title {
    align-items: center;
    display: flex;
    font-size: font-size('h5');
    gap: spacer('xs');
    margin: 0;

    .badge[hidden] {
      display: none;
    }
  }

  &__filter {
    display: flex;
    gap: spacer('xxs');
    padding: 0 spacer('m') spacer('s');

    &[hidden] {
      display: none;
    }
  }

  &__list {
    border-block-start: config('border-width-base', $display) solid color('border');
    list-style: none;
    margin: 0;
    padding: 0;
  }

  &__item {
    align-items: flex-start;
    border-block-end: config('border-width-base', $display) solid color('border');
    display: grid;
    gap: spacer('s');
    grid-template-columns: auto minmax(0, 1fr) auto;
    margin: 0 !important;
    padding: spacer('s') spacer('m');
    position: relative;

    &--unread {
      background-color: color-alpha('primary', 0.05);
    }

    &[hidden] {
      display: none;
    }
  }

  &__icon {
    align-items: center;
    background-color: var(--icon-background);
    block-size: 2.5rem;
    border-radius: radius('pill');
    color: var(--icon-color);
    display: flex;
    inline-size: 2.5rem;
    justify-content: center;

    &--success {
      --icon-background: #{color-alpha('success', 0.14, 'alert')};
      --icon-color: #{color('success', 'alert')};
    }

    &--warning {
      --icon-background: #{color-alpha('warning', 0.18, 'alert')};
      --icon-color: #{color('warning', 'alert')};
    }

    svg {
      block-size: 1.25rem;
      inline-size: 1.25rem;
    }
  }

  &__body {
    @include layout-stack('xxs');
  }

  &__text {
    font-size: config('font-size-sm', $typography);
    line-height: config('line-height-md', $typography);

    strong {
      color: color('heading');
    }
  }

  &__meta {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
  }

  // The unread dot is the button that marks the item as read.
  &__read {
    @include clear-btn;
    align-items: center;
    block-size: 1.75rem;
    border-radius: radius('pill');
    display: flex;
    inline-size: 1.75rem;
    justify-content: center;

    &::before {
      background-color: color('primary');
      block-size: 0.6rem;
      border-radius: radius('pill');
      content: '';
      inline-size: 0.6rem;
    }

    &:hover {
      background-color: color-alpha('primary', 0.12);
    }

    &:focus-visible {
      outline: 2px solid color('focus-ring');
      outline-offset: 2px;
    }
  }

  &__empty {
    color: color('muted');
    padding: spacer('l') spacer('m');
    text-align: center;
  }

  &__footer {
    display: flex;
    font-size: config('font-size-sm', $typography);
    font-weight: 600;
    justify-content: space-between;
    padding: spacer('s') spacer('m');
  }
}
<section class="notification-center" aria-labelledby="notification-center-title" data-notification-center>
    <header class="notification-center__header">
        <h2 class="notification-center__title" id="notification-center-title">Notifications <span class="badge badge--primary badge--count" data-notification-count>3<span class="sr-only"> unread</span></span></h2>
        <button class="btn btn--ghost-primary btn--sm" type="button" data-notification-read-all>Mark all as read</button>
    </header>
    <div class="notification-center__filter" role="group" aria-label="Show" hidden data-notification-filter>
        <button class="btn btn--sm btn--soft-primary" type="button" aria-pressed="true" data-filter="all">All</button>
        <button class="btn btn--sm btn--ghost-primary" type="button" aria-pressed="false" data-filter="unread">Unread</button>
    </div>
    <ul class="notification-center__list" role="list">
        <li class="notification-center__item notification-center__item--unread" data-notification>
            <span class="notification-center__icon notification-center__icon--success" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
            <div class="notification-center__body">
                <p class="notification-center__text"><strong>Deployment succeeded.</strong> <a href="#">spruce-docs</a> is live on production.</p>
                <p class="notification-center__meta"><time datetime="2026-10-04T09:12">12 minutes ago</time> · Deployments</p>
            </div>
            <button class="notification-center__read" type="button" data-notification-read><span class="sr-only">Mark as read: Deployment succeeded</span></button>
        </li>
        <li class="notification-center__item notification-center__item--unread" data-notification>
            <span class="avatar avatar--sm"><img src="https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&w=96&h=96&q=70" alt="" width="40" height="40" loading="lazy"></span>
            <div class="notification-center__body">
                <p class="notification-center__text"><strong>Priya Raman</strong> mentioned you in <a href="#">Pricing page copy</a>: “Can you check the plan names?”</p>
                <p class="notification-center__meta"><time datetime="2026-10-04T08:40">44 minutes ago</time> · Comments</p>
            </div>
            <button class="notification-center__read" type="button" data-notification-read><span class="sr-only">Mark as read: Priya Raman mentioned you</span></button>
        </li>
        <li class="notification-center__item notification-center__item--unread" data-notification>
            <span class="notification-center__icon notification-center__icon--warning" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg></span>
            <div class="notification-center__body">
                <p class="notification-center__text"><strong>API usage at 90%.</strong> You’ll reach the limit in about 3 days. <a href="#">Review the plan</a></p>
                <p class="notification-center__meta"><time datetime="2026-10-04T07:05">2 hours ago</time> · Billing</p>
            </div>
            <button class="notification-center__read" type="button" data-notification-read><span class="sr-only">Mark as read: API usage at 90%</span></button>
        </li>
        <li class="notification-center__item" data-notification>
            <span class="avatar avatar--sm"><img src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=96&h=96&q=70" alt="" width="40" height="40" loading="lazy"></span>
            <div class="notification-center__body">
                <p class="notification-center__text"><strong>Noah Fischer</strong> accepted your invitation to <a href="#">Marketing site</a>.</p>
                <p class="notification-center__meta"><time datetime="2026-10-03T16:20">Yesterday</time> · Team</p>
            </div>
        </li>
    </ul>
    <p class="notification-center__empty" hidden data-notification-empty>You’re all caught up.</p>
    <p class="sr-only" aria-live="polite" data-notification-status></p>
    <footer class="notification-center__footer">
        <a href="#">View all notifications</a>
        <a href="#">Settings</a>
    </footer>
</section>
(() => {
  document.querySelectorAll('[data-notification-center]').forEach((center) => {
    const count = center.querySelector('[data-notification-count]');
    const filter = center.querySelector('[data-notification-filter]');
    const empty = center.querySelector('[data-notification-empty]');
    const status = center.querySelector('[data-notification-status]');
    const items = [...center.querySelectorAll('[data-notification]')];
    let show = 'all';

    const unread = () => items.filter((item) => item.classList.contains('notification-center__item--unread'));

    const update = () => {
      const total = unread().length;

      count.hidden = total === 0;
      count.firstChild.textContent = total;
      items.forEach((item) => {
        item.hidden = show === 'unread' && !item.classList.contains('notification-center__item--unread');
      });
      empty.hidden = items.some((item) => !item.hidden);
    };

    const markRead = (item) => {
      item.classList.remove('notification-center__item--unread');
      item.querySelector('[data-notification-read]')?.remove();
    };

    // The filter needs JavaScript, so it's hidden without it.
    filter.hidden = false;
    filter.addEventListener('click', (event) => {
      const button = event.target.closest('[data-filter]');

      if (!button) return;

      show = button.dataset.filter;
      filter.querySelectorAll('[data-filter]').forEach((other) => {
        const isActive = other === button;

        other.setAttribute('aria-pressed', String(isActive));
        other.classList.toggle('btn--soft-primary', isActive);
        other.classList.toggle('btn--ghost-primary', !isActive);
      });
      update();
    });

    center.addEventListener('click', (event) => {
      const button = event.target.closest('[data-notification-read]');

      if (!button) return;

      const item = button.closest('[data-notification]');
      const next = item.nextElementSibling?.querySelector('a, button');

      markRead(item);
      update();
      status.textContent = `Marked as read. ${unread().length} unread.`;
      // The button is gone; keep the focus nearby.
      (next || center.querySelector('[data-notification-read-all]')).focus();
    });

    center.querySelector('[data-notification-read-all]').addEventListener('click', () => {
      unread().forEach(markRead);
      update();
      status.textContent = 'All notifications marked as read.';
    });

    update();
  });
})();

Notification Center

A notification inbox panel with an unread counter, an All/Unread filter and buttons to mark one or all notifications as read.

Anatomy

  • .notification-center__header: the title with the unread Badge and the "Mark all as read" button.
  • .notification-center__filter: two toggle buttons (All, Unread).
  • .notification-center__item: an __icon or an Avatar, the __body (text and meta) and, on --unread items, the __read dot button.
  • .notification-center__empty and __footer.

Accessibility and Usability

  • The unread dot is a real button with hidden text ("Mark as read: Deployment succeeded"), so it's reachable by keyboard and says what it does.
  • After marking an item as read, its button is removed, so the focus moves to the next item's first control (or the "Mark all as read" button) instead of getting lost.
  • The changes are announced in a polite live region ("Marked as read. 2 unread."), and the counter has a hidden "unread" text.
  • The unread items have a tinted background and the dot; the dot alone carries the state for screen readers through its button.

Technical Details

  • The filter toggles the hidden attribute of the read items. It needs JavaScript, so it's hidden without it; the "Mark all as read" button can also submit a form to the server.
  • Wire the buttons to your API and update the badge in the App Shell header too.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.