01뱃지 값이 펄스한 뒤 열리는 알림 패널
미확인 개수 값이 늘어날 때마다 뱃지가 커졌다 작아지는 펄스를 두 번 반복하고, 벨을 누르면 그 아래에서 패널이 높이 값을 0에서 펼치며 내려온다.
쓰임SaaS 대시보드 상단 알림 벨
NOTIF_BADGE_PULSE_MSmeasureHeightpulseBadge

<main class="stage">
<div class="nb is-demo" id="nb">
<button class="nb__bell" id="nbBell" type="button" aria-expanded="false" aria-controls="nbPanel">
<svg class="nb__ic" viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path opacity=".2" 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"/>
<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="nb__badge" id="nbBadge" aria-hidden="true">0</span>
<span class="nb__pulseRing" id="nbRing" aria-hidden="true"></span>
<span class="nb__sr" id="nbSr" aria-live="polite">미확인 알림 0개</span>
</button>
<div class="nb__panel" id="nbPanel" role="region" aria-label="알림 패널">
<ul class="nb__list" role="list" id="nbList"></ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html measureHeight() 가 패널을 hidden 상태로 실측해 항목 수마다 다른 실제
// 높이 값을 얻고, 그 값으로 0 → 실측치 전환을 건다(고정 높이 아님) — nb__badge--pulse 는
// NOTIF_BADGE_PULSE_MS 로 재생 시간이 정해지는 이중 펄스.
.stage { @include stage($stage-ink); }
.nb {
position: relative;
width: min(100%, 300px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.nb__bell {
position: relative;
width: 56px;
height: 56px;
border-radius: $r-pill;
border: none;
background: $subject-blue-deep;
color: $subject-cream;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.nb__ic { width: 26px; height: 26px; }
.nb__badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 20px;
height: 20px;
padding: 0 $sp-1;
border-radius: $r-pill;
background: $error;
color: $subject-cream;
font-size: 11px;
font-weight: 700;
line-height: 20px;
text-align: center;
transform-origin: center;
}
.nb__badge--pulse { animation-name: nbPulse; animation-timing-function: $ease-spring; }
// 계산 겹: index.html pulseBadge() 가 배지 실측 지름 기준 배율(--ring-scale)을 매번 다시 계산해
// 넣는다 — 이 링은 그 배율만큼 배지 밖으로 퍼지며 사라진다(고정 px 확산이 아니다).
.nb__pulseRing {
position: absolute;
top: -2px;
right: -2px;
min-width: 20px;
height: 20px;
border-radius: $r-pill;
border: 2px solid $error;
opacity: 0;
pointer-events: none;
transform-origin: center;
}
.nb__pulseRing--go { animation: nbRingGo $dur-enter $ease-out; }
@keyframes nbRingGo {
0% { opacity: .6; transform: scale(1); }
100% { opacity: 0; transform: scale(var(--ring-scale, 2.6)); }
}
.nb__sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0;
}
.nb__panel {
width: 100%;
height: 0;
overflow: hidden;
border-radius: $r-card;
background: $tick-panel;
transition: height $dur-base $ease-spring;
}
.nb__list {
list-style: none;
margin: 0;
padding: $sp-2;
display: flex;
flex-direction: column;
gap: $sp-1;
}
.nb__item {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba($subject-cream, .06);
}
.nb__itemName { font-size: 12px; color: $subject-cream; }
.nb__itemTime { font-size: 10px; color: rgba($subject-cream, .55); flex: 0 0 auto; }
@keyframes nbPulse {
0%, 100% { transform: scale(1); }
20% { transform: scale(1.3); }
40% { transform: scale(1); }
60% { transform: scale(1.3); }
80% { transform: scale(1); }
}
@media (max-width: 340px) {
.nb { width: 100%; }
.nb__itemTime { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.nb__panel { transition: none; }
.nb__badge--pulse { animation: none; }
.nb__pulseRing--go { animation: none; }
}.stage html, .stage body {
margin: 0;
overflow: hidden;
}
.stage .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);
}
.nb {
position: relative;
width: min(100%, 300px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.nb__bell {
position: relative;
width: 56px;
height: 56px;
border-radius: 999px;
border: none;
background: #0a4fe8;
color: #fff7e6;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.nb__ic {
width: 26px;
height: 26px;
}
.nb__badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 20px;
height: 20px;
padding: 0 4px;
border-radius: 999px;
background: #c2361a;
color: #fff7e6;
font-size: 11px;
font-weight: 700;
line-height: 20px;
text-align: center;
transform-origin: center;
}
.nb__badge--pulse {
animation-name: nbPulse;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.nb__pulseRing {
position: absolute;
top: -2px;
right: -2px;
min-width: 20px;
height: 20px;
border-radius: 999px;
border: 2px solid #c2361a;
opacity: 0;
pointer-events: none;
transform-origin: center;
}
.nb__pulseRing--go {
animation: nbRingGo 600ms ease-out;
}
@keyframes nbRingGo {
0% {
opacity: 0.6;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(var(--ring-scale, 2.6));
}
}
.nb__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
.nb__panel {
width: 100%;
height: 0;
overflow: hidden;
border-radius: 16px;
background: #231f28;
transition: height 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.nb__list {
list-style: none;
margin: 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.nb__item {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.06);
}
.nb__itemName {
font-size: 12px;
color: #fff7e6;
}
.nb__itemTime {
font-size: 10px;
color: rgba(255, 247, 230, 0.55);
flex: 0 0 auto;
}
@keyframes nbPulse {
0%, 100% {
transform: scale(1);
}
20% {
transform: scale(1.3);
}
40% {
transform: scale(1);
}
60% {
transform: scale(1.3);
}
80% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.nb {
width: 100%;
}
.nb__itemTime {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.nb__panel {
transition: none;
}
.nb__badge--pulse {
animation: none;
}
.nb__pulseRing--go {
animation: none;
}
}(function () {
"use strict";
var NOTIF_BADGE_PULSE_MS = 900; // 뱃지가 값이 오를 때 두 번 숨쉬듯 부풀었다 가라앉는 데 걸리는 시간
var NAMES = [
"결제가 승인됐어요", "새 댓글이 달렸어요", "회의 10분 전이에요",
"업로드가 끝났어요", "초대장이 도착했어요"
];
var TIMES = ["방금 전", "5분 전", "1시간 전", "어제", "2일 전"];
var root = document.getElementById("nb");
var bell = document.getElementById("nbBell");
var badge = document.getElementById("nbBadge");
var ring = document.getElementById("nbRing");
var sr = document.getElementById("nbSr");
var panel = document.getElementById("nbPanel");
var list = document.getElementById("nbList");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
badge.style.animationDuration = NOTIF_BADGE_PULSE_MS + "ms";
var synthetic = false;
function human() {
if (synthetic) return;
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
var items = [];
var open = false;
var seq = 0;
function renderList() {
list.innerHTML = "";
items.forEach(function (it) {
var li = document.createElement("li");
li.className = "nb__item";
li.setAttribute("role", "listitem");
li.innerHTML =
'<span class="nb__itemName">' + it.name + "</span>" +
'<span class="nb__itemTime">' + it.time + "</span>";
list.appendChild(li);
});
}
function pulseBadge() {
badge.classList.remove("nb__badge--pulse");
ring.classList.remove("nb__pulseRing--go");
if (!reduce) {
// 계산 겹: 배지의 실측 지름(getBoundingClientRect)에서 링이 퍼져나갈 배율을 구한다 —
// 고정 px 가 아니라 그 순간 배지 크기 비례라 320px 무대에서도 배지 밖으로 정확히 퍼진다
var bw = badge.getBoundingClientRect().width || 20;
var targetD = bw * 2.6;
ring.style.width = bw + "px";
ring.style.height = bw + "px";
ring.style.setProperty("--ring-scale", (targetD / bw).toFixed(2));
// eslint-disable-next-line no-unused-expressions
void badge.offsetWidth;
void ring.offsetWidth;
badge.classList.add("nb__badge--pulse");
ring.classList.add("nb__pulseRing--go");
}
}
// 계산 겹: 패널을 실제로 펼치기 전, 화면 밖(visibility hidden)에서 콘텐츠의 실제 높이를
// getBoundingClientRect 로 미리 재서(항목 수마다 다른 값) 그 값으로 0 → 실측치를 전환한다.
function measureHeight() {
var prevVis = panel.style.visibility;
var prevH = panel.style.height;
panel.style.visibility = "hidden";
panel.style.height = "auto";
var h = panel.getBoundingClientRect().height;
panel.style.visibility = prevVis;
panel.style.height = prevH;
return h;
}
function setOpen(v) {
open = v;
bell.setAttribute("aria-expanded", v ? "true" : "false");
if (v) {
var h = measureHeight();
panel.style.height = "0px";
requestAnimationFrame(function () {
panel.style.height = h + "px";
});
} else {
panel.style.height = measureHeight() + "px";
requestAnimationFrame(function () {
panel.style.height = "0px";
});
}
}
function addNotification() {
var idx = seq % NAMES.length;
items.unshift({ name: NAMES[idx], time: TIMES[seq % TIMES.length] });
seq++;
if (items.length > 5) items.pop();
badge.textContent = String(items.length);
sr.textContent = "미확인 알림 " + items.length + "개";
pulseBadge();
renderList();
// 패널이 열려 있는 동안 새 알림이 오면 높이도 새 내용에 맞춰 다시 잰다(고정폭 안 잘림)
if (open) panel.style.height = measureHeight() + "px";
}
function toggle() {
human();
setOpen(!open);
}
bell.addEventListener("click", toggle);
bell.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
});
addNotification();
addNotification();
if (reduce) {
panel.style.transition = "none";
setOpen(true);
addNotification();
} else {
var loopTimer = 0;
var step = 0;
// 시연 주기 간격(펄스→열림→추가→닫힘) — 측정창(2초) 안에 패널 열림이 다 보이도록 조정했다.
// NOTIF_BADGE_PULSE_MS 자체는 그대로, 다음 단계로 넘어가는 대기시간만 다르게 준다.
var STEP_WAIT_MS = [500, 500, 400, 900];
function loop() {
if (!root.classList.contains("is-demo")) return;
synthetic = true;
if (step === 0) {
addNotification();
} else if (step === 1) {
setOpen(true);
} else if (step === 2) {
addNotification();
} else {
setOpen(false);
}
synthetic = false;
loopTimer = window.setTimeout(loop, STEP_WAIT_MS[step]);
step = (step + 1) % 4;
}
loopTimer = window.setTimeout(loop, 200);
}
})();// NotifBellBadgePulseOpen — vanilla 판(run/405/demo/01-.../index.html) 의 React 포트.
// 계산 겹: measureHeight() 가 패널을 hidden 상태로 실측해 항목 수마다 다른 실제 높이 값을 얻고
// 그 값으로 0 → 실측치 전환을 건다(고정 높이 아님) — vanilla 와 동일 동작.
import { useEffect, useRef, useState } from "react";
import styles from "./NotifBellBadgePulseOpen.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const NOTIF_BADGE_PULSE_MS = 900; // 뱃지가 값이 오를 때 두 번 숨쉬듯 부풀었다 가라앉는 데 걸리는 시간
const NAMES = [
"결제가 승인됐어요", "새 댓글이 달렸어요", "회의 10분 전이에요",
"업로드가 끝났어요", "초대장이 도착했어요",
];
const TIMES = ["방금 전", "5분 전", "1시간 전", "어제", "2일 전"];
type Item = { name: string; time: string };
export default function NotifBellBadgePulseOpen() {
const [isDemo, setIsDemo] = useState(true);
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const synthRef = useRef(false) as unknown as Box<boolean>;
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const bellRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const badgeRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const ringRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const srRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const panelRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const itemsRef = useRef([] as Item[]) as unknown as Box<Item[]>;
const openRef = useRef(false) as unknown as Box<boolean>;
const seqRef = useRef(0) as unknown as Box<number>;
const loopTimerRef = useRef(0) as unknown as Box<number>;
const stepRef = useRef(0) as unknown as Box<number>;
function human() {
if (synthRef.current) return;
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function renderList() {
const listEl = listRef.current;
if (!listEl) return;
listEl.innerHTML = "";
itemsRef.current.forEach((it) => {
const li = document.createElement("li");
li.className = styles.item;
li.setAttribute("role", "listitem");
li.innerHTML =
'<span class="' + styles.itemName + '">' + it.name + "</span>" +
'<span class="' + styles.itemTime + '">' + it.time + "</span>";
listEl.appendChild(li);
});
}
function pulseBadge(reduce: boolean) {
const badge = badgeRef.current;
const ring = ringRef.current;
if (!badge || !ring) return;
badge.classList.remove(styles.badgePulse);
ring.classList.remove(styles.pulseRingGo);
if (!reduce) {
// 계산 겹: 배지의 실측 지름(getBoundingClientRect)에서 링이 퍼져나갈 배율을 구한다 —
// 고정 px 가 아니라 그 순간 배지 크기 비례라 320px 무대에서도 배지 밖으로 정확히 퍼진다
const bw = badge.getBoundingClientRect().width || 20;
const targetD = bw * 2.6;
ring.style.width = bw + "px";
ring.style.height = bw + "px";
ring.style.setProperty("--ring-scale", (targetD / bw).toFixed(2));
void badge.offsetWidth;
void ring.offsetWidth;
badge.classList.add(styles.badgePulse);
ring.classList.add(styles.pulseRingGo);
}
}
// 계산 겹: 패널을 실제로 펼치기 전, 화면 밖(visibility hidden)에서 콘텐츠의 실제 높이를
// getBoundingClientRect 로 미리 재서(항목 수마다 다른 값) 그 값으로 0 → 실측치를 전환한다.
function measureHeight(): number {
const panel = panelRef.current;
if (!panel) return 0;
const prevVis = panel.style.visibility;
const prevH = panel.style.height;
panel.style.visibility = "hidden";
panel.style.height = "auto";
const h = panel.getBoundingClientRect().height;
panel.style.visibility = prevVis;
panel.style.height = prevH;
return h;
}
function setOpen(v: boolean) {
openRef.current = v;
const bell = bellRef.current, panel = panelRef.current;
if (bell) bell.setAttribute("aria-expanded", v ? "true" : "false");
if (!panel) return;
if (v) {
const h = measureHeight();
panel.style.height = "0px";
requestAnimationFrame(() => { panel.style.height = h + "px"; });
} else {
panel.style.height = measureHeight() + "px";
requestAnimationFrame(() => { panel.style.height = "0px"; });
}
}
function addNotification(reduce: boolean) {
const idx = seqRef.current % NAMES.length;
itemsRef.current.unshift({ name: NAMES[idx], time: TIMES[seqRef.current % TIMES.length] });
seqRef.current++;
if (itemsRef.current.length > 5) itemsRef.current.pop();
const badge = badgeRef.current, sr = srRef.current;
if (badge) badge.textContent = String(itemsRef.current.length);
if (sr) sr.textContent = "미확인 알림 " + itemsRef.current.length + "개";
pulseBadge(reduce);
renderList();
// 패널이 열려 있는 동안 새 알림이 오면 높이도 새 내용에 맞춰 다시 잰다(고정폭 안 잘림)
if (openRef.current) {
const panel = panelRef.current;
if (panel) panel.style.height = measureHeight() + "px";
}
}
function toggle() {
human();
setOpen(!openRef.current);
}
function onKeyDown(e: KeyEvt) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
}
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const badge = badgeRef.current;
if (badge) badge.style.animationDuration = NOTIF_BADGE_PULSE_MS + "ms";
addNotification(reduce);
addNotification(reduce);
if (reduce) {
const panel = panelRef.current;
if (panel) panel.style.transition = "none";
setOpen(true);
addNotification(reduce);
} else {
// 시연 주기 간격(펄스→열림→추가→닫힘) — 측정창(2초) 안에 패널 열림이 다 보이도록 조정했다.
// NOTIF_BADGE_PULSE_MS 자체는 그대로, 다음 단계로 넘어가는 대기시간만 다르게 준다.
const STEP_WAIT_MS = [500, 500, 400, 900];
function loop() {
if (!isDemoRef.current) return;
synthRef.current = true;
const step = stepRef.current;
if (step === 0) addNotification(false);
else if (step === 1) setOpen(true);
else if (step === 2) addNotification(false);
else setOpen(false);
synthRef.current = false;
loopTimerRef.current = window.setTimeout(loop, STEP_WAIT_MS[step]);
stepRef.current = (step + 1) % 4;
}
loopTimerRef.current = window.setTimeout(loop, 200);
}
return () => { window.clearTimeout(loopTimerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.nb + " " + (isDemo ? styles.isDemo : "")} ref={rootRef}>
<button className={styles.bell} type="button" aria-expanded="false" aria-controls="nbPanel"
ref={bellRef} onClick={toggle} onKeyDown={onKeyDown}>
<svg className={styles.ic} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path opacity=".2" 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" />
<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 className={styles.badge} aria-hidden="true" ref={badgeRef}>0</span>
<span className={styles.pulseRing} aria-hidden="true" ref={ringRef} />
<span className={styles.sr} aria-live="polite" ref={srRef}>미확인 알림 0개</span>
</button>
<div className={styles.panel} id="nbPanel" role="region" aria-label="알림 패널" ref={panelRef}>
<ul className={styles.list} role="list" ref={listRef} />
</div>
</div>
</main>
);
}01 뱃지 값이 펄스한 뒤 열리는 알림 패널 — 측정 누적면적 34.257% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 알림 애니메이션 9가지 — 알림 패널 뱃지 정리
이 데모가 속한 글
css 알림 애니메이션 9가지 — 알림 패널 뱃지 정리css 알림 애니메이션 아홉 가지를 한 패널에 모았다. 「알림이 몇 개 남았나요?」 라는 질문에 이 패널은 미확인 값 하나로 답한다. 3번은 그 값이 0이 되는 순간 뱃지를 고리 모양으로 되돌리고, 6번은 같은 값이 이 글의 데모 전부 보기


