09Notification badge menu with live read state
The figure pinned to the bell counts unread items, and reading one in the panel wipes its dot while the figure shrinks that very instant.
Best forNotification centers at the top of apps
steps(1, end)aria-livetranslateY

<main class="stage">
<section class="frame is-demo" id="nbf" aria-label="Club">
<div class="page">
<header class="bar">
<p class="brand">Club</p>
<button class="bell" id="nbf-bell" type="button" aria-label="Alerts" aria-controls="nbf-panel">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<span class="badge" id="nbf-badge" data-n="3" aria-hidden="true"><span class="n3">3</span><span class="n2">2</span><span class="n1">1</span></span>
</button>
</header>
<div class="body">
<p class="skeleton" aria-hidden="true"></p>
<p class="skeleton skeleton--short" aria-hidden="true"></p>
</div>
</div>
<aside class="panel" id="nbf-panel" aria-label="Alerts" aria-hidden="true">
<header class="phead">
<p class="ptitle">Alerts</p>
<button class="allbtn" id="nbf-all" type="button">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span>Mark all as read</span>
</button>
</header>
<ul class="rows">
<li><button class="row" type="button"><span class="dot" aria-hidden="true"></span><span class="rt">Your order started shipping</span><span class="rtime">just now</span></button></li>
<li><button class="row" type="button"><span class="dot" aria-hidden="true"></span><span class="rt">2 coupons expiring soon</span><span class="rtime">2 hours ago</span></button></li>
<li><button class="row" type="button"><span class="dot" aria-hidden="true"></span><span class="rt">New comment posted</span><span class="rtime">yesterday</span></button></li>
</ul>
</aside>
<p class="sr" id="nbf-live" aria-live="polite" data-fmt="{n} unread notifications">3 unread notifications</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
$ink: $subject-ink;
$cream: $subject-cream;
$line: rgba(255, 247, 230, .16);
$hover: rgba(23, 20, 26, .06);
// ── 프레임: 잉크 무대 위 어두운 카드 ────────────────
.frame {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(420px, 94vw);
height: min(260px, 84vh);
border: 1px solid $line;
border-radius: $r-card;
background: rgba(255, 247, 230, .04);
color: $cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.35;
}
.frame, .frame * { box-sizing: border-box; }
.page { display: flex; flex-direction: column; height: 100%; }
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-2 $sp-3;
}
.brand { margin: 0; font-size: 14px; font-weight: 700; letter-spacing: .02em; }
.bell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 1px solid $line;
border-radius: $r-control;
background: transparent;
color: $cream;
cursor: pointer;
}
.bell:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 배지 — 숫자는 겹친 span 을 컷으로 갈아끼운다 (transition 없음, 크로스페이드 금지)
.badge {
position: absolute;
top: -4px;
right: -4px;
width: 18px;
height: 18px;
border-radius: $r-pill;
background: $color;
color: $ink;
}
.badge span {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
line-height: 1;
opacity: 0;
}
.badge[data-n="3"] .n3,
.badge[data-n="2"] .n2,
.badge[data-n="1"] .n1 { opacity: 1; }
.frame.is-clear .badge { opacity: 0; transform: scale(0); }
// 본문 흉내 — 회색 막대
.body {
display: grid;
align-content: start;
gap: $sp-2;
padding: 0 $sp-3 $sp-3;
}
.skeleton {
height: 10px;
margin: 0;
border-radius: $r-pill;
background: rgba(255, 247, 230, .10);
}
.skeleton--short { width: 62%; }
// ── 알림 패널 — 종 아래 오른쪽 정렬 ─────────────────
.panel {
position: absolute; // 프레임 안에 완전히 들어간다
top: 52px;
right: $sp-3;
width: min(240px, 84vw);
padding: $sp-2;
border-radius: $r-card;
background: $cream;
color: $ink;
box-shadow: $shadow-raised;
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity $dur-quick $easing, transform $dur-quick $easing;
}
.frame.is-open .panel { opacity: 1; transform: translateY(0); pointer-events: auto; }
.phead {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 $sp-1;
}
.ptitle { margin: 0; font-size: 12px; font-weight: 700; line-height: 1.3; }
.allbtn {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: rgba(23, 20, 26, .55);
font-family: inherit;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
text-decoration: underline;
cursor: pointer;
}
.allbtn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rows { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.row {
display: grid;
grid-template-columns: 8px 1fr;
column-gap: $sp-2;
align-items: start;
width: 100%;
margin: 0;
padding: $sp-1;
border: 0;
border-radius: $r-control;
background: transparent;
color: inherit;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.row:hover { background: $hover; }
.row:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.dot {
grid-column: 1;
grid-row: 1;
width: 8px;
height: 8px;
margin-top: 4px;
border-radius: 50%;
background: $color;
}
.rt { grid-column: 2; grid-row: 1; font-size: 12px; font-weight: 600; line-height: 1.3; }
.rtime { grid-column: 2; grid-row: 2; font-size: 10px; line-height: 1.3; color: rgba(23, 20, 26, .5); }
// 읽음 — 점 소멸과 줄 어둡게짐은 컷으로 바뀐다 (transition 없음)
.row.is-read { opacity: .5; }
.row.is-read .dot { opacity: 0; transform: scale(0); }
// 화면 밖 스크린리더 영역 — 배지 수를 소리로 알린다
.sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 0-8% 닫힘 → 10% 패널 등장 → 34/48/62% 줄 하나씩 읽음(점 컷 소멸 + 배지 3→2→1→소멸) →
// 78% 유지 → 80-88% 짧은 닫힘. 읽음 처리 컷이 주인공이라 닫힘은 8% 안에 끝낸다.
@keyframes nbf-press {
0%, 4% { transform: translateY(0); }
6% { transform: translateY(2px); }
8%, 100% { transform: translateY(0); }
}
@keyframes nbf-panel-cut {
0%, 8.3% { opacity: 0; }
8.4%, 100% { opacity: 1; }
}
@keyframes nbf-panel-rise {
0%, 8.3% { transform: translateY(0); clip-path: inset(0 0 100% 0); }
8.4%, 84% { transform: translateY(0); clip-path: inset(0 0 0 0); }
90%, 100% { transform: translateY(0); clip-path: inset(100% 0 0 0); }
}
@keyframes nbf-dim1 { 0%, 34% { opacity: 1; } 34.1%, 100% { opacity: .5; } }
@keyframes nbf-dim2 { 0%, 48% { opacity: 1; } 48.1%, 100% { opacity: .5; } }
@keyframes nbf-dim3 { 0%, 62% { opacity: 1; } 62.1%, 100% { opacity: .5; } }
@keyframes nbf-dot1 { 0%, 34% { opacity: 1; transform: scale(1); } 34.1%, 100% { opacity: 0; transform: scale(0); } }
@keyframes nbf-dot2 { 0%, 48% { opacity: 1; transform: scale(1); } 48.1%, 100% { opacity: 0; transform: scale(0); } }
@keyframes nbf-dot3 { 0%, 62% { opacity: 1; transform: scale(1); } 62.1%, 100% { opacity: 0; transform: scale(0); } }
@keyframes nbf-n3 { 0%, 34% { opacity: 1; } 34.1%, 100% { opacity: 0; } }
@keyframes nbf-n2 { 0%, 34% { opacity: 0; } 34.1%, 48% { opacity: 1; } 48.1%, 100% { opacity: 0; } }
@keyframes nbf-n1 { 0%, 48% { opacity: 0; } 48.1%, 62% { opacity: 1; } 62.1%, 100% { opacity: 0; } }
@keyframes nbf-badge {
0%, 62% { opacity: 1; transform: scale(1); }
62.1%, 100% { opacity: 0; transform: scale(0); }
}
.frame.is-demo {
.bell {
animation-name: nbf-press;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.panel {
animation-name: nbf-panel-cut, nbf-panel-rise;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.badge {
animation-name: nbf-badge;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.badge .n3 { animation-name: nbf-n3; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.badge .n2 { animation-name: nbf-n2; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.badge .n1 { animation-name: nbf-n1; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(1) .row { animation-name: nbf-dim1; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(1) .dot { animation-name: nbf-dot1; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(2) .row { animation-name: nbf-dim2; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(2) .dot { animation-name: nbf-dot2; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(3) .row { animation-name: nbf-dim3; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(3) .dot { animation-name: nbf-dot3; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
}
@media (max-width: 340px) {
.bar { padding: $sp-1 $sp-2; }
.brand { font-size: 13px; }
.panel { top: 44px; width: min(216px, 84vw); padding: $sp-1; }
.phead { margin-bottom: 0; }
.ptitle { font-size: 11px; }
.row { padding: 0; column-gap: $sp-1; }
.rt { font-size: 11px; line-height: 1.25; }
.rtime { font-size: 10px; line-height: 1.2; }
.body { gap: $sp-1; padding: 0 $sp-2 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .bell,
.frame.is-demo .panel,
.frame.is-demo .badge,
.frame.is-demo .badge .n3,
.frame.is-demo .badge .n2,
.frame.is-demo .badge .n1,
.frame.is-demo .rows li .row,
.frame.is-demo .rows li .dot { animation: none !important; }
// 움직임만 끄고 「열린 패널 + 안 읽은 알림 3개」 결과는 남긴다
.frame.is-demo .panel { opacity: 1 !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.frame {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(420px, 94vw);
height: min(260px, 84vh);
border: 1px solid rgba(255, 247, 230, 0.16);
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.35;
}
.frame, .frame * {
box-sizing: border-box;
}
.page {
display: flex;
flex-direction: column;
height: 100%;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
}
.brand {
margin: 0;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
}
.bell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 1px solid rgba(255, 247, 230, 0.16);
border-radius: 12px;
background: transparent;
color: #fff7e6;
cursor: pointer;
}
.bell:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
width: 18px;
height: 18px;
border-radius: 999px;
background: #ffd23f;
color: #17141a;
}
.badge span {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
line-height: 1;
opacity: 0;
}
.badge[data-n="3"] .n3,
.badge[data-n="2"] .n2,
.badge[data-n="1"] .n1 {
opacity: 1;
}
.frame.is-clear .badge {
opacity: 0;
transform: scale(0);
}
.body {
display: grid;
align-content: start;
gap: 8px;
padding: 0 12px 12px;
}
.skeleton {
height: 10px;
margin: 0;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
}
.skeleton--short {
width: 62%;
}
.panel {
position: absolute;
top: 52px;
right: 12px;
width: min(240px, 84vw);
padding: 8px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.frame.is-open .panel {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.phead {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 4px;
}
.ptitle {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.allbtn {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: rgba(23, 20, 26, 0.55);
font-family: inherit;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
text-decoration: underline;
cursor: pointer;
}
.allbtn:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.rows {
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: grid;
grid-template-columns: 8px 1fr;
column-gap: 8px;
align-items: start;
width: 100%;
margin: 0;
padding: 4px;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.row:hover {
background: rgba(23, 20, 26, 0.06);
}
.row:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.dot {
grid-column: 1;
grid-row: 1;
width: 8px;
height: 8px;
margin-top: 4px;
border-radius: 50%;
background: #ffd23f;
}
.rt {
grid-column: 2;
grid-row: 1;
font-size: 12px;
font-weight: 600;
line-height: 1.3;
}
.rtime {
grid-column: 2;
grid-row: 2;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.5);
}
.row.is-read {
opacity: 0.5;
}
.row.is-read .dot {
opacity: 0;
transform: scale(0);
}
.sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@keyframes nbf-press {
0%, 4% {
transform: translateY(0);
}
6% {
transform: translateY(2px);
}
8%, 100% {
transform: translateY(0);
}
}
@keyframes nbf-panel-cut {
0%, 8.3% {
opacity: 0;
}
8.4%, 100% {
opacity: 1;
}
}
@keyframes nbf-panel-rise {
0%, 8.3% {
transform: translateY(0);
clip-path: inset(0 0 100% 0);
}
8.4%, 84% {
transform: translateY(0);
clip-path: inset(0 0 0 0);
}
90%, 100% {
transform: translateY(0);
clip-path: inset(100% 0 0 0);
}
}
@keyframes nbf-dim1 {
0%, 34% {
opacity: 1;
}
34.1%, 100% {
opacity: 0.5;
}
}
@keyframes nbf-dim2 {
0%, 48% {
opacity: 1;
}
48.1%, 100% {
opacity: 0.5;
}
}
@keyframes nbf-dim3 {
0%, 62% {
opacity: 1;
}
62.1%, 100% {
opacity: 0.5;
}
}
@keyframes nbf-dot1 {
0%, 34% {
opacity: 1;
transform: scale(1);
}
34.1%, 100% {
opacity: 0;
transform: scale(0);
}
}
@keyframes nbf-dot2 {
0%, 48% {
opacity: 1;
transform: scale(1);
}
48.1%, 100% {
opacity: 0;
transform: scale(0);
}
}
@keyframes nbf-dot3 {
0%, 62% {
opacity: 1;
transform: scale(1);
}
62.1%, 100% {
opacity: 0;
transform: scale(0);
}
}
@keyframes nbf-n3 {
0%, 34% {
opacity: 1;
}
34.1%, 100% {
opacity: 0;
}
}
@keyframes nbf-n2 {
0%, 34% {
opacity: 0;
}
34.1%, 48% {
opacity: 1;
}
48.1%, 100% {
opacity: 0;
}
}
@keyframes nbf-n1 {
0%, 48% {
opacity: 0;
}
48.1%, 62% {
opacity: 1;
}
62.1%, 100% {
opacity: 0;
}
}
@keyframes nbf-badge {
0%, 62% {
opacity: 1;
transform: scale(1);
}
62.1%, 100% {
opacity: 0;
transform: scale(0);
}
}
.frame.is-demo .bell {
animation-name: nbf-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.frame.is-demo .panel {
animation-name: nbf-panel-cut, nbf-panel-rise;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.frame.is-demo .badge {
animation-name: nbf-badge;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .badge .n3 {
animation-name: nbf-n3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .badge .n2 {
animation-name: nbf-n2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .badge .n1 {
animation-name: nbf-n1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(1) .row {
animation-name: nbf-dim1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(1) .dot {
animation-name: nbf-dot1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(2) .row {
animation-name: nbf-dim2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(2) .dot {
animation-name: nbf-dot2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(3) .row {
animation-name: nbf-dim3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(3) .dot {
animation-name: nbf-dot3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.bar {
padding: 4px 8px;
}
.brand {
font-size: 13px;
}
.panel {
top: 44px;
width: min(216px, 84vw);
padding: 4px;
}
.phead {
margin-bottom: 0;
}
.ptitle {
font-size: 11px;
}
.row {
padding: 0;
column-gap: 4px;
}
.rt {
font-size: 11px;
line-height: 1.25;
}
.rtime {
font-size: 10px;
line-height: 1.2;
}
.body {
gap: 4px;
padding: 0 8px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .bell,
.frame.is-demo .panel,
.frame.is-demo .badge,
.frame.is-demo .badge .n3,
.frame.is-demo .badge .n2,
.frame.is-demo .badge .n1,
.frame.is-demo .rows li .row,
.frame.is-demo .rows li .dot {
animation: none !important;
}
.frame.is-demo .panel {
opacity: 1 !important;
transform: none !important;
}
}// 실제 동작: 줄을 누르면 그 줄만 읽음이 되고 배지 숫자가 줄어든다. 화면 밖 aria-live 영역이 수를 갱신한다.
var root = document.getElementById('nbf');
var bell = document.getElementById('nbf-bell');
var panel = document.getElementById('nbf-panel');
var live = document.getElementById('nbf-live');
var badge = document.getElementById('nbf-badge');
var allBtn = document.getElementById('nbf-all');
var rows = Array.prototype.slice.call(root.querySelectorAll('.row'));
bell.setAttribute('aria-haspopup', 'true');
bell.setAttribute('aria-expanded', 'false');
panel.inert = true; // 닫힌 동안 패널 안 버튼은 탭 순서에서 빠진다
function unread() {
return rows.filter(function (r) { return !r.classList.contains('is-read'); });
}
function sync() {
root.classList.remove('is-demo'); // 첫 조작에서 격자 루프를 끈다
var n = unread().length;
root.classList.toggle('is-clear', n === 0);
badge.setAttribute('data-n', String(n));
live.textContent = live.dataset.fmt.replace('{n}', String(n)); // 문구는 data-fmt 템플릿에서 — JS 에 하드코딩하면 언어팩을 못 탄다
}
function setOpen(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-open', on);
bell.setAttribute('aria-expanded', on ? 'true' : 'false');
panel.setAttribute('aria-hidden', on ? 'false' : 'true');
panel.inert = !on;
var hit = unread();
(on ? (hit.length ? hit[0] : rows[0]) : bell).focus({ preventScroll: true });
}
bell.addEventListener('click', function () { setOpen(!root.classList.contains('is-open')); });
allBtn.addEventListener('click', function () {
rows.forEach(function (r) { r.classList.add('is-read'); });
sync();
});
rows.forEach(function (r) {
r.addEventListener('click', function () { r.classList.add('is-read'); sync(); });
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root.classList.contains('is-open')) setOpen(false);
});import { useState, useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./NotificationBell.module.scss";
export type NotificationBellLabels = {
brand: string;
bellLabel: string;
panelTitle: string;
markAll: string;
unreadText: string;
items: Array<{ title: string; time: string }>;
};
export type NotificationBellProps = {
labels?: Partial<NotificationBellLabels>;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 이벤트 제네릭을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type KeyEvt = { key: string; preventDefault(): void };
type Focusable = { focus: (o: { preventScroll: boolean }) => void };
type PanelEl = { inert?: boolean };
type Box<T> = { current: T | null };
const defaults: NotificationBellLabels = {
brand: "Club",
bellLabel: "Notifications",
panelTitle: "Notifications",
markAll: "Mark all as read",
unreadText: "{n} unread",
items: [
{ title: "Your order started shipping", time: "just now" },
{ title: "2 coupons expiring soon", time: "2 hours ago" },
{ title: "New comment posted", time: "yesterday" },
],
};
function BellIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
}
export default function NotificationBell({
labels,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ffd23f",
}: NotificationBellProps) {
const L = { ...defaults, ...labels };
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const [read, setRead] = useState<boolean[]>(L.items.map(() => false));
const bellRef = useRef<Focusable | null>(null);
const panelRef = useRef<PanelEl | null>(null);
const rowRefs = useRef<Array<Focusable | null>>([]);
// 닫힌 동안 패널 안 버튼은 탭 순서에서 빠진다 (inert)
useEffect(() => { if (panelRef.current) panelRef.current.inert = !open; }, [open]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const unread = read.filter((r) => !r).length;
function firstUnread() {
const rows = rowRefs.current; // Box 배열 ref null 가드
if (!rows) return undefined;
for (let j = 0; j < rows.length; j++) {
if (!read[j] && rows[j]) return rows[j];
}
return rows[0];
}
function setBell(on: boolean) {
setLive(false);
setOpen(on);
(on ? firstUnread() : bellRef.current)?.focus({ preventScroll: true });
}
function markRead(i: number) {
setLive(false);
setRead(read.map((r, j) => (j === i ? true : r)));
}
function markAll() {
setLive(false);
setRead(L.items.map(() => true));
}
function panelKeys(e: KeyEvt) {
if (e.key === "Escape") setBell(false);
}
const cls = [styles.frame, live ? styles.isDemo : "", open ? styles.isOpen : "", unread === 0 ? styles.isClear : ""]
.filter(Boolean)
.join(" ");
return (
<section className={cls} style={vars} aria-label={L.brand}>
<div className={styles.page}>
<header className={styles.bar}>
<p className={styles.brand}>{L.brand}</p>
<button
className={styles.bell}
type="button"
aria-label={L.bellLabel}
aria-controls="nbf-panel"
aria-haspopup="true"
aria-expanded={open}
onClick={() => setBell(!open)}
ref={(el: Focusable | null) => { bellRef.current = el; }}
>
<BellIcon />
<span className={styles.badge} data-n={String(unread)} aria-hidden="true">
<span className={styles.n3}>3</span>
<span className={styles.n2}>2</span>
<span className={styles.n1}>1</span>
</span>
</button>
</header>
<div className={styles.body}>
<p className={styles.skeleton} aria-hidden="true" />
<p className={styles.skeletonShort} aria-hidden="true" />
</div>
</div>
<aside
className={styles.panel}
id="nbf-panel"
aria-label={L.panelTitle}
aria-hidden={!open}
onKeyDown={panelKeys}
ref={(el: PanelEl | null) => { panelRef.current = el; }}
>
<header className={styles.phead}>
<p className={styles.ptitle}>{L.panelTitle}</p>
<button className={styles.allbtn} type="button" onClick={markAll}>
<CheckIcon />
<span>{L.markAll}</span>
</button>
</header>
<ul className={styles.rows}>
{L.items.map((it, i) => (
<li key={it.title}>
<button
type="button"
className={[styles.row, read[i] ? styles.isRead : ""].filter(Boolean).join(" ")}
onClick={() => markRead(i)}
ref={(el: Focusable | null) => { if (rowRefs.current) rowRefs.current[i] = el; }}
>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.rt}>{it.title}</span>
<span className={styles.rtime}>{it.time}</span>
</button>
</li>
))}
</ul>
</aside>
<p className={styles.sr} aria-live="polite">{L.unreadText.replace("{n}", String(unread))}</p>
</section>
);
}09 Notification badge menu with live read state — Measured area 24.846% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · 9 mega menu ui patterns, link list to site map
The post this demo lives in
9 mega menu ui patterns, link list to site mapA mega menu ui opens a screen-wide panel from one top-row item, turning the site into one readable map. The nine running See all demos in this post
If you like this effect - similar demos
Transitions03 Badge count-bump toggle — each time a notification count value rises, that display's size value grows once then settles backCSS Bottom Navigation Bar: 9 Transition Patterns
Transitions08 Mobile bottom toast9 CSS Toast Notifications — No Library, Pure CSS
Transitions07 Vertical tabs9 CSS Tab Switch Animations — No Library, Pure CSS