Meldingencentrum New
Bell with an unread counter and a panel with notifications grouped per day (today, yesterday, date), Unread and All tabs, mark all as read, read one by clicking it, an empty state with one way out and a new notification that arrives after a few seconds and is announced via aria-live. Esc or an outside click closes and returns focus to the bell. In production you connect it to Server-Sent Events or web push.
Fase 3 van de aanbouw: kozijnen plaatsen, gepland in week 43.
Meldingen
How to use it
- Paste the HTML where the components should appear.
- Add the CSS to your stylesheet. All classes are prefixed, so nothing clashes with your own styles.
- Add the JavaScript and call init() with the element that contains the HTML.
<!-- Meldingencentrum · NedDev · eigen werk. -->
<div class="jouw-container">
<div class="ntc">
<div class="ntc__bar">
<span class="ntc__logo">Mijn verbouwing</span>
<span class="ntc__who">Familie Bakker, Amersfoort</span>
<button type="button" class="ntc__bell" aria-expanded="true" aria-controls="ntc-panel">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>
<span class="ntc__badge" aria-hidden="true"></span><span class="ntc__sr ntc__bl"></span>
</button>
</div>
<div class="ntc__page"><p>Fase 3 van de aanbouw: kozijnen plaatsen, gepland in week 43.</p></div>
<div class="ntc__panel" id="ntc-panel" role="region" aria-label="Meldingen">
<div class="ntc__ph">
<h3 class="ntc__h">Meldingen</h3>
<button type="button" class="ntc__all">Alles gelezen</button>
</div>
<div class="ntc__tabs" role="tablist" aria-label="Filter meldingen">
<button type="button" role="tab" id="ntc-t1" aria-selected="true" aria-controls="ntc-list" data-f="unread">Ongelezen</button>
<button type="button" role="tab" id="ntc-t2" aria-selected="false" aria-controls="ntc-list" data-f="all" tabindex="-1">Alles</button>
</div>
<div class="ntc__list" id="ntc-list" role="tabpanel" aria-labelledby="ntc-t1" tabindex="0"></div>
</div>
<p class="ntc__sr ntc__live" aria-live="polite"></p>
</div>
</div>/* Meldingencentrum · NedDev · eigen werk. */
.ntc{--ink:#0a0a0a;--pop:#F5480F;--pd:#b83a0b;--mut:#5b5b5b;--line:rgba(10,10,10,.12);position:absolute;inset:0;overflow:hidden;font-family:'Switzer',system-ui,sans-serif;font-size:12.5px;line-height:1.35;color:var(--ink);background:#f4f3f1;text-align:left}
.ntc *,.ntc *::before,.ntc *::after{box-sizing:border-box}
.ntc__sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);margin:0;white-space:nowrap}
.ntc__bar{display:flex;align-items:center;gap:10px;padding:8px 10px 8px 12px;background:#fff;border-bottom:1px solid var(--line)}
.ntc__logo{font-weight:700;font-size:13.5px}
.ntc__who{font-size:11.5px;color:var(--mut);margin-right:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ntc__bell{position:relative;display:grid;place-items:center;width:32px;height:32px;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--ink);cursor:pointer;transition:border-color .15s}
.ntc__bell:hover{border-color:var(--ink)}
.ntc__bell[aria-expanded="true"]{border-color:var(--ink);background:#f4f3f1}
.ntc__bell svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linejoin:round}
.ntc__badge{position:absolute;top:-5px;right:-5px;min-width:17px;height:17px;padding:0 4px;border-radius:4px;background:var(--pop);color:var(--ink);font-size:11px;font-weight:700;line-height:17px;text-align:center;font-variant-numeric:tabular-nums}
.ntc__badge:empty{display:none}
.ntc__badge.is-bump{animation:ntc-bump .4s ease}
@keyframes ntc-bump{40%{transform:scale(1.25)}}
.ntc__page{padding:12px;color:var(--mut);font-size:12px}
.ntc__page p{margin:0}
.ntc__panel{position:absolute;top:46px;right:8px;bottom:8px;width:min(300px,calc(100% - 16px));display:flex;flex-direction:column;background:#fff;border:1px solid var(--ink);border-radius:4px;opacity:1;translate:0 0;transition:opacity .15s,translate .15s}
.ntc__panel[hidden]{display:none}
@starting-style{.ntc__panel{opacity:0;translate:0 -4px}}
.ntc__ph{display:flex;align-items:center;justify-content:space-between;padding:8px 10px 4px}
.ntc__h{margin:0;font-size:13.5px;font-weight:600}
.ntc__all{min-height:24px;padding:4px 0;border:0;background:none;font:500 11.5px/1.2 'Switzer',system-ui,sans-serif;color:var(--ink);text-decoration:underline;text-underline-offset:2px;cursor:pointer}
.ntc__all:hover{color:var(--pd)}
.ntc__all:disabled{color:#9a9a9a;text-decoration:none;cursor:default}
.ntc__tabs{display:flex;gap:12px;padding:0 10px;border-bottom:1px solid var(--line)}
.ntc__tabs button{padding:6px 0;border:0;border-bottom:2px solid transparent;margin-bottom:-1px;background:none;font:500 12px/1.2 'Switzer',system-ui,sans-serif;color:var(--mut);cursor:pointer}
.ntc__tabs button:hover{color:var(--ink)}
.ntc__tabs button[aria-selected="true"]{color:var(--ink);border-bottom-color:var(--ink);font-weight:600}
.ntc__list{flex:1;min-height:0;overflow:auto;scrollbar-width:thin}
.ntc__day{margin:0;padding:8px 10px 2px;font-size:11px;font-weight:600;color:var(--mut)}
.ntc__item{display:flex;gap:8px;width:100%;padding:6px 10px;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;transition:background-color .15s}
.ntc__item:hover{background:#f4f3f1}
.ntc__dot{flex:none;width:7px;height:7px;margin-top:5px;border-radius:4px;background:transparent}
.ntc__item.is-unread .ntc__dot{background:var(--pop)}
.ntc__item.is-unread .ntc__tx b{font-weight:600}
.ntc__tx{flex:1;min-width:0}
.ntc__tx b{display:block;font-weight:400}
.ntc__tx span{display:block;font-size:11px;color:var(--mut)}
.ntc__item.is-new{animation:ntc-in .35s ease}
@keyframes ntc-in{from{background:#fdeee8}}
.ntc__empty{padding:22px 14px;text-align:center;color:var(--mut)}
.ntc__empty b{display:block;color:var(--ink);font-size:13px;margin-bottom:2px}
.ntc__empty button{margin-top:8px;padding:6px 10px;border:1px solid var(--ink);border-radius:4px;background:#fff;font:500 12px/1 'Switzer',system-ui,sans-serif;color:var(--ink);cursor:pointer}
.ntc__empty button:hover{background:var(--ink);color:#fff}
.ntc button:focus-visible,.ntc__list:focus-visible{outline:2px solid var(--pop);outline-offset:-2px}
.ntc__bell:focus-visible{outline-offset:2px}
@media (prefers-reduced-motion:reduce){.ntc *{transition:none!important;animation:none!important}}// Meldingencentrum · NedDev · eigen werk.
// root = het element waarin de HTML staat
function init(root){
const w = root.querySelector('.ntc'); if (!w) return;
const bell = w.querySelector('.ntc__bell'), badge = w.querySelector('.ntc__badge'), bl = w.querySelector('.ntc__bl');
const panel = w.querySelector('.ntc__panel'), list = w.querySelector('.ntc__list'), allBtn = w.querySelector('.ntc__all');
const tabs = [...w.querySelectorAll('[role="tab"]')], live = w.querySelector('.ntc__live');
const d0 = new Date(); d0.setHours(0, 0, 0, 0);
const at = (days, h, m) => { const d = new Date(d0); d.setDate(d.getDate() - days); d.setHours(h, m); return d; };
let N = [
{ t: 'Offerte voor de dakkapel staat klaar', s: 'Bekijk en teken online', d: at(0, 10, 42), u: true },
{ t: 'Uitvoerder Mark komt morgen om 7:30', s: 'Zet de oprit vrij voor de container', d: at(0, 9, 15), u: true },
{ t: '6 nieuwe foto\'s van de aanbouw', s: 'Fundering en eerste laag metselwerk', d: at(1, 16, 5), u: true },
{ t: 'Factuur 2026-118 is betaald', s: '€ 8.450,00, termijn 2 van 4', d: at(1, 11, 30), u: false },
{ t: 'Planning week 43 bijgewerkt', s: 'Kozijnen schuiven een dag op', d: at(3, 14, 0), u: false }
];
let filter = 'unread', added = false;
const tm = new Intl.DateTimeFormat('nl-NL', { hour: '2-digit', minute: '2-digit' });
const dm = new Intl.DateTimeFormat('nl-NL', { weekday: 'short', day: 'numeric', month: 'short' });
const dayLabel = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); const n = Math.round((d0 - x) / 864e5); return n === 0 ? 'Vandaag' : n === 1 ? 'Gisteren' : dm.format(d); };
function render(newId) {
const un = N.filter(n => n.u).length;
badge.textContent = un ? String(un) : '';
bl.textContent = un ? 'Meldingen, ' + un + ' ongelezen' : 'Meldingen, niets ongelezen';
allBtn.disabled = !un;
const show = N.filter(n => filter === 'all' || n.u).sort((a, b) => b.d - a.d);
if (!show.length) {
list.innerHTML = '<div class="ntc__empty"><b>Je bent helemaal bij</b>Nieuwe meldingen verschijnen hier.<br><button type="button" class="ntc__seeall">Alle meldingen bekijken</button></div>';
return;
}
let h = '', last = '';
show.forEach(n => {
const lab = dayLabel(n.d);
if (lab !== last) { if (last) h += '</div>'; h += '<div role="group" aria-label="' + lab + '"><p class="ntc__day" aria-hidden="true">' + lab + '</p>'; last = lab; }
h += '<button type="button" class="ntc__item' + (n.u ? ' is-unread' : '') + (n === newId ? ' is-new' : '') + '" data-k="' + N.indexOf(n) + '"><span class="ntc__dot" aria-hidden="true"></span><span class="ntc__tx"><b>' + n.t + '</b><span>' + (n.u ? 'Ongelezen · ' : '') + n.s + ' · ' + tm.format(n.d) + '</span></span></button>';
});
list.innerHTML = h + '</div>';
}
function setOpen(o, focusBell) {
panel.hidden = !o; bell.setAttribute('aria-expanded', String(o));
if (!o && focusBell) bell.focus();
}
function setTab(t, focus) {
tabs.forEach(x => { const on = x === t; x.setAttribute('aria-selected', String(on)); x.tabIndex = on ? 0 : -1; });
list.setAttribute('aria-labelledby', t.id); filter = t.dataset.f; render(); if (focus) t.focus();
}
bell.addEventListener('click', () => { setOpen(panel.hidden); if (!panel.hidden) tabs.find(t => t.tabIndex === 0).focus(); });
tabs.forEach(t => t.addEventListener('click', () => setTab(t)));
w.querySelector('.ntc__tabs').addEventListener('keydown', e => {
if (!/Arrow(Left|Right)|Home|End/.test(e.key)) return; e.preventDefault();
const i = tabs.indexOf(document.activeElement);
const n = e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : (i + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
setTab(tabs[n], true);
});
allBtn.addEventListener('click', () => { const n = N.filter(x => x.u).length; N.forEach(x => { x.u = false; }); render(); live.textContent = n + ' meldingen als gelezen gemarkeerd'; tabs[0].focus(); });
list.addEventListener('click', e => {
if (e.target.closest('.ntc__seeall')) { setTab(tabs[1], true); return; }
const it = e.target.closest('.ntc__item'); if (!it) return;
const n = N[+it.dataset.k]; if (!n.u) { live.textContent = 'Geopend: ' + n.t; return; }
n.u = false; const k = it.dataset.k; render();
const again = list.querySelector('[data-k="' + k + '"]'); (again || list.querySelector('.ntc__item') || list.querySelector('button') || list).focus();
live.textContent = 'Gelezen: ' + n.t;
});
w.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) { e.preventDefault(); setOpen(false, true); } });
document.addEventListener('click', e => { if (!panel.hidden && !w.contains(e.target)) setOpen(false); }, true);
w.addEventListener('click', e => { const path = e.composedPath(); if (!panel.hidden && !path.includes(panel) && !path.includes(bell)) setOpen(false); });
render();
// Er komt na 6 seconden één nieuwe melding binnen (in productie via SSE of push).
setTimeout(() => {
if (added) return; added = true;
const n = { t: 'Nieuw bericht van uitvoerder Mark', s: 'De steigers staan, morgen begint het dak', d: new Date(), u: true };
N.push(n); render(n);
badge.classList.remove('is-bump'); void badge.offsetWidth; badge.classList.add('is-bump');
live.textContent = 'Nieuwe melding: ' + n.t;
}, 6000);
}
init(document.querySelector('.jouw-container'));
More components
Seen something you like?
Add it to your selection and send it over. We build your site around it, in your brand.
Get in touch →