Forms
Undo toast
A destructive action with no confirmation dialog: the row leaves the list on the press, the delete is held behind a toast, and it commits when the toast expires rather than when anything is clicked.
Reach for it when building
- removing tracks from a playlist
- deleting photos from an album
- archiving items from a list
- unfollowing or unsubscribing
- clearing entries from a watch list
- dismissing rows from a queue
- undo
- toast
- destructive
- aria-live
- reduced-motion
<div class="undo-demo">
<ul class="undo-list" id="undo-list"></ul>
<div class="undo-toast" id="undo-toast" role="status" hidden>
<span class="undo-toast-text" id="undo-toast-text"></span>
<span class="undo-held" id="undo-held" hidden>held</span>
<button type="button" class="undo-toast-btn" id="undo-undo">Undo</button>
<span class="undo-bar" id="undo-bar"></span>
</div>
<p class="undo-log" id="undo-log" role="status"></p>
<button type="button" class="undo-reset" id="undo-reset">Reset the demo</button>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.undo-demo { display: flex; flex-direction: column; gap: 0.75rem; max-width: 420px; }
.undo-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.undo-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--border);
}
.undo-row:last-child { border-bottom: none; }
.undo-title { font-weight: 600; }
.undo-sub {
display: block;
font-size: 0.8125rem;
color: var(--dim);
font-weight: 400;
}
.undo-remove {
font: inherit;
font-size: 0.875rem;
background: none;
border: 1px solid var(--border);
color: var(--dim);
border-radius: 6px;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.undo-remove:hover { color: var(--bad); border-color: var(--bad); }
.undo-empty {
padding: 1.25rem;
color: var(--dim);
text-align: center;
font-size: 0.9375rem;
}
.undo-toast {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.6rem 0.75rem;
position: relative;
overflow: hidden;
}
.undo-toast[hidden] { display: none; }
.undo-toast-text { flex: 1; font-size: 0.9375rem; }
.undo-held {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--signal);
}
.undo-held[hidden] { display: none; }
.undo-toast-btn {
font: inherit;
font-size: 0.875rem;
font-weight: 600;
background: var(--accent);
color: var(--accent-ink);
border: none;
border-radius: 6px;
padding: 0.3rem 0.75rem;
cursor: pointer;
}
/* The countdown is information, not decoration — it is the only thing
telling someone how long they still have — so it is driven in script
rather than by a CSS animation the reduced-motion rule would flatten. */
.undo-bar {
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: 100%;
background: var(--signal);
}
.undo-log { margin: 0; font-size: 0.875rem; color: var(--dim); min-height: 1.4em; }
.undo-reset {
font: inherit;
font-size: 0.875rem;
align-self: flex-start;
background: none;
border: 1px solid var(--border);
color: var(--dim);
border-radius: 6px;
padding: 0.25rem 0.6rem;
cursor: pointer;
}var HOLD_MS = 5000;
var seedTracks = [
{ id: 't1', title: 'Midnight in Tokyo', sub: 'Ayako Rin — 4:12' },
{ id: 't2', title: 'Slow Ferry', sub: 'The Harbour Lights — 3:38' },
{ id: 't3', title: 'Paper Radio', sub: 'Nils Aker — 5:01' },
{ id: 't4', title: 'Long Way Round', sub: 'Corbin & Dale — 4:47' }
];
var tracks = seedTracks.slice();
var listElement = document.getElementById('undo-list');
var toastElement = document.getElementById('undo-toast');
var toastTextElement = document.getElementById('undo-toast-text');
var heldElement = document.getElementById('undo-held');
var barElement = document.getElementById('undo-bar');
var logElement = document.getElementById('undo-log');
var undoButton = document.getElementById('undo-undo');
var resetButton = document.getElementById('undo-reset');
var pending = null;
var elapsed = 0;
var lastFrameTime = 0;
var isHeld = false;
var frameHandle = null;
function stopCountdown() {
if (frameHandle !== null) {
cancelAnimationFrame(frameHandle);
frameHandle = null;
}
}
function render() {
listElement.innerHTML = '';
if (tracks.length === 0) {
var emptyRow = document.createElement('li');
emptyRow.className = 'undo-empty';
emptyRow.textContent = 'Nothing left in this playlist.';
listElement.appendChild(emptyRow);
return;
}
tracks.forEach(function (track) {
var row = document.createElement('li');
row.className = 'undo-row';
var label = document.createElement('span');
label.className = 'undo-title';
label.textContent = track.title;
var subtitle = document.createElement('span');
subtitle.className = 'undo-sub';
subtitle.textContent = track.sub;
label.appendChild(subtitle);
var removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'undo-remove';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', function () {
remove(track.id);
});
row.appendChild(label);
row.appendChild(removeButton);
listElement.appendChild(row);
});
}
// The only place the delete actually happens. Reaching it means the window
// closed without an undo — never a click, never a dismissal.
function commit() {
if (pending === null) return;
logElement.textContent =
'Removed ' + pending.track.title + ' — the delete went through when the toast expired.';
pending = null;
stopCountdown();
toastElement.hidden = true;
}
function tick(now) {
if (pending === null) return;
if (!isHeld) elapsed += now - lastFrameTime;
lastFrameTime = now;
var remaining = Math.max(0, HOLD_MS - elapsed);
barElement.style.width = (remaining / HOLD_MS) * 100 + '%';
if (remaining <= 0) {
commit();
return;
}
frameHandle = requestAnimationFrame(tick);
}
function remove(id) {
// A second removal while one is still pending commits the first rather
// than dropping it: only one hold exists at a time, and silently losing
// the earlier delete would be the one unrecoverable outcome here.
if (pending !== null) commit();
var index = -1;
tracks.forEach(function (track, trackIndex) {
if (track.id === id) index = trackIndex;
});
if (index === -1) return;
var track = tracks[index];
tracks.splice(index, 1);
render();
pending = { track: track, index: index };
elapsed = 0;
isHeld = false;
heldElement.hidden = true;
toastTextElement.textContent = 'Removed ' + track.title;
toastElement.hidden = false;
barElement.style.width = '100%';
logElement.textContent = '';
stopCountdown();
lastFrameTime = performance.now();
frameHandle = requestAnimationFrame(tick);
}
function setHeld(held) {
isHeld = held;
heldElement.hidden = !held;
}
toastElement.addEventListener('mouseenter', function () { setHeld(true); });
toastElement.addEventListener('mouseleave', function () { setHeld(false); });
toastElement.addEventListener('focusin', function () { setHeld(true); });
toastElement.addEventListener('focusout', function () { setHeld(false); });
undoButton.addEventListener('click', function () {
if (pending === null) return;
tracks.splice(pending.index, 0, pending.track);
logElement.textContent =
'Put ' + pending.track.title + ' back at position ' + (pending.index + 1) + '.';
pending = null;
stopCountdown();
toastElement.hidden = true;
render();
});
resetButton.addEventListener('click', function () {
stopCountdown();
pending = null;
toastElement.hidden = true;
tracks = seedTracks.slice();
logElement.textContent = '';
render();
});
render();Paste this into an agent to rebuild the pattern from scratch.
Build a destructive action that asks for no confirmation. The row leaves the list the instant the button is pressed, the delete is held for a few seconds behind a toast offering Undo, and it commits when that window closes.
Reach for this when the action is reversible and people do it repeatedly — removing tracks from a playlist, deleting photos, archiving rows, unfollowing. A confirm dialog on every one of those costs a click each time to prevent a mistake that happens rarely and is cheap to fix, which is the wrong trade. Walk away when the action genuinely cannot be deferred: a payment, a send, an outbound API call, anything where the few seconds of holding are not yours to spend. Walk away too when the result is invisible, because someone who cannot see what changed does not know to reach for Undo.
The rule that makes it work: **commit on expiry, not on interaction.** The delete fires when the timer runs out, and nowhere else. Not when the toast is dismissed, not when it scrolls away, not when the next action starts. Wiring the commit to a dismissal is the common mistake and it turns an accidental swipe into an irreversible delete — the precise outcome the pattern exists to prevent.
Remove the row from the list immediately, before anything is confirmed. The toast is the only acknowledgement, and that is deliberate: an optimistic removal is what makes the interaction feel free, and holding the row in place until the timer ends would be slower than the dialog you just removed.
**Pause the countdown on hover and on focus, not just hover.** Someone reading the toast has stopped to decide, and the timer running out while they read is the failure mode. Focus matters as much as hover — a keyboard user tabbing to the Undo button gets no pause at all from a hover-only implementation, so the control they are reaching for can expire under them. Show that the hold is happening, with a word or a stalled bar; a frozen countdown with no explanation reads as broken.
**Restore to the original index, not to the top.** Keep the removed item's position alongside the item itself and splice it back in there. Undo means "as you were", and a row that reappears in the wrong place makes someone re-verify the whole list.
Drive the countdown in script rather than with a CSS animation. The remaining time is information — it is the only thing telling someone how long they still have — so a global reduced-motion rule that flattens animation durations must not flatten it. That rule is right to strip decorative motion and wrong to strip this.
Handle a second removal while one is still pending by committing the first, not by discarding it. Only one hold exists at a time, and silently dropping the earlier delete is the one outcome nobody can recover from.
Announce with role="status", never role="alert". The toast is confirmation of something the person just did on purpose; alert is for the unexpected and interrupts a screen reader mid-sentence to say so.
Every colour comes from theme custom properties, and the countdown bar has to stay visible against the toast in both light and dark themes.