08알림 드롭다운 패널
종 아이콘을 누르면 패널이 실제로 그 자리에서 커지며 열리고, 알림을 누르면 그 항목만 읽음 처리되며 위 배지 숫자가 실제로 줄어듭니다.
쓰임헤더 종 아이콘에 붙는 알림 드롭다운 하나만 필요할 때
transform-originaria-expandedclick

<main class="stage">
<div class="np">
<button class="np__bell is-demo" type="button" aria-label="알림" aria-expanded="true">
<span class="np__bell-ic" aria-hidden="true"></span>
<span class="np__badge">2</span>
</button>
<div class="np__panel is-open" role="menu" aria-label="알림 목록">
<p class="np__panel-title">알림</p>
<button class="np__item is-unread" type="button" role="menuitem">
<span class="np__dot" aria-hidden="true"></span>
<span class="np__item-body"><strong>새 댓글</strong><span>박하은님이 댓글을 남겼습니다</span></span>
</button>
<button class="np__item is-unread" type="button" role="menuitem">
<span class="np__dot" aria-hidden="true"></span>
<span class="np__item-body"><strong>결제 완료</strong><span>이번 달 구독이 갱신됐습니다</span></span>
</button>
<button class="np__item" type="button" role="menuitem">
<span class="np__dot" aria-hidden="true"></span>
<span class="np__item-body"><strong>팀 초대</strong><span>정유나님이 팀에 합류했습니다</span></span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// 드롭다운이 아래로 펼쳐지므로 무대 중앙 정렬 대신 위쪽에 앵커한다 (실측: 중앙 정렬이면 패널이 300px 아래로 넘친다)
.stage { align-items: flex-start; padding-top: $sp-8; }
.np { position: relative; box-sizing: border-box; width: min(280px, 88vw); display: flex; justify-content: flex-end; }
.np__bell {
position: relative;
width: $sp-8; height: $sp-8;
border-radius: 50%;
border: 1px solid rgba(255, 247, 230, .22);
background: rgba(255, 247, 230, .06);
cursor: pointer;
display: grid; place-items: center;
}
.np__bell-ic {
width: 14px; height: 14px;
border: 2px solid $subject-cream;
border-bottom: 0;
border-radius: $r-sm $r-sm 0 0;
position: relative;
transform-origin: top center; // 규칙8: 종 꼭지점 흔들림
}
.np__bell-ic::after {
content: "";
position: absolute; left: -3px; bottom: -4px;
width: 20px; height: 2px;
background: $subject-cream;
border-radius: $r-xs;
}
.np__badge {
position: absolute; top: 0; right: 0;
min-width: 15px; height: 15px; padding: 0 $sp-1;
border-radius: $r-pill;
background: $error;
color: $subject-cream;
font-size: 9px; font-weight: 800; line-height: 15px; text-align: center;
box-shadow: 0 0 0 2px $stage-ink;
}
.np__panel {
box-sizing: border-box;
position: absolute; top: calc(100% + #{$sp-2}); right: 0;
width: 232px;
border-radius: $r-card;
background: #201c26;
border: 1px solid rgba(255, 247, 230, .12);
box-shadow: $shadow-raised;
padding: $sp-2;
transform-origin: top right; // 규칙8: 종 버튼 모서리서 펼침
opacity: 0; transform: scale(.9) translateY(-4px);
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.np__panel.is-open { opacity: 1; transform: scale(1) translateY(0); pointer-events: auto; }
.np__panel-title { margin: $sp-1 $sp-2 $sp-2; font-size: 11px; font-weight: 800; color: rgba(255, 247, 230, .55); }
.np__item {
width: 100%;
display: flex; align-items: flex-start; gap: $sp-2;
border: 0; background: transparent; cursor: pointer;
padding: $sp-2;
border-radius: $r-sm;
text-align: left;
transition: background $dur-quick $easing;
}
.np__item:hover, .np__item:focus-visible { background: rgba(255, 247, 230, .06); }
.np__dot { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; margin-top: $sp-1; background: transparent; }
.np__item.is-unread .np__dot { background: $color; }
.np__item-body { display: flex; flex-direction: column; gap: 0; }
.np__item-body strong { font-size: 11px; color: $subject-cream; }
.np__item-body span { font-size: 10px; color: rgba(255, 247, 230, .5); }
// ── 자동 루프: 종이 살짝 흔들리고 패널이 열리는 모션을 반복한다 ──────────────
.np__bell.is-demo .np__bell-ic { animation: np-ring 2s ease-in-out infinite; }
.np__bell.is-demo + .np__panel { animation: np-pop 2s $easing infinite; }
@keyframes np-ring {
0%, 78%, 100% { transform: rotate(0deg); }
82% { transform: rotate(14deg); }
86% { transform: rotate(-12deg); }
90% { transform: rotate(8deg); }
94% { transform: rotate(0deg); }
}
@keyframes np-pop {
0% { opacity: 0; transform: scale(.9) translateY(-4px); }
16%, 100% { opacity: 1; transform: scale(1) translateY(0); }
}
@media (max-width: 340px) {
.np__panel { width: 200px; }
}
@media (max-height: 220px) {
.stage { padding-top: $sp-2; }
.np__panel { top: calc(100% + 4px); padding: $sp-1; }
.np__panel-title { display: none; }
.np__item { padding: 4px $sp-1; gap: $sp-1; }
.np__item-body strong { font-size: 10px; }
.np__item-body span { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.np__bell.is-demo .np__bell-ic { animation: none !important; }
.np__panel { animation: none !important; transition: none; }
}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);
}
.stage {
align-items: flex-start;
padding-top: 32px;
}
.np {
position: relative;
box-sizing: border-box;
width: min(280px, 88vw);
display: flex;
justify-content: flex-end;
}
.np__bell {
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid rgba(255, 247, 230, 0.22);
background: rgba(255, 247, 230, 0.06);
cursor: pointer;
display: grid;
place-items: center;
}
.np__bell-ic {
width: 14px;
height: 14px;
border: 2px solid #fff7e6;
border-bottom: 0;
border-radius: 8px 8px 0 0;
position: relative;
transform-origin: top center;
}
.np__bell-ic::after {
content: "";
position: absolute;
left: -3px;
bottom: -4px;
width: 20px;
height: 2px;
background: #fff7e6;
border-radius: 4px;
}
.np__badge {
position: absolute;
top: 0;
right: 0;
min-width: 15px;
height: 15px;
padding: 0 4px;
border-radius: 999px;
background: #c2361a;
color: #fff7e6;
font-size: 9px;
font-weight: 800;
line-height: 15px;
text-align: center;
box-shadow: 0 0 0 2px #17141a;
}
.np__panel {
box-sizing: border-box;
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 232px;
border-radius: 16px;
background: #201c26;
border: 1px solid rgba(255, 247, 230, 0.12);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 8px;
transform-origin: top right;
opacity: 0;
transform: scale(0.9) translateY(-4px);
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.np__panel.is-open {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.np__panel-title {
margin: 4px 8px 8px;
font-size: 11px;
font-weight: 800;
color: rgba(255, 247, 230, 0.55);
}
.np__item {
width: 100%;
display: flex;
align-items: flex-start;
gap: 8px;
border: 0;
background: transparent;
cursor: pointer;
padding: 8px;
border-radius: 8px;
text-align: left;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.np__item:hover, .np__item:focus-visible {
background: rgba(255, 247, 230, 0.06);
}
.np__dot {
flex: 0 0 auto;
width: 7px;
height: 7px;
border-radius: 50%;
margin-top: 4px;
background: transparent;
}
.np__item.is-unread .np__dot {
background: #2f6df6;
}
.np__item-body {
display: flex;
flex-direction: column;
gap: 0;
}
.np__item-body strong {
font-size: 11px;
color: #fff7e6;
}
.np__item-body span {
font-size: 10px;
color: rgba(255, 247, 230, 0.5);
}
.np__bell.is-demo .np__bell-ic {
animation: np-ring 2s ease-in-out infinite;
}
.np__bell.is-demo + .np__panel {
animation: np-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes np-ring {
0%, 78%, 100% {
transform: rotate(0deg);
}
82% {
transform: rotate(14deg);
}
86% {
transform: rotate(-12deg);
}
90% {
transform: rotate(8deg);
}
94% {
transform: rotate(0deg);
}
}
@keyframes np-pop {
0% {
opacity: 0;
transform: scale(0.9) translateY(-4px);
}
16%, 100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@media (max-width: 340px) {
.np__panel {
width: 200px;
}
}
@media (max-height: 220px) {
.stage {
padding-top: 8px;
}
.np__panel {
top: calc(100% + 4px);
padding: 4px;
}
.np__panel-title {
display: none;
}
.np__item {
padding: 4px 4px;
gap: 4px;
}
.np__item-body strong {
font-size: 10px;
}
.np__item-body span {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.np__bell.is-demo .np__bell-ic {
animation: none !important;
}
.np__panel {
animation: none !important;
transition: none;
}
}// 실제 동작: 종을 누르면 패널이 실제로 열리고, 알림을 누르면 그 항목만 읽음 처리되며 배지 숫자가 실제로 줄어든다.
var bell = document.querySelector('.np__bell');
var panel = document.querySelector('.np__panel');
var badge = document.querySelector('.np__badge');
function unreadCount() { return document.querySelectorAll('.np__item.is-unread').length; }
function syncBadge() {
var n = unreadCount();
badge.textContent = String(n);
badge.style.display = n > 0 ? '' : 'none';
}
bell.addEventListener('click', function () {
bell.classList.remove('is-demo');
var open = bell.getAttribute('aria-expanded') === 'true';
bell.setAttribute('aria-expanded', String(!open));
panel.classList.toggle('is-open', !open);
});
document.querySelectorAll('.np__item').forEach(function (it) {
it.addEventListener('click', function () {
it.classList.remove('is-unread');
syncBadge();
});
});
document.addEventListener('click', function (e) {
if (!e.target.closest('.np')) { bell.setAttribute('aria-expanded', 'false'); panel.classList.remove('is-open'); }
});
syncBadge();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NotificationsPanel.module.scss";
export type NotificationsPanelProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Item = { id: string; title: string; desc: string; unread: boolean };
const INITIAL: Item[] = [
{ id: "a", title: "새 댓글", desc: "박하은님이 댓글을 남겼습니다", unread: true },
{ id: "b", title: "결제 완료", desc: "이번 달 구독이 갱신됐습니다", unread: true },
{ id: "c", title: "팀 초대", desc: "정유나님이 팀에 합류했습니다", unread: false },
];
export default function NotificationsPanel({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: NotificationsPanelProps) {
const [open, setOpen] = useState(true);
const [items, setItems] = useState(INITIAL);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const unread = items.filter((i) => i.unread).length;
function markRead(id: string) {
setItems((list) => list.map((i) => (i.id === id ? { ...i, unread: false } : i)));
}
return (
<div className={styles.np} style={vars}>
<button type="button" className={styles.bell} aria-label="알림" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
<span className={styles.bellIc} />
{unread > 0 && <span className={styles.badge}>{unread}</span>}
</button>
<div className={`${styles.panel} ${open ? styles.open : ""}`} role="menu" aria-label="알림 목록">
<p className={styles.panelTitle}>알림</p>
{items.map((it) => (
<button key={it.id} type="button" role="menuitem" className={styles.item} onClick={() => markRead(it.id)}>
<span className={`${styles.dot} ${it.unread ? styles.unread : ""}`} />
<span className={styles.itemBody}><strong>{it.title}</strong><span>{it.desc}</span></span>
</button>
))}
</div>
</div>
);
}08 알림 드롭다운 패널 — 측정 누적면적 5.662% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 대시보드 사이드바·위젯 UI 9종 — 완성 페이지 말고 이 조각 하나만
이 데모가 속한 글
대시보드 사이드바·위젯 UI 9종 — 완성 페이지 말고 이 조각 하나만대시보드 사이드바 하나, 통계 카드 한 줄, 알림 드롭다운 하나처럼 완성된 관리자 페이지 전체가 아니라 화면 한 조각만 그대로 가져다 붙이는 대시보드 위젯 아홉 개를 모았습니다. 아홉 개 중 하나는 숫자가 실제로 세 이 글의 데모 전부 보기


