@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);
}
&__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();
};
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.`;
(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();
});
})();