GODRICH

웹 토스트 알림 CSS 애니메이션 9종 — JS 없이, CSS만으로

웹 토스트 알림은 화면 한쪽에 잠깐 떴다가 스스로 사라지는 작은 알림 카드로, 사용자가 지금 보던 화면을 벗어나지 않아도 결과를 알 수 있게 해 줍니다. 슬라이드로 들어오는 것부터 스와이프로 미는 것, 진행바가 다 닳으면 닫히는 것까지 아홉 가지를 모았습니다.

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

아홉 개는 인기 순이 아니라 토스트 하나가 화면에서 겪는 순서로 놓았습니다. 눈에 띄는 등장 방식(01~03), 스스로 정리되는 법(04~06), 상태를 색과 모양으로 말하는 법(07~09) 순서입니다.

01오른쪽 슬라이드 인

카드가 화면 오른쪽 밖에서 translateX로 미끄러져 들어와 잠깐 머물다 같은 방향으로 빠져나갑니다. 저장·업로드 완료처럼 코너에 붙는 알림에 가장 기본적으로 씁니다.

translateX0.6초코너 고정
.toast {
  position: relative;
  transform: translateX(140%);
  opacity: 0;
}
.toast.is-demo { animation: slide-loop $dur-loop $easing infinite; }
@keyframes slide-loop {
  0%   { transform: translateX(140%); opacity: 0; }
  18%  { transform: translateX(0); opacity: 1; }
  78%  { transform: translateX(0); opacity: 1; }
  100% { transform: translateX(140%); opacity: 0; }
}

02Popover API 토스트

popover 속성과 popovertarget 버튼만으로 여닫혀, top-layer 스택과 ESC·바깥 클릭 닫기를 JS 없이 얻습니다. 기본 스타일이 화면 중앙 고정이라 코너 위치는 직접 덮어써야 합니다.

popoverpopovertargetshowPopover()
<div id="ptoast" class="toast" popover role="status" aria-live="polite">
  <span class="toast__dot" aria-hidden="true"></span>
  <p class="toast__title">연결됨</p>
  <button popovertarget="ptoast" popovertargetaction="hide" aria-label="알림 닫기">
    <svg viewBox="0 0 24 24" width="14" height="14"><line x1="5" y1="5" x2="19" y2="19"/><line x1="19" y1="5" x2="5" y2="19"/></svg>
  </button>
</div>
<script>document.getElementById('ptoast').showPopover();</script>

033개 스택

알림 3개가 평소엔 살짝 겹친 덱으로 접혀 있다가 hover나 focus 하면 부채꼴로 펼쳐져 각각 읽힙니다. 이벤트가 짧은 시간에 몰리는 대시보드 알림함에 어울립니다.

CSS 변수 --ihover·focus-within덱 펼치기
.stack__item {
  position: absolute; inset: 0;
  transform: translateY(calc(var(--i) * -6px))
             scale(calc(1 - var(--i) * .05));
  opacity: calc(1 - var(--i) * .22);
}
.stack:hover .stack__item,
.stack:focus-within .stack__item {
  transform: translateY(calc(var(--i) * -22px)) scale(1);
  opacity: 1;
  transition: transform $duration $easing, opacity $duration $easing;
}

04남은 시간 바

바닥의 진행바가 scaleX로 등속으로 줄어들다가 다 닳는 순간과 같은 타이밍에 토스트가 사라집니다. 업로드·전송 완료처럼 정해진 시간 뒤 자동으로 닫혀야 하는 알림에 씁니다.

scaleXlinearanimationend
.toast__bar {
  position: absolute; left: 0; bottom: 0;
  width: 100%; height: 4px;
  background: $color;
  transform-origin: left;
}
.toast.is-demo .toast__bar { animation: bar-shrink $dur-loop linear infinite; }
@keyframes bar-shrink {
  0%   { transform: scaleX(1); }
  82%  { transform: scaleX(0); }
  100% { transform: scaleX(0); }
}

05되돌리기 토스트

삭제 문구 옆 실행 취소 버튼을 누르면 타이머가 멈추고 문구가 취소됨으로 바뀌며, 누르지 않으면 위에서부터 접히며 사라집니다. 리스트 항목 삭제 확인 대신 쓰기 좋습니다.

scaleYclearTimeout4초
document.querySelectorAll('.toast').forEach(function (t) {
  var btn = t.querySelector('.toast__undo'), msg = t.querySelector('.toast__text');
  var timer = setTimeout(function () { t.classList.add('is-gone'); }, 4000);
  btn.addEventListener('click', function () {
    clearTimeout(timer);
    msg.textContent = '취소되었습니다';
    t.classList.add('is-cancelled');
  });
});

06스와이프로 닫기

포인터를 누른 채 좌우로 끌면 손가락을 따라 카드가 움직이고, 80px 이상 밀고 떼면 그 방향으로 빠져나가며 닫힙니다. 터치 위주의 모바일 채팅·메신저 알림함에 맞는 방식입니다.

pointermovetranslateX(var(--dx))80px 문턱
el.addEventListener('pointerdown', function (e) {
  x0 = e.clientX; dragging = true; el.setPointerCapture(e.pointerId);
});
el.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  dx = e.clientX - x0; el.style.setProperty('--dx', dx + 'px');
});
el.addEventListener('pointerup', function () {
  dragging = false;
  if (Math.abs(dx) > 80) el.classList.add(dx > 0 ? 'is-swiped-right' : 'is-swiped-left');
  else el.style.setProperty('--dx', '0px');
});

07성공 아이콘

무채색 링 아이콘이 opacity로 사라지는 동시에 상태색 링과 체크가 겹쳐 드러나, 색 자체가 결과를 말합니다. $color를 error 색으로 바꾸면 그대로 실패 배지가 됩니다.

opacity 크로스페이드stroke-dashoffset상태색 변수
.badge__neutral { opacity: .3; }
.badge__colored { opacity: 0; }
.badge__check { stroke-dasharray: 100; stroke-dashoffset: 100; }

.toast.is-demo .badge__neutral { animation: fade-out $dur-loop $easing infinite; }
.toast.is-demo .badge__colored { animation: fade-in $dur-loop $easing infinite; }
.toast.is-demo .badge__check   { animation: draw $dur-loop $easing infinite; }

08모바일 하단 토스트

화면 하단에 고정된 알약형 바가 안전영역을 피해 위로 살짝 올라왔다가 다시 내려갑니다. 손잡이 달린 바텀시트와 달리 한 줄만 보여주고 자동으로 닫힙니다.

safe-area-inset-bottomtranslateY$r-pill
.toast {
  position: fixed;
  left: 16px; right: 16px;
  bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
  margin: 0 auto;
  max-width: 340px;
  border-radius: 999px;
  transform: translateY(140%);
  opacity: 0;
}
.toast.is-demo { animation: bottom-loop $dur-loop $easing infinite; }

09알림 배지

종 아이콘이 꼭짓점을 축으로 짧게 흔들리는 동시에 오른쪽 위 배지가 확대·축소되며 새 알림이 왔다는 한 순간을 만듭니다. 헤더의 읽지 않은 메시지 수 표시에 씁니다.

transform-box: fill-boxrotatescale
.bell__body {
  transform-box: fill-box;
  transform-origin: 50% 0%;
}
.bell.is-demo .bell__body { animation: shake $dur-loop $easing infinite; }
.bell.is-demo .badge       { animation: pulse $dur-loop $easing infinite; }
@keyframes shake {
  0%, 55%, 100% { transform: rotate(0deg); }
  60% { transform: rotate(-14deg); }
  65% { transform: rotate(12deg); }
  70% { transform: rotate(-8deg); }
}

어디서 깨지는가 — 함정 하나

아홉 개가 전부 transformopacity만 움직이는 이유는 같습니다. widthtop을 애니메이션하면 브라우저가 매 프레임 레이아웃을 다시 계산하기 때문입니다. 그런데 레이어를 겹치는 항목에서 다른 함정을 하나 만났습니다. 07 성공 아이콘은 무채색 링과 상태색 링, 두 개의 SVG를 같은 자리에 겹쳐 opacity로 바꿔치기하는데, 두 레이어에 grid-area: 1 / 1을 안 주면 세로로 나란히 쌓여 배지 전체 높이가 두 배로 늘어납니다. 03 3개 스택도 비슷한 함정이 있습니다. 카드마다 --i 값을 style.scss 안에 고정으로 적으면 세 장이 똑같은 값을 받아 완전히 겹쳐 보이므로, 반드시 style="--i:0"처럼 요소마다 인라인으로 다르게 줘야 합니다.

01 오른쪽 슬라이드 인은 translateX(100%)가 아니라 140%를 씁니다. 카드에 box-shadow가 붙어 있어서, 카드 폭만큼만 밀어내면 그림자 끄트머리가 화면 오른쪽 경계에 아직 걸쳐 보이기 때문입니다.

02 Popover API 토스트에서도 한 번 걸렸습니다. popover 속성의 기본 UA 스타일은 화면 가운데 고정이라, position: fixedtop·right·margin을 전부 덮어쓰지 않으면 코너가 아니라 한가운데 떠 버립니다. Chrome·Edge 114+, Safari 17+, Firefox 125+에서 Popover API를 쓸 수 있고(caniuse), 그보다 오래된 브라우저에서는 popover 속성 자체가 무시되면서 평범한 <div>로 남기 때문에 별도 JS로 보이기·숨기기를 대신 걸어야 합니다. 08 모바일 하단 토스트에서 쓴 env(safe-area-inset-bottom)도 마찬가지로, iOS Safari 11.2+부터(caniuse) 실제 노치·홈 인디케이터 값을 돌려주고, 그 기능이 없는 브라우저에서는 calc()의 폴백값 0px이 조용히 적용됩니다. 06 스와이프로 닫기에서는 pointerdownsetPointerCapture를 걸지 않으면 손가락이 카드 경계를 벗어나는 순간 pointermove가 끊겨 드래그가 그대로 멈췄습니다. grid-area 하나, 인라인 --i 하나, 방금 나온 setPointerCapture 하나까지 세 군데를 다 고쳐서 렌더 측정까지 다시 돌려 둔 zip을 열 때 넣을 문자열은 vp67sjt7이며, 아래 버튼을 누르면 내려받을 수 있습니다.

접근성

아홉 개 전부 role="status" aria-live="polite"를 썼습니다. 화면을 읽는 사람에게는 알리되 지금 하던 일을 멈추게 하진 않는 수준이라서입니다. 로그인 실패처럼 사용자의 응답이 꼭 필요한 알림이라면 aria-live="assertive"로 올려 즉시 끼어들게 해야 합니다. 04 남은 시간 바처럼 스스로 닫히는 항목은 WCAG 2.2.1 Timing Adjustable이 시간 제한을 늘리거나 끌 방법을 요구합니다. 이 글의 04는 5초라는 최소 시간만 두었으므로, 실제로 쓸 때는 05처럼 실행 취소 버튼을 같이 둬서 타이머를 멈출 길을 마련해야 합니다. prefers-reduced-motion: reduce에서는 대부분 슬라이드·회전·펼침 애니메이션을 껐지만, 04의 진행바만은 예외로 남겼습니다. 진행바가 언제 닫힐지 알려 주는 기능적인 정보이지 장식이 아니기 때문입니다. prefers-reduced-motion이 정확히 어떤 값을 받고 어떻게 켜지는지는 MDN 문서에 정리돼 있습니다.

018편 바이브코딩 마이크로 인터랙션의 토스트 쌓임은 새 토스트가 올 때마다 기존 걸 밀어 올리는 큐라서, 여기 03 3개 스택(고정된 덱이 hover로 펼쳐지는 정적 스택)과는 방향이 반대입니다. 003편 모달 애니메이션의 바텀 시트도 손잡이 달린 큰 패널이라, 한 줄짜리 알약형이 안전영역을 피해 자동으로 닫히는 08 모바일 하단 토스트와는 쓰임이 다릅니다. 이 사이트의 다른 CSS 애니메이션 글은 카테고리 목록에서 훑어볼 수 있고, 무엇을 만드는 곳인지는 소개 페이지에 적어 뒀습니다.

FAQ

토스트는 role="status"인가요, role="alert"인가요?

대부분의 토스트는 role="status" aria-live="polite"가 맞습니다. role="alert"(또는 aria-live="assertive")는 화면 읽기를 즉시 끊고 끼어드는 강도라서, 결제 실패나 세션 만료처럼 사용자가 반드시 지금 알아야 하는 알림에만 씁니다.

자동으로 닫히는 시간은 몇 초가 적당한가요?

정해진 답은 없지만 WCAG 2.2.1은 시간 제한을 늘리거나 끌 방법을 요구합니다. 이 글의 04는 5초를 기본값으로 두었을 뿐이므로, 실제로 쓸 때는 05처럼 실행 취소나 일시정지 버튼을 같이 둬서 다 읽기 전에 사라지는 상황을 막아야 합니다.

Popover API를 아직 못 쓰는 브라우저는 어떻게 하나요?

popover 속성이 없는 브라우저에서는 그냥 평범한 <div>로 남으므로, if ('showPopover' in document.createElement('div'))로 지원 여부를 확인하고 지원하지 않을 때만 직접 클래스 토글로 열고 닫는 JS를 추가하면 됩니다.

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

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