05An Undo button that really undoes
Deleting a row makes it disappear while the toast is up, and pressing Undo cancels the scheduled removal so the row unfolds back into its old slot.
Best forDelete flows that offer undo instead of a confirm dialog
clearTimeoutgrid-template-rowsaria-live

<main class="stage">
<div class="un is-demo">
<ul class="un__list" id="un-list" aria-label="File list">
<li class="un__row"><span class="un__in">
<span class="un__name">february-invoice.pdf</span>
<button class="un__del" type="button" aria-label="Delete this row">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>
</button>
</span></li>
<li class="un__row"><span class="un__in">
<span class="un__name">meeting-notes.md</span>
<button class="un__del" type="button" aria-label="Delete this row">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>
</button>
</span></li>
<li class="un__row"><span class="un__in">
<span class="un__name">logo-final.png</span>
<button class="un__del" type="button" aria-label="Delete this row">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>
</button>
</span></li>
</ul>
<div class="un__slot">
<div class="un__toast" id="un-toast">
<span class="un__msg" id="un-msg" role="status" aria-live="polite">Item deleted</span>
<button class="un__undo" type="button" id="un-undo">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg>
Undo
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.un {
box-sizing: border-box;
width: min(396px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.un__list {
box-sizing: border-box;
height: 108px;
margin: 0;
padding: 0;
list-style: none;
}
// 줄 접기 — grid-template-rows 1fr → 0fr 은 높이를 안다는 뜻이라 접힌 뒤 아래 줄이 자연스럽게 올라온다
.un__row {
display: grid;
grid-template-rows: 1fr;
margin: 0;
padding: 0;
transition: grid-template-rows $duration $easing, opacity $dur-quick linear;
}
.un__row.is-gone { grid-template-rows: 0fr; opacity: 0; }
.un__in {
display: flex;
align-items: center;
gap: $sp-2;
overflow: hidden;
min-height: 0;
margin: 0 0 $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .07);
}
.un__name {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.un__del {
display: inline-flex;
flex: 0 0 auto;
margin: 0;
padding: $sp-1;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .1);
color: $subject-cream;
cursor: pointer;
}
.un__del svg { display: block; }
.un__del:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 토스트 자리 — 높이를 미리 잡아 두어야 토스트가 뜰 때 위 목록이 밀리지 않는다
.un__slot {
position: relative;
overflow: hidden;
height: 44px;
}
.un__toast {
position: absolute;
left: 0;
right: 0;
bottom: 4px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-2 $sp-2 $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-press;
transform: translateY(48px);
transition: transform $duration $easing;
}
.un__toast.is-on { transform: translateY(0); }
.un__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.un__undo {
display: inline-flex;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $color;
font-family: inherit;
font-size: 11px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.un__undo svg { display: block; }
.un__undo:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 자동 루프: 가운데 줄이 접히고 → 토스트가 올라오고 → 되돌리기가 눌려 → 줄이 도로 펴진다 ──
.un.is-demo .un__row { transition: none; }
.un.is-demo .un__row:nth-child(2) {
animation-name: un-row;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes un-row {
0%, 18% { grid-template-rows: 1fr; opacity: 1; }
30% { grid-template-rows: 0fr; opacity: 0; }
62% { grid-template-rows: 0fr; opacity: 0; }
76% { grid-template-rows: 1fr; opacity: 1; }
90%, 100% { grid-template-rows: 1fr; opacity: 1; }
}
.un.is-demo .un__toast {
transition: none;
animation-name: un-toast;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes un-toast {
0%, 20% { transform: translateY(48px); }
32% { transform: translateY(0); }
66% { transform: translateY(0); }
80% { transform: translateY(48px); }
90%, 100% { transform: translateY(48px); }
}
// 되돌리기가 눌리는 순간 — 줄이 다시 펴지기 직전이다
.un.is-demo .un__undo {
animation-name: un-press;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes un-press {
0%, 56% { transform: scale(1); }
62% { transform: scale(.84); }
70% { transform: scale(1); }
90%, 100% { transform: scale(1); }
}
@media (max-width: 340px) {
.un__list { height: 90px; }
.un__in { margin: 0 0 $sp-1; padding: $sp-1 $sp-2; }
.un__name { font-size: 11px; line-height: 16px; }
.un__slot { height: 36px; }
.un__toast { gap: $sp-2; padding: $sp-1 $sp-1 $sp-1 $sp-3; }
.un__msg { font-size: 11px; line-height: 18px; }
.un__undo { padding: 0 $sp-2; font-size: 10px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.un.is-demo .un__row:nth-child(2),
.un.is-demo .un__toast,
.un.is-demo .un__undo { animation: none !important; }
.un.is-demo .un__toast { transform: translateY(0); }
.un__row,
.un__toast { transition: none; }
}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);
}
.un {
box-sizing: border-box;
width: min(396px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.un__list {
box-sizing: border-box;
height: 108px;
margin: 0;
padding: 0;
list-style: none;
}
.un__row {
display: grid;
grid-template-rows: 1fr;
margin: 0;
padding: 0;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms linear;
}
.un__row.is-gone {
grid-template-rows: 0fr;
opacity: 0;
}
.un__in {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
min-height: 0;
margin: 0 0 8px;
padding: 8px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.07);
}
.un__name {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.un__del {
display: inline-flex;
flex: 0 0 auto;
margin: 0;
padding: 4px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
cursor: pointer;
}
.un__del svg {
display: block;
}
.un__del:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.un__slot {
position: relative;
overflow: hidden;
height: 44px;
}
.un__toast {
position: absolute;
left: 0;
right: 0;
bottom: 4px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 8px 8px 16px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(48px);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.un__toast.is-on {
transform: translateY(0);
}
.un__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.un__undo {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #4cd4a6;
font-family: inherit;
font-size: 11px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.un__undo svg {
display: block;
}
.un__undo:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.un.is-demo .un__row {
transition: none;
}
.un.is-demo .un__row:nth-child(2) {
animation-name: un-row;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes un-row {
0%, 18% {
grid-template-rows: 1fr;
opacity: 1;
}
30% {
grid-template-rows: 0fr;
opacity: 0;
}
62% {
grid-template-rows: 0fr;
opacity: 0;
}
76% {
grid-template-rows: 1fr;
opacity: 1;
}
90%, 100% {
grid-template-rows: 1fr;
opacity: 1;
}
}
.un.is-demo .un__toast {
transition: none;
animation-name: un-toast;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes un-toast {
0%, 20% {
transform: translateY(48px);
}
32% {
transform: translateY(0);
}
66% {
transform: translateY(0);
}
80% {
transform: translateY(48px);
}
90%, 100% {
transform: translateY(48px);
}
}
.un.is-demo .un__undo {
animation-name: un-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes un-press {
0%, 56% {
transform: scale(1);
}
62% {
transform: scale(0.84);
}
70% {
transform: scale(1);
}
90%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.un__list {
height: 90px;
}
.un__in {
margin: 0 0 4px;
padding: 4px 8px;
}
.un__name {
font-size: 11px;
line-height: 16px;
}
.un__slot {
height: 36px;
}
.un__toast {
gap: 8px;
padding: 4px 4px 4px 12px;
}
.un__msg {
font-size: 11px;
line-height: 18px;
}
.un__undo {
padding: 0 8px;
font-size: 10px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.un.is-demo .un__row:nth-child(2),
.un.is-demo .un__toast,
.un.is-demo .un__undo {
animation: none !important;
}
.un.is-demo .un__toast {
transform: translateY(0);
}
.un__row,
.un__toast {
transition: none;
}
}// 실제 동작: 지운 줄은 cuts in 없애지 않고 접어만 둔다. 되돌리기를 누르면 예약된 삭제를 clearTimeout 으로 취소하고 다시 편다
var root = document.querySelector('.un');
var toast = document.getElementById('un-toast');
var timer = 0, pending = null;
function drop(row) {
root.classList.remove('is-demo');
if (pending) commit();
pending = row;
row.classList.add('is-gone');
toast.classList.add('is-on');
clearTimeout(timer);
timer = setTimeout(commit, 4000);
}
function commit() { // Undo 없이 시간이 다 가면 그때 진짜로 지운다
clearTimeout(timer);
if (pending) pending.remove();
pending = null;
toast.classList.remove('is-on');
}
function undo() {
clearTimeout(timer);
if (pending) pending.classList.remove('is-gone');
pending = null;
toast.classList.remove('is-on');
}
[].forEach.call(document.querySelectorAll('.un__del'), function (b) {
b.addEventListener('click', function () { drop(b.closest('.un__row')); });
});
document.getElementById('un-undo').addEventListener('click', undo);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UndoToast.module.scss";
export type UndoToastProps = {
files?: string[];
listLabel?: string;
deleteLabel?: string;
message?: string;
undoLabel?: string;
grace?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function UndoToast({
files = ["february-invoice.pdf", "meeting-notes.md", "logo-final.png"],
listLabel = "File list",
deleteLabel = "Delete this row",
message = "Item deleted",
undoLabel = "Undo",
grace = 4000,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: UndoToastProps) {
const [demo, setDemo] = useState(true);
const [rows, setRows] = useState<string[]>(files);
const [gone, setGone] = useState<string>("");
const timer = useRef<number[]>([0]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => () => { clearTimeout((timer.current ?? [0])[0]); }, []);
function drop(name: string) {
setDemo(false);
setGone(name);
const t = timer.current ?? [0];
clearTimeout(t[0]);
// 시간이 다 가면 그때 진짜로 목록에서 뺀다 — 그 전까지는 접혀만 있다
t[0] = setTimeout(() => { setRows((v) => v.filter((n) => n !== name)); setGone(""); }, grace) as unknown as number;
}
function undo() {
clearTimeout((timer.current ?? [0])[0]); // 예약된 삭제를 취소하면 줄이 그대로 살아 있다
setGone("");
}
return (
<div className={`${styles.un} ${demo ? styles.isDemo : ""}`} style={vars}>
<ul className={styles.list} aria-label={listLabel}>
{rows.map((name, i) => (
<li key={name} className={`${styles.row} ${gone === name ? styles.isGone : ""}`} data-nth={i}>
<span className={styles.in}>
<span className={styles.name}>{name}</span>
<button className={styles.del} type="button" aria-label={deleteLabel} onClick={() => drop(name)}>
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" /><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" /></g></svg>
</button>
</span>
</li>
))}
</ul>
<div className={styles.slot}>
<div className={`${styles.toast} ${gone ? styles.isOn : ""}`}>
<span className={styles.msg} role="status" aria-live="polite">{gone ? message : ""}</span>
<button className={styles.undo} type="button" onClick={undo}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" /><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128" /></g></svg>
{undoLabel}
</button>
</div>
</div>
</div>
);
}05 An Undo button that really undoes — Measured area 26.956% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · Toast Notification UI: 9 Stacking Patterns
The post this demo lives in
Toast Notification UI: 9 Stacking PatternsToast notification UI is what decides how those corner messages pile up and how they leave again. All nine run without a See all demos in this post


