05本当に元に戻すボタン
一覧から一行を消すとトーストが出ている間その行が消え、元に戻すを押すと予約された削除が取り消されて行が元の位置に開き直します。
使いどころ確認ダイアログの代わりに取り消しを出す削除の流れ
clearTimeoutgrid-template-rowsaria-live

<main class="stage">
<div class="un is-demo">
<ul class="un__list" id="un-list" aria-label="ファイル一覧">
<li class="un__row"><span class="un__in">
<span class="un__name">2月請求書.pdf</span>
<button class="un__del" type="button" aria-label="この行を削除">
<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">会議メモ.md</span>
<button class="un__del" type="button" aria-label="この行を削除">
<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">ロゴ最終.png</span>
<button class="un__del" type="button" aria-label="この行を削除">
<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">項目を削除しました</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>
元に戻す
</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;
}
}// 실제 동작: 지운 줄은 割り込む 없애지 않고 접어만 둔다. 되돌리기를 누르면 예약된 삭제를 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() { // 元に戻す 없이 시간이 다 가면 그때 진짜로 지운다
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 本当に元に戻すボタン — 計測 累積面積 26.956% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · トースト通知 UI 9選 — 積み上がるスタック実装
このデモが属する記事
トースト通知 UI 9選 — 積み上がるスタック実装トースト通知 ui とは、画面の隅に一瞬だけ出て消える知らせをどう積み、どう片づけるかを決める仕組みのことです。9つともライブラリなしでバニラ JS と SCSS だけで動き、最初の1つは新しい通知が前のカードを押し上げる動きだけを扱います この記事のデモをすべて見る


