05되돌리기 토스트
삭제 문구 옆 실행 취소 버튼을 누르면 타이머가 멈추고 문구가 취소됨으로 바뀌며, 누르지 않으면 위에서부터 접히며 사라진다.
쓰임메일·메시지 삭제, 리스트 항목 삭제 확인 대신 쓰는 실행 취소
scaleYclearTimeout4초

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">항목을 삭제했습니다</p>
<button class="toast__undo" type="button">실행 취소</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base; // 300ms — 접히며 사라지는 시간
$easing: $ease-out;
$color: $subject-blue-deep; // 실행 취소 글자 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.toast {
box-sizing: border-box;
display: flex; align-items: center; justify-content: space-between; gap: $sp-4;
width: min(300px, 88vw);
padding: $sp-4;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-raised;
transform-origin: top center; // 규칙8: 위 변에서 접혀 사라짐
}
.toast__text { margin: 0; font-weight: 600; font-size: 13px; line-height: 1.4; font-family: inherit; }
.toast__undo {
flex: none;
border: 0; background: transparent;
color: $color; font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit;
padding: $sp-2 $sp-1;
cursor: pointer;
&:hover, &:focus-visible { text-decoration: underline; }
}
.toast.is-cancelled .toast__undo { visibility: hidden; }
// 실측 2026-09-10: 05번은 "실행취소" 버튼이 본체다. 데모는 등장 → 유지 → (취소 없이) 위에서부터 접히며 사라지는
// 한 바퀴를 반복한다 — scaleY 로 접는다(height 는 애니메이션하지 않는다, 원칙 1)
.toast.is-demo {
animation: undo-loop $dur-loop $easing infinite;
}
@keyframes undo-loop {
0% { transform: translateY(16px) scaleY(1); opacity: 0; }
15% { transform: translateY(0) scaleY(1); opacity: 1; }
70% { transform: translateY(0) scaleY(1); opacity: 1; }
92% { transform: translateY(0) scaleY(.35); opacity: .3; }
100% { transform: translateY(0) scaleY(0); opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 접히지 않고 보이다 사라진다(opacity만) ────────
@media (prefers-reduced-motion: reduce) {
.toast.is-demo { animation: none; transform: none; opacity: 1; }
}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);
}
.toast {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: min(300px, 88vw);
padding: 16px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top center;
}
.toast__text {
margin: 0;
font-weight: 600;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
}
.toast__undo {
flex: none;
border: 0;
background: transparent;
color: #0a4fe8;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
padding: 8px 4px;
cursor: pointer;
}
.toast__undo:hover, .toast__undo:focus-visible {
text-decoration: underline;
}
.toast.is-cancelled .toast__undo {
visibility: hidden;
}
.toast.is-demo {
animation: undo-loop 2s ease-out infinite;
}
@keyframes undo-loop {
0% {
transform: translateY(16px) scaleY(1);
opacity: 0;
}
15% {
transform: translateY(0) scaleY(1);
opacity: 1;
}
70% {
transform: translateY(0) scaleY(1);
opacity: 1;
}
92% {
transform: translateY(0) scaleY(0.35);
opacity: 0.3;
}
100% {
transform: translateY(0) scaleY(0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.toast.is-demo {
animation: none;
transform: none;
opacity: 1;
}
}// 실제 동작: 4초 뒤 사라지지만, 그 전에 "실행 취소"를 누르면 타이머를 멈추고 문구를 바꾼다
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');
});
});import { useCallback, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UndoToast.module.scss";
export type UndoToastProps = {
message: string;
undoLabel?: string;
cancelledMessage?: string;
onExpire?: () => void;
/** 몇 ms 뒤 자동으로 onExpire 를 부를지 (기본 4000ms) */
timeout?: number;
color?: string;
};
type Vars = CSSProperties & { "--color"?: string };
export default function UndoToast({
message,
undoLabel = "실행 취소",
cancelledMessage = "취소되었습니다",
onExpire,
timeout = 4000,
color = "#0a4fe8",
}: UndoToastProps) {
const [cancelled, setCancelled] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
if (timer.current === null && onExpire) {
timer.current = setTimeout(onExpire, timeout);
}
const undo = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
setCancelled(true);
}, []);
const vars: Vars = { "--color": color };
return (
<div className={styles.toast} style={vars} role="status" aria-live="polite">
<p className={styles.text}>{cancelled ? cancelledMessage : message}</p>
{!cancelled && (
<button className={styles.undo} type="button" onClick={undo}>
{undoLabel}
</button>
)}
</div>
);
}05 되돌리기 토스트 — 측정 누적면적 14.713% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 웹 토스트 알림 CSS 애니메이션 9종 — JS 없이, CSS만으로
이 데모가 속한 글
웹 토스트 알림 CSS 애니메이션 9종 — JS 없이, CSS만으로웹 토스트 알림은 화면 한쪽에 잠깐 떴다가 스스로 사라지는 작은 알림 카드로, 사용자가 지금 보던 화면을 벗어나지 않아도 결과를 알 수 있게 해 줍니다. 슬라이드로 들어오는 것부터 스와이프로 미는 것, 진행바가 다 이 글의 데모 전부 보기


