웹 토스트 알림 CSS 애니메이션 9종 — JS 없이, CSS만으로
웹 토스트 알림은 화면 한쪽에 잠깐 떴다가 스스로 사라지는 작은 알림 카드로, 사용자가 지금 보던 화면을 벗어나지 않아도 결과를 알 수 있게 해 줍니다. 슬라이드로 들어오는 것부터 스와이프로 미는 것, 진행바가 다 닳으면 닫히는 것까지 아홉 가지를 모았습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 오른쪽 슬라이드 인
- 02 Popover API 토스트
- 03 3개 스택
- 04 남은 시간 바
- 05 되돌리기 토스트
- 06 스와이프로 닫기
- 07 성공 아이콘
- 08 모바일 하단 토스트
- 09 알림 배지
아홉 개는 인기 순이 아니라 토스트 하나가 화면에서 겪는 순서로 놓았습니다. 눈에 띄는 등장 방식(01~03), 스스로 정리되는 법(04~06), 상태를 색과 모양으로 말하는 법(07~09) 순서입니다.
01오른쪽 슬라이드 인
카드가 화면 오른쪽 밖에서 translateX로 미끄러져 들어와 잠깐 머물다 같은 방향으로 빠져나갑니다. 저장·업로드 완료처럼 코너에 붙는 알림에 가장 기본적으로 씁니다.
.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 없이 얻습니다. 기본 스타일이 화면 중앙 고정이라 코너 위치는 직접 덮어써야 합니다.
<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 하면 부채꼴로 펼쳐져 각각 읽힙니다. 이벤트가 짧은 시간에 몰리는 대시보드 알림함에 어울립니다.
.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로 등속으로 줄어들다가 다 닳는 순간과 같은 타이밍에 토스트가 사라집니다. 업로드·전송 완료처럼 정해진 시간 뒤 자동으로 닫혀야 하는 알림에 씁니다.
.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되돌리기 토스트
삭제 문구 옆 실행 취소 버튼을 누르면 타이머가 멈추고 문구가 취소됨으로 바뀌며, 누르지 않으면 위에서부터 접히며 사라집니다. 리스트 항목 삭제 확인 대신 쓰기 좋습니다.
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 이상 밀고 떼면 그 방향으로 빠져나가며 닫힙니다. 터치 위주의 모바일 채팅·메신저 알림함에 맞는 방식입니다.
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 색으로 바꾸면 그대로 실패 배지가 됩니다.
.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모바일 하단 토스트
화면 하단에 고정된 알약형 바가 안전영역을 피해 위로 살짝 올라왔다가 다시 내려갑니다. 손잡이 달린 바텀시트와 달리 한 줄만 보여주고 자동으로 닫힙니다.
.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알림 배지
종 아이콘이 꼭짓점을 축으로 짧게 흔들리는 동시에 오른쪽 위 배지가 확대·축소되며 새 알림이 왔다는 한 순간을 만듭니다. 헤더의 읽지 않은 메시지 수 표시에 씁니다.
.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); }
}
어디서 깨지는가 — 함정 하나
아홉 개가 전부 transform과 opacity만 움직이는 이유는 같습니다. width나 top을 애니메이션하면 브라우저가 매 프레임 레이아웃을 다시 계산하기 때문입니다. 그런데 레이어를 겹치는 항목에서 다른 함정을 하나 만났습니다. 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: fixed와 top·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 스와이프로 닫기에서는 pointerdown 때 setPointerCapture를 걸지 않으면 손가락이 카드 경계를 벗어나는 순간 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를 추가하면 됩니다.