GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

css 알림 애니메이션 9가지 — 알림 패널 뱃지 정리

css 알림 애니메이션 아홉 가지를 한 패널에 모았다. 「알림이 몇 개 남았나요?」 라는 질문에 이 패널은 미확인 값 하나로 답한다. 3번은 그 값이 0이 되는 순간 뱃지를 고리 모양으로 되돌리고, 6번은 같은 값이 비면 목록 전체를 빈 상태 문구로 바꾸고, 9번은 탭을 옮길 때마다

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 알림 패널이 같은 값 하나로 다르게 움직이는 이유

01은 뱃지 실측 지름, 03은 스프링 진행값, 06은 목록 순번 — 아홉 항목은 저마다 다른 값 하나를 쥐고 있고, 그 값이 바뀌는 순간에만 패널 어딘가가 다시 계산돼 움직인다. 항목마다 그 값을 만드는 함수와 실제 소스를 아래에 그대로 옮겨 짚는다.

01뱃지 값이 펄스한 뒤 열리는 알림 패널

대시보드 헤더의 알림 벨처럼 미확인 개수 값이 늘어날 때마다 뱃지가 두 번 펄스하고, 벨을 누르면 그 아래에서 패널이 높이 값을 0에서 펼치며 내려온다.

뱃지 값이 펄스한 뒤 열리는 알림 패널
새 창에서 열기

01 · 뱃지 값이 펄스한 뒤 열리는 알림 패널 — 측정 누적면적 34.257% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NOTIF_BADGE_PULSE_MSmeasureHeightpulseBadge
  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");
    }

펄스가 퍼지는 배율은 고정 px 가 아니라 getBoundingClientRect로 잰 뱃지의 실측 지름에 비례해 정해지므로, 뱃지 크기가 다른 화면에서도 링이 항상 뱃지 밖으로 정확히 퍼진다.

02날짜 그룹 값에 따라 접히는 헤더 목록

메신저의 어제·오늘 같은 날짜 그룹 헤더를 누르면, 그 아래 항목 개수만큼 높이 값이 줄어들며 하위 목록이 접힌다.

날짜 그룹 값에 따라 접히는 헤더 목록
새 창에서 열기

02 · 날짜 그룹 값에 따라 접히는 헤더 목록 — 측정 누적면적 29.136% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NOTIF_GROUP_COLLAPSE_DELAY_MSgroupElstoggleGroup
  function setGroup(key, expand) {
    state[key] = expand;
    var els = groupEls(key);
    els.header.setAttribute("aria-expanded", expand ? "true" : "false");
    var items = Array.prototype.slice.call(els.list.children);
    var n = items.length;
    items.forEach(function (li, i) {
      var order = expand ? (n - 1 - i) : i;
      var delay = reduce ? 0 : order * NOTIF_GROUP_COLLAPSE_DELAY_MS;
      var h = li.dataset.h ? parseFloat(li.dataset.h) : null;
      if (h === null) {
        li.style.height = "auto";
        h = li.getBoundingClientRect().height;
        li.dataset.h = String(h);
      }
      li.style.transitionDelay = delay + "ms";
      if (expand) {

접히거나 펼쳐질 때 각 항목의 transitionDelay는 순번 값에 NOTIF_GROUP_COLLAPSE_DELAY_MS를 곱해 정해져, 펼칠 때는 아래에서 위로·접을 때는 위에서 아래로 순서가 반대로 번진다.

03읽음 값이 켜지면 고리로 바뀌는 점 표시

작업 큐의 안 읽은 항목마다 채워진 점이 떠 있다가, 읽음 값이 켜지는 순간 같은 반지름의 테두리만 남은 고리로 모양이 바뀐다.

읽음 값이 켜지면 고리로 바뀌는 점 표시
새 창에서 열기

03 · 읽음 값이 켜지면 고리로 바뀌는 점 표시 — 측정 누적면적 23.068% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SpringtickmarkRead
    function apply(p) {
      var c = Math.max(0, Math.min(1, p));
      var over = Math.max(0, p - 1);
      fillEl.style.opacity = String(1 - c);
      ringEl.style.borderWidth = (c * 2.4).toFixed(2) + "px";
      var bump = 1 + over * 0.85;
      fillEl.parentNode.style.transform = "scale(" + bump.toFixed(3) + ")";
      // 같은 스프링 값(c)으로 행 전체 톤도 함께 보간한다 — 안 읽음일수록 진하고, 읽으면 투명해진다
      if (tintEl) tintEl.style.opacity = (0.10 * (1 - c)).toFixed(3);
    }

이 변화는 스프링 진행값 하나(c)로 점의 투명도·테두리 굵기·바깥 확대 배율을 동시에 보간하고, 같은 값으로 행 배경의 톤까지 함께 옅어지게 만든다.

04이동 거리 값을 넘기면 사라지는 스와이프 항목

손가락으로 알림 카드를 옆으로 끌면, 이동 거리 값이 기준을 넘거나 손을 뗀 속도가 빠를 때만 그 카드가 사라진다.

이동 거리 값을 넘기면 사라지는 스와이프 항목
새 창에서 열기

04 · 이동 거리 값을 넘기면 사라지는 스와이프 항목 — 측정 누적면적 38.515% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SWIPE_DISMISS_RATIOSWIPE_VELOCITY_PX_MSendDrag
    function endDrag(e) {
      if (!dragging) return;
      dragging = false;
      row.style.transition = "";
      var dt = Math.max(1, e.timeStamp - startT);
      var velocity = Math.abs(dx) / dt;
      var ratio = Math.abs(dx) / width;
      if (ratio > SWIPE_DISMISS_RATIO || velocity > SWIPE_VELOCITY_PX_MS) {
        row.style.transform = "translateX(-100%)";
        reveal.style.opacity = "1";
        dismiss(li, name);
      } else {
        row.style.transform = "translateX(0px)";
        reveal.style.opacity = "0";
      }
    }

판정은 거리 비율(ratio)과 속도(velocity) 두 값을 함께 보고, 둘 중 하나만 문턱을 넘어도 밀어서 지우기로 확정하는 이중 조건이다.

05밀림 값만큼 옆으로 비켜나는 스누즈 항목

스누즈 버튼을 누르면 그 항목만 밀림 거리 값만큼 옆으로 비켜난 뒤, 카드 실측 폭만큼 한 번 더 밀려나며 자리를 접는다.

밀림 값만큼 옆으로 비켜나는 스누즈 항목
새 창에서 열기

05 · 밀림 값만큼 옆으로 비켜나는 스누즈 항목 — 측정 누적면적 41.176% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NOTIF_SNOOZE_SLIDE_PXDURATIONSsnoozeOut
  function snoozeOut(li, row, label, name) {
    var durIdx = snoozed % DURATIONS.length;
    label.textContent = DURATIONS[durIdx];
    row.style.transform = "translateX(-" + NOTIF_SNOOZE_SLIDE_PX + "px)";
    var width = li.getBoundingClientRect().width;
    window.setTimeout(function () {
      row.style.transform = "translateX(-" + (NOTIF_SNOOZE_SLIDE_PX + width) + "px)";
      var h = li.getBoundingClientRect().height;
      li.style.height = h + "px";
      window.setTimeout(function () {
        li.style.height = "0px";
        li.style.marginBottom = "0px";
      }, reduce ? 0 : 40);
      window.setTimeout(function () {
        if (li.parentNode) li.parentNode.removeChild(li);
      }, reduce ? 0 : 320);
    }, reduce ? 0 : 150);

두 번째 밀림 폭은 NOTIF_SNOOZE_SLIDE_PX에 getBoundingClientRect로 잰 카드 실측 폭을 더해 정해지므로, 카드 너비가 반응형으로 바뀌어도 항상 화면 밖까지 밀려난다.

06차례 값을 따라 번지는 전체 읽음 처리

전체 읽음 버튼을 누르면 목록 위쪽 항목부터 차례로 읽음 표시가 번지고, 한 번에 모두 바뀌지 않는다.

차례 값을 따라 번지는 전체 읽음 처리
새 창에서 열기

06 · 차례 값을 따라 번지는 전체 읽음 처리 — 측정 누적면적 14.643% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NOTIF_MARK_ALL_STAGGER_MSmarkAllReadupdateCount
  function markAllRead() {
    rows.forEach(function (r, i) {
      if (r.read) return;
      var delay = reduce ? 0 : i * NOTIF_MARK_ALL_STAGGER_MS;
      r.fill.style.transitionDelay = delay + "ms";
      r.ring.style.transitionDelay = delay + "ms";
      r.tint.style.transitionDelay = delay + "ms";
      r.li.style.transitionDelay = delay + "ms";
      r.read = true;
      r.fill.style.opacity = "0";
      r.ring.style.borderWidth = "2.4px";
      r.tint.style.opacity = "0";
      r.li.style.borderLeftWidth = "0px";
    });
    updateCount();

같은 순번 값(i * NOTIF_MARK_ALL_STAGGER_MS)을 점 채우기·테두리 굵기·행 톤·왼쪽 강조선 네 속성에 동일한 지연으로 걸어, 네 변화가 한 항목 안에서는 항상 같은 순간에 맞물려 끝난다.

07남은 개수 값이 0 이면 바뀌는 빈 상태 문구

알림을 지우거나 스누즈해 남은 개수 값이 0이 되는 순간, 목록이 옅어지며 사라지고 빈 상태 문구가 그 자리에서 짙어진다.

남은 개수 값이 0 이면 바뀌는 빈 상태 문구
새 창에서 열기

07 · 남은 개수 값이 0 이면 바뀌는 빈 상태 문구 — 측정 누적면적 6.199% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

showEmptyshowListfill
  function showEmpty() {
    empty.removeAttribute("hidden");
    requestAnimationFrame(function () {
      list.classList.add("es__list--out");
      empty.classList.add("es__empty--in");
    });
    window.setTimeout(function () {
      list.setAttribute("hidden", "");
    }, reduce ? 0 : 260);
    clearBtn.disabled = true;
    sr.textContent = "받은 알림이 없어요";

두 요소는 투명도만 반대로 움직이는 게 아니라, 목록을 hidden 속성으로 완전히 감추는 시점을 setTimeout으로 늦춰 사라지는 전환이 끝나기 전에 레이아웃이 먼저 무너지지 않게 한다.

08더보기 값을 누르면 시간차로 들어오는 항목

더보기를 누르면 다음 페이지 값을 불러와, 새 항목이 위에서 아래로 순서대로 하나씩 끼어들며 나타난다.

더보기 값을 누르면 시간차로 들어오는 항목
새 창에서 열기

08 · 더보기 값을 누르면 시간차로 들어오는 항목 — 측정 누적면적 29.639% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NOTIF_LOAD_STAGGER_MSloadMorefillStatic
  function loadMore() {
    if (pageIdx >= PAGES.length) return;
    var names = PAGES[pageIdx];
    pageIdx++;
    var added = [];
    names.forEach(function (name, order) {
      var li = makeRow(name);
      li.style.opacity = "0";
      li.style.transform = "translateY(14px)";
      list.appendChild(li);
      added.push({ li: li, order: order });
    });
    // 강제 리플로우 뒤 다음 프레임에서 최종 값을 건다 — 같은 틱에서 시작값 0 → 끝값 1 을
    // 연달아 주면 브라우저가 시작 프레임을 한 번도 그리지 못하고 바로 끝 값으로 건너뛰어
    // 전환이 눈에 안 보인다(실측 2026-09-24: opacity 가 매 프레임 이미 1 로 찍힘). 강제
    // 리플로우(offsetHeight 읽기)로 시작 상태를 굳힌 뒤 requestAnimationFrame 두 번으로
    // 확실히 다음 그리기 이후에 최종 값을 건다.
    void list.offsetHeight;
    requestAnimationFrame(function () {
      requestAnimationFrame(function () {
        added.forEach(function (a) {
          var delay = reduce ? 0 : a.order * NOTIF_LOAD_STAGGER_MS;

강제 리플로우(offsetHeight 읽기) 뒤 requestAnimationFrame을 두 번 걸어야 시작 값(0)이 실제로 한 번 그려지고, 이 순서를 건너뛰면 브라우저가 시작 프레임을 그리지 못하고 끝 값으로 바로 건너뛴다.

09탭 값이 바뀌면 겹쳐 지나가는 필터 목록

전체·멘션·시스템 탭 값을 바꾸면 이전 목록이 옅어지며 밀려나는 사이, 새 필터 결과가 그 자리에서 짙어지며 겹쳐 지나간다.

탭 값이 바뀌면 겹쳐 지나가는 필터 목록
새 창에서 열기

09 · 탭 값이 바뀌면 겹쳐 지나가는 필터 목록 — 측정 누적면적 20.828% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

selectTabfilterCountCATS
  function selectTab(cat, viaKeyboard) {
    if (cat === active) return;
    var incoming = back;
    var outgoing = front;
    var n = fill(incoming, cat);
    incoming.style.transition = "none";
    incoming.style.opacity = "0";
    incoming.style.transform = "translateY(12px)";
    incoming.removeAttribute("hidden");
    // 강제 리플로우 뒤 다음 프레임에서 최종 값을 건다 — 시작 값(0)이 한 번 그려지기 전에
    // 끝 값(1)으로 바로 덮으면 전환이 안 보인다(실측 2026-09-24, 항목 08 과 같은 원인).
    void incoming.offsetHeight;
    incoming.style.transition = "";
    requestAnimationFrame(function () {
      requestAnimationFrame(function () {
        outgoing.classList.add("tf__list--out");
        incoming.style.opacity = "1";
        incoming.style.transform = "translateY(0)";
      });
    });
    window.setTimeout(function () {
      outgoing.setAttribute("hidden", "");

08번과 같은 이유로 여기서도 강제 리플로우 뒤 requestAnimationFrame을 두 번 걸어 시작 상태를 먼저 굳히고, 탭 값은 곧 어느 목록을 채워 넣을지를 정하는 fill(incoming, cat) 호출의 인자가 된다.

값이 실제로 계산되는 지점 — 미확인 개수·타이머 라벨·필터 결과 수

이 웹 알림창 안에서 값이 다시 계산되는 지점은 크게 셋이다. 01·06·07은 미확인 개수 값을 기준으로 뱃지 숫자와 빈 상태 전환을 맞추고, 05는 스누즈 타이머 라벨을 남은 개수 값에서 하나씩 빼며 다시 그리고, 09는 탭을 옮길 때마다 필터 결과 수를 세어 목록을 새로 채운다. 세 지점 모두 값을 DOM 에 미리 박아두지 않고, 클릭·타이머·탭 전환이 일어난 그 순간에 직접 다시 센다.

리플로우를 건너뛰면 08·09가 멈춰 보이는 함정

08과 09는 새 항목이나 새 탭 목록을 넣을 때 시작 값(투명도 0, 이동 값 14px)과 끝 값(투명도 1, 이동 값 0)을 같은 틱에서 연달아 걸면, 브라우저가 시작 프레임을 한 번도 그리지 못하고 바로 끝 값으로 건너뛰어 전환이 눈에 안 보이게 깨진다 — 소스 주석에 실측 날짜까지 남아 있는, 실제로 겪은 문제다. 고치는 방법은 void el.offsetHeight로 강제 리플로우를 한 번 일으켜 시작 값을 화면에 먼저 굳힌 뒤, requestAnimationFrame을 두 번 걸어 그다음 그리기 차례에만 끝 값을 주는 것이다. 02에도 비슷한 함정이 있는데, 접힌 항목의 높이를 li.dataset.h에 한 번 실측해 캐시해 두므로, 폰트가 늦게 로드되거나 리사이즈로 실제 줄 수가 바뀌면 캐시된 높이가 새 내용과 어긋나 접힘 애니메이션이 짧거나 길게 끊긴다.

접근성과 reduced-motion — 아홉 개가 값 하나로 멈추는 법

아홉 항목 모두 prefers-reduced-motion을 matchMedia로 확인해 reduce 값이 참이면 지연·펄스·크로스페이드에 쓰이는 시간 값을 0으로 낮추고, scss 쪽에서도 같은 클래스에 transition: none·animation: none을 따로 걸어 둔다. 그렇다고 값 자체가 안 바뀌는 건 아니라서, reduce 분기에서도 뱃지 숫자·읽음 상태·필터 결과 같은 계산 결과는 화면에 그대로 반영되고 중간 과정만 사라진다. 01·03·04·06·07·08·09는 aria-live 영역에 「4개 안 읽음」 같은 상태 문구를 따로 갱신해 스크린리더가 패널 그림과 별개로 값을 듣게 하고, 03의 읽음 토글 버튼은 aria-pressed를 읽음 값과 함께 갱신한다.

값 하나만 바꿔보는 변형 세 가지

항목 값 이름 기존 → 변경 바뀌는 느낌
01 NOTIF_BADGE_PULSE_MS 900 → 1600 값을 늘리면 뱃지가 느긋하게 두 번 숨 쉬듯 펄스하고, 줄이면 짧게 두 번 튀어 다급한 인상을 준다.
02 NOTIF_GROUP_COLLAPSE_DELAY_MS 80 → 240 값을 키우면 하위 항목이 계단처럼 한 칸씩 늦게 접히고, 줄이면 거의 동시에 접혀 버린다.
05 NOTIF_SNOOZE_SLIDE_PX 40 → 120 값을 늘리면 스누즈한 카드가 화면 밖까지 크게 밀려나 완전히 치워진 느낌이고, 줄이면 살짝만 비켜나 자리만 양보하는 정도로 보인다.

알림 벨을 다는 화면들 — 대시보드 헤더·메신저·작업 큐

01은 대시보드 상단의 알림 벨, 02·06은 메신저의 날짜별 알림함, 04·05는 처리·스누즈 액션이 잦은 작업 큐 알림 목록에 어울린다. 07의 빈 상태 문구는 알림이 전부 처리된 작업 큐 화면에서, 09의 탭 전환은 전체·멘션·시스템처럼 알림 종류가 나뉘는 고객센터 콘솔에 붙이면 자연스럽다. 이런 웹 알림 ui 구성에서 03의 점→고리 표시는 항목 하나씩 읽음 여부를 확인해야 하는 좁은 사이드바 알림함에 특히 맞는다. p3z5t2dn 으로 여는 zip 속 아홉 개 원본에는 이 화면별 계산 순서가 그대로 남아 있어, 값만 옮기지 말고 리플로우·실측 절차까지 함께 옮겨야 한다.

아홉 항목의 실측값 — 누적 면적·강도·움직인 프레임

측정은 자동 재생 24프레임을 캡처해 냈고, 표의 움직인프레임은 그중 비교 가능한 23프레임 기준이다.

항목 이름 누적면적% 강도평균 움직인프레임
01 뱃지 펄스 오픈 34.3 95.8 10/23
02 그룹 접힘 29.1 179.9 9/23
03 점-고리 읽음 23.1 42.7 18/23
04 스와이프 해제 38.5 105.1 6/23
05 스누즈 슬라이드 41.2 178.2 11/23
06 전체 읽음 번짐 14.6 75.1 14/23
07 빈 상태 크로스페이드 6.2 71.1 8/23
08 더보기 스태거 29.6 73.4 10/23
09 탭 필터 크로스페이드 20.8 64.0 11/23

05번 스누즈 슬라이드가 누적면적 41.2%로 가장 넓게 움직였고, 02번 그룹 접힘은 강도평균 179.9로 프레임 사이 픽셀 변화가 가장 크다. 07번 빈 상태 크로스페이드는 문구 하나만 자리를 바꾸는 구성이라 누적면적이 6.2%로 아홉 개 중 가장 작다.

토스트 048편·메가메뉴 266편과 갈라지는 지점

048번 웹 토스트 알림 CSS 애니메이션 9종은 화면 가장자리에 잠깐 떴다 사라지는 알림 한 장을 다루고, 이번 9종은 그 알림들이 쌓여 있는 패널 자체를 열고 접고 거르는 동작을 다룬다는 점이 다르다. 266번 메가 메뉴 ui 9종은 클릭 없이 호버만으로 펼쳐지는 사이트 지도 구조를 다루고, 이번 9종은 개수·순번·거리 같은 값이 직접 애니메이션 계산에 들어간다는 점에서 결이 다르다. 268번 알림 목록 ui 9가지는 사이드바에 고정된 활동 피드 목록의 레이아웃을 다루고, 이번 9종은 같은 목록 안에서 뱃지·스와이프·스누즈·탭 전환처럼 손으로 조작하는 동작 하나하나에 집중한다.

웹 알림 ui 예제는 css 알림 애니메이션 말고 어디서 더 보나?

구조(CSS transitions)와 접근성(prefers-reduced-motion, aria-live)의 기준을 먼저 MDN 문서로 확인한 뒤, 아홉 개 원본을 열어 같은 규격이 실제로 어떻게 구현됐는지 비교해 보는 편이 정확하다. 순서를 촘촘히 보고 싶다면 requestAnimationFrame 문서로 08·09의 이중 rAF 처리를 먼저 짚고 오면 소스가 더 빨리 읽힌다.

알림 뱃지 zip 을 열기 전에 자주 나오는 질문

css 알림 애니메이션과 알림 팝업 디자인은 어떻게 다른가요?

이 아홉 개는 화면 한쪽에 고정된 패널 안에서 뱃지·목록·탭이 움직이는 css 알림 애니메이션이고, 흔히 말하는 알림 팝업 디자인은 화면 중앙이나 가장자리에 잠깐 떴다 사라지는 토스트·모달 쪽에 가깝다. 겹치는 부분은 03·07처럼 값 하나가 바뀔 때 모양을 다시 그리는 계산 방식이다.

reduced-motion 환경에서는 어떻게 동작하나요?

반복형 지연과 펄스는 시간 값이 0으로 낮아져 즉시 끝 상태로 바뀌고, 뱃지 숫자·읽음 값·필터 결과 같은 계산 자체는 그대로 진행돼 화면에 반영된다. 시각적인 과정만 생략될 뿐 기능은 남는다.

아홉 개 중 어디부터 적용해 보면 좋을까요?

알림 벨이 이미 있는 화면이라면 01의 뱃지 펄스부터 붙여 보고, 목록이 길어지는 화면이라면 06의 전체 읽음 번짐과 07의 빈 상태 전환을 함께 적용하는 편이 자연스럽다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.