05Toast hoàn tác
Bấm nút hoàn tác cạnh dòng chữ xoá thì JS huỷ bộ đếm giờ và đổi chữ báo đã huỷ, không bấm thì sau 4 giây toast tự cuộn lại từ trên xuống bằng scaleY.
Dùng choXoá email, tin nhắn; dùng hoàn tác thay cho hộp xác nhận khi xoá một dòng
scaleYclearTimeout4초

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">Đã xoá mục</p>
<button class="toast__undo" type="button">Hoàn tác</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초 뒤 사라지지만, 그 전에 "Hoàn tác"를 누르면 타이머를 멈추고 문구를 바꾼다
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 = 'Đã huỷ';
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 Toast hoàn tác — Diện tích đo được 14.713% · khung hình chuyển động 11/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 CSS Toast Notification — không cần JS
Bài viết chứa demo này
9 CSS Toast Notification — không cần JSCSS toast notification là thẻ thông báo nhỏ tự nổi lên một góc màn hình rồi tự đóng, dựng bằng CSS thuần, không cần JS. Xem tất cả demo trong bài này


