Meldingencentrum Nieuw
Bel met teller voor ongelezen meldingen en een paneel met meldingen per dag (vandaag, gisteren, datum), tabs Ongelezen en Alles, alles gelezen markeren, een melding lezen door erop te klikken, een lege staat met één uitweg en een nieuwe melding die na een paar seconden binnenkomt en wordt voorgelezen via aria-live. Esc of klik buiten sluit en zet de focus terug op de bel. Koppel in productie aan Server-Sent Events of web push.
Fase 3 van de aanbouw: kozijnen plaatsen, gepland in week 43.
Meldingen
Zo gebruik je het
- Plak de HTML op de plek waar het onderdeel moet komen.
- Zet de CSS in je stylesheet. Alle klassen hebben een eigen voorvoegsel, dus niets botst met je eigen opmaak.
- Voeg de JavaScript toe en roep init() aan met het element waar de HTML in staat.
<!-- 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'));
Meer uit componenten
Iets gezien dat je mooi vindt?
Zet het in je selectie en stuur hem op. Dan bouwen we je site eromheen, in jouw huisstijl.
Neem contact op →