UI Components Medium
Notification Bell
Notification bell icon with unread badge, dropdown notification feed, mark-as-read, mark-all-read, and grouping by Today/Earlier. No dependencies.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
--accent: #818cf8;
--red: #f87171;
--green: #34d399;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* Topbar */
.topbar {
height: 60px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.topbar-logo {
font-size: 1rem;
font-weight: 700;
color: var(--accent);
}
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.topbar-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), #a5b4fc);
display: grid;
place-items: center;
font-size: 0.7rem;
font-weight: 700;
color: #fff;
cursor: pointer;
}
/* Bell */
.notif-wrap {
position: relative;
}
.bell-btn {
width: 38px;
height: 38px;
border-radius: 9px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text-muted);
cursor: pointer;
display: grid;
place-items: center;
position: relative;
transition: background .15s, color .15s;
}
.bell-btn:hover {
color: var(--text);
background: var(--border);
}
.bell-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 18px;
height: 18px;
background: var(--red);
border-radius: 999px;
font-size: 0.62rem;
font-weight: 700;
color: #fff;
display: grid;
place-items: center;
padding: 0 4px;
animation: badgePop .3s ease;
border: 2px solid var(--bg);
}
.bell-badge:empty,
.bell-badge[data-count="0"] {
display: none;
}
@keyframes badgePop {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
/* Panel */
.notif-panel {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 340px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.5);
z-index: 200;
overflow: hidden;
animation: panelIn .15s ease;
}
@keyframes panelIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: none;
}
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.notif-title {
font-size: 0.9rem;
font-weight: 700;
}
.mark-all-btn {
background: none;
border: none;
color: var(--accent);
font-size: 0.78rem;
cursor: pointer;
font-family: inherit;
transition: opacity .15s;
}
.mark-all-btn:hover {
opacity: 0.8;
}
.notif-list {
max-height: 320px;
overflow-y: auto;
}
/* Item */
.notif-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
transition: background .15s;
cursor: pointer;
position: relative;
}
.notif-item:hover {
background: var(--surface2);
}
.notif-item.unread {
background: rgba(129, 140, 248, 0.04);
}
.notif-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
margin-top: 5px;
transition: opacity .2s;
}
.notif-item:not(.unread) .notif-dot {
opacity: 0;
}
.notif-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.75rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.notif-body {
flex: 1;
min-width: 0;
}
.notif-text {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
}
.notif-text strong {
font-weight: 600;
}
.notif-time {
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 3px;
}
.notif-del {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 2px 4px;
border-radius: 4px;
opacity: 0;
transition: opacity .15s, color .15s;
}
.notif-item:hover .notif-del {
opacity: 1;
}
.notif-del:hover {
color: var(--red);
}
/* Group label */
.notif-group-label {
padding: 8px 16px 4px;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-muted);
}
/* Empty */
.notif-empty {
padding: 32px;
text-align: center;
}
.notif-empty-icon {
font-size: 2rem;
margin-bottom: 10px;
}
.notif-empty p {
font-size: 0.82rem;
color: var(--text-muted);
}
/* Footer */
.notif-footer {
padding: 10px 16px;
border-top: 1px solid var(--border);
text-align: center;
}
.notif-footer-link {
font-size: 0.78rem;
color: var(--accent);
text-decoration: none;
}
/* Page */
.page-body {
display: grid;
place-items: center;
height: calc(100vh - 60px);
}
.hint {
color: var(--text-muted);
font-size: 0.875rem;
}let notifications = [
{
id: 1,
avatar: "SK",
color: "#0ea5e9",
text: "<strong>Sarah Kim</strong> commented on your resource",
time: "2 min ago",
read: false,
group: "Today",
},
{
id: 2,
avatar: "⚡",
color: "#818cf8",
text: "New component <strong>Sidebar Admin</strong> released",
time: "1 hour ago",
read: false,
group: "Today",
},
{
id: 3,
avatar: "MR",
color: "#8b5cf6",
text: "<strong>Marcus Reed</strong> starred your snippet",
time: "3 hours ago",
read: false,
group: "Today",
},
{
id: 4,
avatar: "🚀",
color: "#f59e0b",
text: "Phase 8 components are now live in the library",
time: "Yesterday",
read: true,
group: "Earlier",
},
{
id: 5,
avatar: "JL",
color: "#34d399",
text: "<strong>Julia Lee</strong> started following you",
time: "2 days ago",
read: true,
group: "Earlier",
},
];
let removedItem = null,
undoTimer = null;
const bellBtn = document.getElementById("bellBtn");
const bellBadge = document.getElementById("bellBadge");
const panel = document.getElementById("notifPanel");
const list = document.getElementById("notifList");
const empty = document.getElementById("notifEmpty");
const markAll = document.getElementById("markAllBtn");
function unreadCount() {
return notifications.filter((n) => !n.read).length;
}
function render() {
list.innerHTML = "";
const groups = ["Today", "Earlier"];
let anyVisible = false;
groups.forEach((group) => {
const items = notifications.filter((n) => n.group === group);
if (!items.length) return;
anyVisible = true;
const label = document.createElement("div");
label.className = "notif-group-label";
label.textContent = group;
list.appendChild(label);
items.forEach((n) => {
const el = document.createElement("div");
el.className = "notif-item" + (n.read ? "" : " unread");
el.dataset.id = n.id;
el.innerHTML = `
<div class="notif-dot"></div>
<div class="notif-avatar" style="background:${n.color}">${n.avatar}</div>
<div class="notif-body">
<div class="notif-text">${n.text}</div>
<div class="notif-time">${n.time}</div>
</div>
<button class="notif-del" aria-label="Remove notification">×</button>
`;
// Mark read on click
el.addEventListener("click", (e) => {
if (e.target.classList.contains("notif-del")) return;
n.read = true;
render();
});
// Delete
el.querySelector(".notif-del").addEventListener("click", (e) => {
e.stopPropagation();
removeNotif(n.id);
});
list.appendChild(el);
});
});
const count = unreadCount();
bellBadge.textContent = count || "";
empty.hidden = anyVisible;
list.hidden = !anyVisible;
}
function removeNotif(id) {
const idx = notifications.findIndex((n) => n.id === id);
if (idx === -1) return;
removedItem = { ...notifications[idx], idx };
notifications.splice(idx, 1);
render();
// Undo toast
clearTimeout(undoTimer);
const toast = document.getElementById("undoToast");
toast.hidden = false;
undoTimer = setTimeout(() => {
toast.hidden = true;
removedItem = null;
}, 3500);
}
document.getElementById("undoBtn")?.addEventListener("click", () => {
if (!removedItem) return;
notifications.splice(removedItem.idx, 0, { ...removedItem });
removedItem = null;
clearTimeout(undoTimer);
document.getElementById("undoToast").hidden = true;
render();
});
// Mark all
markAll?.addEventListener("click", () => {
notifications.forEach((n) => (n.read = true));
render();
});
// Toggle panel
bellBtn?.addEventListener("click", (e) => {
e.stopPropagation();
const open = panel.hidden;
panel.hidden = !open;
bellBtn.setAttribute("aria-expanded", String(open));
});
// Close outside
document.addEventListener("click", (e) => {
if (!document.getElementById("notifWrap").contains(e.target)) {
panel.hidden = true;
bellBtn.setAttribute("aria-expanded", "false");
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
panel.hidden = true;
bellBtn.setAttribute("aria-expanded", "false");
}
});
render();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notification Bell</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<span class="topbar-logo">✦ StealThis</span>
<div class="topbar-right">
<!-- Notification Bell -->
<div class="notif-wrap" id="notifWrap">
<button class="bell-btn" id="bellBtn" aria-label="Notifications" aria-haspopup="true"
aria-expanded="false">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
<span class="bell-badge" id="bellBadge" aria-live="polite">3</span>
</button>
<!-- Dropdown -->
<div class="notif-panel" id="notifPanel" role="dialog" aria-label="Notifications" hidden>
<div class="notif-header">
<h2 class="notif-title">Notifications</h2>
<button class="mark-all-btn" id="markAllBtn">Mark all as read</button>
</div>
<div class="notif-list" id="notifList">
<!-- Rendered by JS -->
</div>
<div class="notif-empty" id="notifEmpty" hidden>
<div class="notif-empty-icon">🔔</div>
<p>All caught up! No new notifications.</p>
</div>
<div class="notif-footer">
<a href="#" class="notif-footer-link">View all notifications →</a>
</div>
</div>
</div>
<div class="topbar-avatar">AC</div>
</div>
</header>
<main class="page-body">
<p class="hint">Click the bell icon above to open notifications.</p>
</main>
<script src="script.js"></script>
</body>
</html>Notification Bell
A notification bell button with an animated unread badge counter, dropdown feed grouped by Today/Earlier, individual mark-as-read on click, mark-all-read action, and delete per item.
Features
- Bell icon with animated red badge showing unread count
- Dropdown notification feed with max-height scroll
- Items grouped into “Today” and “Earlier” sections
- Unread items have a blue dot and distinct background
- Click an item → marks as read (dot disappears, badge decrements)
- “Mark all as read” button clears all unread state
- Delete (×) button removes items from the list
- Empty state when all notifications are cleared
- Closes on outside click or
Escape
How it works
- Notification data is a JS array; unread count is derived by filtering
.read === false renderNotifications()rebuilds the feed DOM from state on each state change- Bell badge animates with a CSS
scale+bouncekeyframe on new notifications