03既読と未読が分かれる通知リスト
未読の行は点が scale で脈打ち背景が明るいまま、開くと steps(1, end) のカットで既読の色に変わり、「すべて既読」を押すと残り数が aria-live で 0 と読み上げられます。
使いどころ右上のベルから開く通知パネル、管理画面のお知らせ一覧
scalesteps(1, end)aria-live

<main class="stage">
<section class="nc is-demo" id="nc" aria-label="通知リスト">
<header class="nc__h">
<span class="nc__bellw">
<span class="nc__bell" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><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>
<span class="nc__badge" id="nc-badge" data-t="未読{n}件" aria-live="polite">未読3件</span>
</span>
<button class="nc__all" type="button" id="nc-all">
<span class="nc__ck" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><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>
すべて既読
</button>
</header>
<ul class="nc__list" id="nc-list">
<li class="nc__row" data-row="1">
<button class="nc__it" type="button">
<span class="nc__dot" aria-hidden="true"></span>
<span class="nc__msg">週次報告が承認されました</span>
<span class="nc__meta"><span class="nc__read" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><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>5分前</span>
</button>
</li>
<li class="nc__row" data-row="2">
<button class="nc__it" type="button">
<span class="nc__dot" aria-hidden="true"></span>
<span class="nc__msg">イ・ジュンさんがコメントを残しました</span>
<span class="nc__meta"><span class="nc__read" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><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>20分前</span>
</button>
</li>
<li class="nc__row" data-row="3">
<button class="nc__it" type="button">
<span class="nc__dot" aria-hidden="true"></span>
<span class="nc__msg">発注書ファイルが届きました</span>
<span class="nc__meta"><span class="nc__read" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><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>1時間前</span>
</button>
</li>
<li class="nc__row is-read nc__row--old" data-row="4">
<button class="nc__it" type="button">
<span class="nc__dot" aria-hidden="true"></span>
<span class="nc__msg">在庫リストが更新されました</span>
<span class="nc__meta"><span class="nc__read" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><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>昨日</span>
</button>
</li>
</ul>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
$ink: $subject-ink;
$soft: color.mix($subject-cream, $stage-yellow, 78%);
.nc {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $ink;
border-radius: $r-card;
background: $stage-yellow;
color: $ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.nc__h {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0 0 $sp-2;
}
.nc__bellw { display: inline-flex; align-items: center; gap: $sp-1; }
.nc__bell { display: inline-flex; width: 15px; height: 15px; color: $ink; }
.nc__badge {
padding: 0 $sp-2;
border-radius: $r-pill;
background: $soft;
color: $ink;
font-size: 10px;
font-weight: 700;
line-height: 1.7;
white-space: nowrap;
}
.nc__all {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 0 0 auto;
padding: 0 $sp-2;
border: 0;
border-radius: $r-pill;
background: $ink;
color: $stage-yellow;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.nc__all:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.nc__all:active { transform: scale(.94); }
.nc__ck { display: inline-flex; width: 11px; height: 11px; }
.nc__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
// ── 알림 한 줄 ────────────────────────────────────
.nc__it {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-2;
width: 100%;
margin: 0;
padding: $sp-1 $sp-2;
border: 1px solid rgba(23, 20, 26, .18);
border-radius: $r-sm;
background: #ffffff; // 안 읽음 = 흰 카드 (노랑 무대 위에서 밝게)
color: $ink;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.nc__it:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.nc__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: $color;
}
.nc__msg { font-weight: 700; }
.nc__meta {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgba(23, 20, 26, .72);
font-size: 10px;
white-space: nowrap;
}
.nc__read { display: none; color: $subject-mint; width: 11px; height: 11px; }
// 읽음 상태 — 사람이 클릭한 정적 경로
.nc__row.is-read .nc__it {
border-color: transparent;
background: rgba(23, 20, 26, .08);
}
.nc__row.is-read .nc__dot { display: none; }
.nc__row.is-read .nc__msg { color: rgba(23, 20, 26, .72); font-weight: 400; }
.nc__row.is-read .nc__read { display: inline-flex; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 읽음 전환은 steps 컷으로 한 프레임에 자르고, 프레임 수는 체크 슬라이드와 점 맥박이 채운다
@keyframes nc-read-1 {
0%, 14% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
15%, 87% { border-color: transparent; background: rgba(23, 20, 26, .08); }
88%, 100% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
}
@keyframes nc-read-2 {
0%, 42% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
43%, 87% { border-color: transparent; background: rgba(23, 20, 26, .08); }
88%, 100% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
}
@keyframes nc-read-3 {
0%, 69% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
70%, 87% { border-color: transparent; background: rgba(23, 20, 26, .08); }
88%, 100% { border-color: rgba(23, 20, 26, .18); background: #ffffff; }
}
@keyframes nc-dot-cut-1 { 0%, 14% { opacity: 1; } 15%, 100% { opacity: 0; } }
@keyframes nc-dot-cut-2 { 0%, 42% { opacity: 1; } 43%, 100% { opacity: 0; } }
@keyframes nc-dot-cut-3 { 0%, 69% { opacity: 1; } 70%, 100% { opacity: 0; } }
@keyframes nc-dot-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.5); }
}
@keyframes nc-msg-dim-1 { 0%, 14% { color: $ink; } 15%, 87% { color: rgba(23, 20, 26, .72); } 88%, 100% { color: $ink; } }
@keyframes nc-msg-dim-2 { 0%, 42% { color: $ink; } 43%, 87% { color: rgba(23, 20, 26, .72); } 88%, 100% { color: $ink; } }
@keyframes nc-msg-dim-3 { 0%, 69% { color: $ink; } 70%, 87% { color: rgba(23, 20, 26, .72); } 88%, 100% { color: $ink; } }
@keyframes nc-chk-in-1 { 0%, 14% { visibility: hidden; } 15% { visibility: visible; transform: translateX(6px); } 21%, 87% { visibility: visible; transform: translateX(0); } 88%, 100% { visibility: hidden; transform: translateX(6px); } }
@keyframes nc-chk-in-2 { 0%, 42% { visibility: hidden; } 43% { visibility: visible; transform: translateX(6px); } 49%, 87% { visibility: visible; transform: translateX(0); } 88%, 100% { visibility: hidden; transform: translateX(6px); } }
@keyframes nc-chk-in-3 { 0%, 69% { visibility: hidden; } 70% { visibility: visible; transform: translateX(6px); } 76%, 87% { visibility: visible; transform: translateX(0); } 88%, 100% { visibility: hidden; transform: translateX(6px); } }
@keyframes nc-all-press {
0%, 68% { transform: scale(1); }
70% { transform: scale(.9); }
74%, 87% { transform: scale(1); }
88%, 100% { transform: scale(1); }
}
@keyframes nc-badge-shrink {
0%, 14% { transform: scale(1); }
15%, 42% { transform: scale(.92); }
43%, 69% { transform: scale(.84); }
70%, 87% { transform: scale(.76); }
88%, 100% { transform: scale(1); }
}
.nc.is-demo {
.nc__row[data-row="1"] .nc__it { animation-name: nc-read-1; }
.nc__row[data-row="2"] .nc__it { animation-name: nc-read-2; }
.nc__row[data-row="3"] .nc__it { animation-name: nc-read-3; }
.nc__it {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.nc__row[data-row="1"] .nc__dot { animation-name: nc-dot-cut-1, nc-dot-pulse; }
.nc__row[data-row="2"] .nc__dot { animation-name: nc-dot-cut-2, nc-dot-pulse; }
.nc__row[data-row="3"] .nc__dot { animation-name: nc-dot-cut-3, nc-dot-pulse; }
.nc__dot {
animation-duration: $dur-loop, 700ms;
animation-timing-function: steps(1, end), ease-in-out;
animation-iteration-count: infinite, infinite;
}
.nc__row[data-row="1"] .nc__msg { animation-name: nc-msg-dim-1; }
.nc__row[data-row="2"] .nc__msg { animation-name: nc-msg-dim-2; }
.nc__row[data-row="3"] .nc__msg { animation-name: nc-msg-dim-3; }
.nc__msg {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.nc__row[data-row="1"] .nc__read { animation-name: nc-chk-in-1; }
.nc__row[data-row="2"] .nc__read { animation-name: nc-chk-in-2; }
.nc__row[data-row="3"] .nc__read { animation-name: nc-chk-in-3; }
.nc__read {
display: inline-flex;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.nc__all {
animation-name: nc-all-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.nc__badge {
animation-name: nc-badge-shrink;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.nc { padding: $sp-2; font-size: 11px; }
.nc__h { margin-bottom: $sp-1; }
.nc__it { padding: 0 $sp-2; font-size: 11px; }
.nc__msg { font-size: 11px; }
.nc__row--old { display: none; } // 좁은 화면에서는 읽은 항목부터 비운다
.nc__badge { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.nc__all:active { transform: none; }
.nc.is-demo .nc__it,
.nc.is-demo .nc__dot,
.nc.is-demo .nc__msg,
.nc.is-demo .nc__read,
.nc.is-demo .nc__all,
.nc.is-demo .nc__badge { animation: none !important; transform: none !important; }
// 움직임만 끄고 판정 결과는 남긴다 — 세 줄 전부 읽은 상태
.nc.is-demo .nc__row[data-row="1"] .nc__it,
.nc.is-demo .nc__row[data-row="2"] .nc__it,
.nc.is-demo .nc__row[data-row="3"] .nc__it { background: rgba(23, 20, 26, .08) !important; border-color: transparent !important; }
.nc.is-demo .nc__row[data-row="1"] .nc__dot,
.nc.is-demo .nc__row[data-row="2"] .nc__dot,
.nc.is-demo .nc__row[data-row="3"] .nc__dot { opacity: 0 !important; }
.nc.is-demo .nc__row[data-row="1"] .nc__msg,
.nc.is-demo .nc__row[data-row="2"] .nc__msg,
.nc.is-demo .nc__row[data-row="3"] .nc__msg { color: rgba(23, 20, 26, .72) !important; }
.nc.is-demo .nc__row[data-row="1"] .nc__read,
.nc.is-demo .nc__row[data-row="2"] .nc__read,
.nc.is-demo .nc__row[data-row="3"] .nc__read { display: inline-flex !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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.nc {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid #17141a;
border-radius: 16px;
background: #ffd23f;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.nc__h {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
}
.nc__bellw {
display: inline-flex;
align-items: center;
gap: 4px;
}
.nc__bell {
display: inline-flex;
width: 15px;
height: 15px;
color: #17141a;
}
.nc__badge {
padding: 0 8px;
border-radius: 999px;
background: rgb(100%, 93.6705882353%, 75.7882352941%);
color: #17141a;
font-size: 10px;
font-weight: 700;
line-height: 1.7;
white-space: nowrap;
}
.nc__all {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 0 0 auto;
padding: 0 8px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #ffd23f;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.nc__all:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.nc__all:active {
transform: scale(0.94);
}
.nc__ck {
display: inline-flex;
width: 11px;
height: 11px;
}
.nc__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.nc__it {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
width: 100%;
margin: 0;
padding: 4px 8px;
border: 1px solid rgba(23, 20, 26, 0.18);
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.nc__it:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.nc__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #2f6df6;
}
.nc__msg {
font-weight: 700;
}
.nc__meta {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
white-space: nowrap;
}
.nc__read {
display: none;
color: #4cd4a6;
width: 11px;
height: 11px;
}
.nc__row.is-read .nc__it {
border-color: transparent;
background: rgba(23, 20, 26, 0.08);
}
.nc__row.is-read .nc__dot {
display: none;
}
.nc__row.is-read .nc__msg {
color: rgba(23, 20, 26, 0.72);
font-weight: 400;
}
.nc__row.is-read .nc__read {
display: inline-flex;
}
@keyframes nc-read-1 {
0%, 14% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
15%, 87% {
border-color: transparent;
background: rgba(23, 20, 26, 0.08);
}
88%, 100% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
}
@keyframes nc-read-2 {
0%, 42% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
43%, 87% {
border-color: transparent;
background: rgba(23, 20, 26, 0.08);
}
88%, 100% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
}
@keyframes nc-read-3 {
0%, 69% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
70%, 87% {
border-color: transparent;
background: rgba(23, 20, 26, 0.08);
}
88%, 100% {
border-color: rgba(23, 20, 26, 0.18);
background: #ffffff;
}
}
@keyframes nc-dot-cut-1 {
0%, 14% {
opacity: 1;
}
15%, 100% {
opacity: 0;
}
}
@keyframes nc-dot-cut-2 {
0%, 42% {
opacity: 1;
}
43%, 100% {
opacity: 0;
}
}
@keyframes nc-dot-cut-3 {
0%, 69% {
opacity: 1;
}
70%, 100% {
opacity: 0;
}
}
@keyframes nc-dot-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.5);
}
}
@keyframes nc-msg-dim-1 {
0%, 14% {
color: #17141a;
}
15%, 87% {
color: rgba(23, 20, 26, 0.72);
}
88%, 100% {
color: #17141a;
}
}
@keyframes nc-msg-dim-2 {
0%, 42% {
color: #17141a;
}
43%, 87% {
color: rgba(23, 20, 26, 0.72);
}
88%, 100% {
color: #17141a;
}
}
@keyframes nc-msg-dim-3 {
0%, 69% {
color: #17141a;
}
70%, 87% {
color: rgba(23, 20, 26, 0.72);
}
88%, 100% {
color: #17141a;
}
}
@keyframes nc-chk-in-1 {
0%, 14% {
visibility: hidden;
}
15% {
visibility: visible;
transform: translateX(6px);
}
21%, 87% {
visibility: visible;
transform: translateX(0);
}
88%, 100% {
visibility: hidden;
transform: translateX(6px);
}
}
@keyframes nc-chk-in-2 {
0%, 42% {
visibility: hidden;
}
43% {
visibility: visible;
transform: translateX(6px);
}
49%, 87% {
visibility: visible;
transform: translateX(0);
}
88%, 100% {
visibility: hidden;
transform: translateX(6px);
}
}
@keyframes nc-chk-in-3 {
0%, 69% {
visibility: hidden;
}
70% {
visibility: visible;
transform: translateX(6px);
}
76%, 87% {
visibility: visible;
transform: translateX(0);
}
88%, 100% {
visibility: hidden;
transform: translateX(6px);
}
}
@keyframes nc-all-press {
0%, 68% {
transform: scale(1);
}
70% {
transform: scale(0.9);
}
74%, 87% {
transform: scale(1);
}
88%, 100% {
transform: scale(1);
}
}
@keyframes nc-badge-shrink {
0%, 14% {
transform: scale(1);
}
15%, 42% {
transform: scale(0.92);
}
43%, 69% {
transform: scale(0.84);
}
70%, 87% {
transform: scale(0.76);
}
88%, 100% {
transform: scale(1);
}
}
.nc.is-demo .nc__row[data-row="1"] .nc__it {
animation-name: nc-read-1;
}
.nc.is-demo .nc__row[data-row="2"] .nc__it {
animation-name: nc-read-2;
}
.nc.is-demo .nc__row[data-row="3"] .nc__it {
animation-name: nc-read-3;
}
.nc.is-demo .nc__it {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.nc.is-demo .nc__row[data-row="1"] .nc__dot {
animation-name: nc-dot-cut-1, nc-dot-pulse;
}
.nc.is-demo .nc__row[data-row="2"] .nc__dot {
animation-name: nc-dot-cut-2, nc-dot-pulse;
}
.nc.is-demo .nc__row[data-row="3"] .nc__dot {
animation-name: nc-dot-cut-3, nc-dot-pulse;
}
.nc.is-demo .nc__dot {
animation-duration: 2s, 700ms;
animation-timing-function: steps(1, end), ease-in-out;
animation-iteration-count: infinite, infinite;
}
.nc.is-demo .nc__row[data-row="1"] .nc__msg {
animation-name: nc-msg-dim-1;
}
.nc.is-demo .nc__row[data-row="2"] .nc__msg {
animation-name: nc-msg-dim-2;
}
.nc.is-demo .nc__row[data-row="3"] .nc__msg {
animation-name: nc-msg-dim-3;
}
.nc.is-demo .nc__msg {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.nc.is-demo .nc__row[data-row="1"] .nc__read {
animation-name: nc-chk-in-1;
}
.nc.is-demo .nc__row[data-row="2"] .nc__read {
animation-name: nc-chk-in-2;
}
.nc.is-demo .nc__row[data-row="3"] .nc__read {
animation-name: nc-chk-in-3;
}
.nc.is-demo .nc__read {
display: inline-flex;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.nc.is-demo .nc__all {
animation-name: nc-all-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.nc.is-demo .nc__badge {
animation-name: nc-badge-shrink;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.nc {
padding: 8px;
font-size: 11px;
}
.nc__h {
margin-bottom: 4px;
}
.nc__it {
padding: 0 8px;
font-size: 11px;
}
.nc__msg {
font-size: 11px;
}
.nc__row--old {
display: none;
}
.nc__badge {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.nc__all:active {
transform: none;
}
.nc.is-demo .nc__it,
.nc.is-demo .nc__dot,
.nc.is-demo .nc__msg,
.nc.is-demo .nc__read,
.nc.is-demo .nc__all,
.nc.is-demo .nc__badge {
animation: none !important;
transform: none !important;
}
.nc.is-demo .nc__row[data-row="1"] .nc__it,
.nc.is-demo .nc__row[data-row="2"] .nc__it,
.nc.is-demo .nc__row[data-row="3"] .nc__it {
background: rgba(23, 20, 26, 0.08) !important;
border-color: transparent !important;
}
.nc.is-demo .nc__row[data-row="1"] .nc__dot,
.nc.is-demo .nc__row[data-row="2"] .nc__dot,
.nc.is-demo .nc__row[data-row="3"] .nc__dot {
opacity: 0 !important;
}
.nc.is-demo .nc__row[data-row="1"] .nc__msg,
.nc.is-demo .nc__row[data-row="2"] .nc__msg,
.nc.is-demo .nc__row[data-row="3"] .nc__msg {
color: rgba(23, 20, 26, 0.72) !important;
}
.nc.is-demo .nc__row[data-row="1"] .nc__read,
.nc.is-demo .nc__row[data-row="2"] .nc__read,
.nc.is-demo .nc__row[data-row="3"] .nc__read {
display: inline-flex !important;
transform: none !important;
}
}// 실제 동작: 항목을 열면 그 항목만 읽음이 되고, 모두 읽음은 남은 항목 전부를 읽음으로 바꾼다.
// 남은 수는 data-t 의 {n} 자리에 실제 숫자를 넣어 갱신한다.
var nc = document.getElementById('nc');
var ncBadge = document.getElementById('nc-badge');
var ncRows = [].slice.call(nc.querySelectorAll('.nc__row'));
function ncSync() {
var left = ncRows.filter(function (r) { return !r.classList.contains('is-read'); }).length;
ncBadge.textContent = ncBadge.getAttribute('data-t').replace('{n}', left);
}
ncRows.forEach(function (r) {
r.querySelector('.nc__it').addEventListener('click', function () {
nc.classList.remove('is-demo');
r.classList.add('is-read');
ncSync();
});
});
document.getElementById('nc-all').addEventListener('click', function () {
nc.classList.remove('is-demo');
ncRows.forEach(function (r) { r.classList.add('is-read'); });
ncSync();
});
ncSync();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./NotificationCenterList.module.scss";
export type NotificationCenterListProps = {
groupLabel?: string;
markAllLabel?: string;
unreadMsg?: (n: number) => string;
items?: { key: string; msg: string; tm: string; read?: boolean }[];
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function BellIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<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="1em" height="1em" fill="currentColor">
<g>
<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>
);
}
const DEFAULT_ITEMS = [
{ key: "n1", msg: "The weekly report was approved", tm: "5m ago" },
{ key: "n2", msg: "Lee Jun left a comment", tm: "20m ago" },
{ key: "n3", msg: "A purchase order file arrived", tm: "1h ago" },
{ key: "n4", msg: "The stock list was refreshed", tm: "yesterday", read: true },
];
export default function NotificationCenterList({
groupLabel = "Notification list",
markAllLabel = "Mark all read",
unreadMsg = (n) => `${n} unread`,
items = DEFAULT_ITEMS,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: NotificationCenterListProps) {
const [live, setLive] = useState(demo);
const [read, setRead] = useState<Record<string, boolean>>(
Object.fromEntries(items.map((x) => [x.key, !!x.read]))
);
const left = items.filter((x) => !read[x.key]).length;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function open(k: string) {
setLive(false);
setRead((r) => ({ ...r, [k]: true }));
}
function all() {
setLive(false);
setRead(Object.fromEntries(items.map((x) => [x.key, true])));
}
const cls = [styles.nc, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.bellw}>
<span className={styles.bell} aria-hidden="true"><BellIcon /></span>
<span className={styles.badge} aria-live="polite">{unreadMsg(left)}</span>
</span>
<button className={styles.all} type="button" onClick={all}>
<span className={styles.ck} aria-hidden="true"><CheckIcon /></span>
{markAllLabel}
</button>
</header>
<ul className={styles.list}>
{items.map((x, i) => (
<li key={x.key} className={[styles.row, i + 1 <= 3 ? styles[`r${i + 1}`] : "", read[x.key] ? styles.isRead : ""].filter(Boolean).join(" ")}>
<button className={styles.it} type="button" onClick={() => open(x.key)}>
<span className={styles.dot} aria-hidden="true"></span>
<span className={styles.msg}>{x.msg}</span>
<span className={styles.meta}>
<span className={styles.readIc} aria-hidden="true"><CheckIcon /></span>
{x.tm}
</span>
</button>
</li>
))}
</ul>
</section>
);
}03 既読と未読が分かれる通知リスト — 計測 累積面積 15.917% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · 通知 ui 9選 — サイドバーに置ける活動フィードウィジェット
このデモが属する記事
通知 ui 9選 — サイドバーに置ける活動フィードウィジェット通知 ui とは、一瞬だけ現れて消えるトーストではなく、画面の片隅に居座り、積み上がり続けるリストのことです。サイドバーに置けるこの9個のウィジェットは、既読、修正前の値、ログレベル、今日の件数まで、すべて実際の状態を書き換えて動きます。 この記事のデモをすべて見る


