GODRICH

Toast Notification UI: 9 Stacking Patterns

Toast notification UI is what decides how those corner messages pile up and how they leave again.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order here is not popularity; it is the life of one notice on screen. It arrives and takes a slot (01), says what kind of thing happened with color and an icon (02), and shows how long it has left (03). Then comes clearing it: flick it off with a thumb (04), take back a delete that was a mistake (05), and swap a spinner for its answer without the card ever moving (06). The last three are what happens when several land at once: fold them into a deck (07), pick which of six slots the group sits in (08), and split the reading order for people who never see the screen (09). One piece of skeleton is shared by all nine: the moving part lives inside a box with a pinned height, and inside a stack the cards shift with transform. Push a card with top or margin instead and the browser re-measures the page every time the pile grows.

01Push-up stack

A new card is inserted at the bottom of the list and everything above it climbs one slot. To keep that climb from snapping, each card's position is read before the insert, pinned back to where it was afterwards, and released a frame later. Those four steps are FLIP, and the browser only ever animates the last one.

getBoundingClientRecttranslateYrequestAnimationFrame
var before = [].map.call(list.children, function (el) { return el.getBoundingClientRect().top; });
list.insertBefore(el, list.firstChild);
[].forEach.call(list.children, function (node, i) {
  if (!i) return;
  var d = before[i - 1] - node.getBoundingClientRect().top; // old top − new top = distance to undo
  node.style.transition = 'none';
  node.style.transform = 'translateY(' + d + 'px)';
});
requestAnimationFrame(function () { // release a frame later or the browser never sees a start position
  [].forEach.call(list.children, function (node, i) {
    if (!i) return;
    node.style.transition = '';
    node.style.transform = '';
  });
});

02Icon and color per kind

Success, warning, error, and info sit stacked inside one card, and a single data-kind value picks which face shows. The autoplay loop rotates all four every half second, cutting between them with steps(1, end) so two faces are never half-visible at the same time. A different negative animation-delay is all it takes to drive four elements from one keyframe block.

data-kindsteps(1, end)currentColor
.tk.is-demo .tk__face {
  animation-name: tk-face;
  animation-duration: $dur-loop;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}
.tk.is-demo .tk__face[data-kind="ok"]   { animation-delay: 0s; }
.tk.is-demo .tk__face[data-kind="warn"] { animation-delay: -1.5s; }
.tk.is-demo .tk__face[data-kind="bad"]  { animation-delay: -1s; }
.tk.is-demo .tk__face[data-kind="info"] { animation-delay: -0.5s; }
@keyframes tk-face {
  0%   { opacity: 1; }
  25%  { opacity: 0; }
  100% { opacity: 0; }
}

03Progress bar with hover pause

The bar along the bottom shrinks with scaleX, and because its origin sits on the left it empties from the right end. Hovering or moving keyboard focus in puts animation-play-state: paused on the bar and cancels the dismiss timer, which is re-armed with whatever time was left once you leave. Pause only one of the two and you get a frozen bar on a toast that closes anyway.

scaleXanimation-play-statetransform-origin
.pb__fill {
  display: block;
  height: 100%;
  background: $color;
  transform-origin: left center;
  transform: scaleX(1);
}
.pb__toast.is-on .pb__fill {
  animation-name: pb-drain;
  animation-duration: 4200ms;
  animation-timing-function: linear;
  animation-iteration-count: 1;
  animation-fill-mode: forwards;
}
.pb__toast.is-hold .pb__fill { animation-play-state: paused; }
@keyframes pb-drain {
  0%   { transform: scaleX(1); }
  100% { transform: scaleX(0); }
}

04Swipe to fling away

Press and drag, and the card tracks the pointer exactly; let go past 72px of travel and it flies 420px in that direction and closes. Anything shorter resets the offset to zero and the card springs back. Calling setPointerCapture on the way down is what keeps move events coming after the finger leaves the card.

setPointerCapturepointermovetranslateX(var(--dx))
card.addEventListener('pointerdown', function (e) {
  root.classList.remove('is-demo');
  down = true; x0 = e.clientX; dx = 0;
  card.setPointerCapture(e.pointerId); // move events keep coming after the finger leaves the card
  card.classList.add('is-drag');
});
card.addEventListener('pointermove', function (e) {
  if (!down) return;
  dx = e.clientX - x0;
  set(dx);
});
card.addEventListener('pointerup', function () {
  if (!down) return;
  down = false;
  card.classList.remove('is-drag');
  if (Math.abs(dx) > OUT) fling(dx < 0 ? -1 : 1); else set(0);
});

05An undo button that really undoes

A deleted row isn't removed right away; it's folded shut by moving grid-template-rows from 1fr to 0fr. Undo cancels the scheduled removal with clearTimeout and unfolds the row, so nothing has to be stashed anywhere to be restored later. The slot the toast rises into has a fixed height, which stops the list above from jumping when it appears.

clearTimeoutgrid-template-rowsaria-live
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 undo() {
  clearTimeout(timer);
  if (pending) pending.classList.remove('is-gone');
  pending = null;
  toast.classList.remove('is-on');
}

06Loading toast that morphs into a result

The ring spinning inside the card becomes a success or failure mark the instant the work finishes. It turns through one rotate cycle per second forever, and the swap lands on a cut between sample frames, so there is no window where both icons are half-there. While the work runs, the card carries aria-busy="true", and that flips to false when the answer arrives.

rotatesteps(1, end)aria-busy
.pm__spin {
  animation-name: pm-spin;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}
@keyframes pm-spin {
  0%   { rotate: 0deg; }
  100% { rotate: 360deg; }
}
@keyframes pm-load {
  0%   { opacity: 1; }
  35%  { opacity: 0; }
  56%  { opacity: 1; }
  77%  { opacity: 0; }
  89%  { opacity: 1; }
  100% { opacity: 1; }
}

07Group that folds past three

Every card carries its own index in --i, and folded versus fanned differ only in what that number is multiplied by and in the scale that rides along with it. Folded, the cards offset 12px apiece and shrink a little with scale so the pile reads as a deck; fanned, the step becomes 36px and everything returns to full size. Stacking order is handled by one line, z-index: calc(9 - var(--i)).

aria-expanded--iscale
.cg__item {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  margin: 0;
  padding: 0;
  z-index: calc(9 - var(--i));
  transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04));
  transition: transform $duration $easing;
  cursor: pointer;
}
.cg__list.is-open .cg__item { transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1); }

08Six-corner position switch

One data-pos value on the window changes both the inset and the transform of the toast group, parking it in one of six slots, three along the top and three along the bottom. Center slots pair a left edge of 50% with translate(-50%), and right-hand slots pair calc(100% - 8px) with translate(-100%), so the math holds without knowing how wide the group is. Transitioning both values over the same duration is what makes the group glide instead of jump.

data-posinsetaria-pressed
.ps__toasts {
  position: absolute;
  inset: calc(100% - 8px) auto auto calc(100% - 8px);
  display: flex;
  flex-direction: column;
  gap: $sp-1;
  width: 148px;
  transform: translate(-100%, -100%);
  transition: inset $duration $easing, transform $duration $easing;
}
.ps__screen[data-pos="tl"] .ps__toasts { inset: 30px auto auto 8px;               transform: translate(0, 0); }
.ps__screen[data-pos="tc"] .ps__toasts { inset: 30px auto auto 50%;               transform: translate(-50%, 0); }
.ps__screen[data-pos="tr"] .ps__toasts { inset: 30px auto auto calc(100% - 8px);  transform: translate(-100%, 0); }
.ps__screen[data-pos="br"] .ps__toasts { inset: calc(100% - 8px) auto auto calc(100% - 8px); transform: translate(-100%, -100%); }

09Live regions that split the reading order

The left lane lets the sentence a screen reader is already speaking finish and waits its turn; the right lane cuts in. The only difference between them is a role name and one aria-live value, so routing errors to the right lane keeps every other notice from stepping on someone's reading. Adding aria-atomic="true" makes the whole sentence be read again instead of only the piece that changed.

role="alert"aria-live="assertive"aria-atomic
<p class="lr__card" role="status" aria-live="polite" aria-atomic="true" aria-label="Polite notification lane">
  <span class="lr__dot" aria-hidden="true"></span>
  <span class="lr__msg">Mail sent</span>
</p>
<p class="lr__card" role="alert" aria-live="assertive" aria-atomic="true" aria-label="Urgent notification lane">
  <span class="lr__dot" aria-hidden="true"></span>
  <span class="lr__msg">The payment was declined</span>
</p>

Where this breaks — three traps

A toast is a component whose job is to leave, and running nine of them on a two-second loop surfaced a problem the other posts on this site never hit. The cover image for each demo is picked as the single frame out of twenty-four that differs most from the one before it, and in a toast demo that frame is always the moment the card disappears. The first cut of 03 landed exactly there: the cover came out as an empty orange field with one button on it, and not a single frame with a notice in it was ever a candidate. The fix was to stop deleting the card in the loop at all. When the bar runs out the card dips once and the bar refills, as if the next notice had arrived, while real closing still happens through the button and the timer.

The second trap is where a fade sits. Two seconds sampled into twenty-four frames means a sample every 4.1667%, and a disappearance that straddles one of them is captured as a translucent ghost card. So every state swap in 06 and 09 is a steps(1, end) cut parked between samples, at 35%, 45%, 56%, 77%, and 89%. Item 01 needed different treatment because it holds three cards: removing all three at once makes that empty frame the winner again, so they are cut one at a time at 78%, 82%, and 86%. The third is size. An absolutely positioned toast is invisible to its parent's height calculation, so the stage looks fine while the document quietly grows taller than the frame. That is why every one of the nine puts its moving part inside a box with a pinned height, and every demo whose cards travel past that box also clips it with overflow: hidden. On a 320×200 stage only 174px of height survives the padding, so narrow screens drop the card step from 44px to 34px and shorten the box to match. The password on the archive holding all nine sources is qh4e8nxm, and the nine SCSS files and React ports inside carry exactly the values described here.

Accessibility

When someone asks for less motion (prefers-reduced-motion: reduce), all nine stop the autoplay loop and leave the current state on screen. The cards in 01 simply stand in their three slots with no glide, the bar in 03 stops draining, and the reading meter in 09 holds one height while color keeps showing which lane is live. None of the pressing and dismissing is wired to the mouse alone, either.

Item Mouse Keyboard Announcement
01 stack click the button Enter · Space aria-live="polite" for the new row only
03 progress hover to hold Tab focus holds it too aria-label on the close button
04 swipe drag and release Left/Right arrow closes that way the card behind stays, so nothing goes blank
05 undo click the bin Tab · Enter role="status" announces the delete
06 promise click the button Enter · Space aria-busy while the work runs
07 group click a card or the button Enter · Space aria-expanded
08 position click a slot Tab · Enter aria-pressed · one aria-label per slot
09 two lanes click the button Enter · Space role="status" and role="alert" kept apart

Why role="status" should be the default of the two, and what the assertive lane costs a reader, is set out in the MDN page on the status role. The pointer capture behind 04 has its conditions written up in the MDN page on setPointerCapture; skip it and move events stop the instant the finger crosses the card edge, leaving the card stranded mid-drag. Other parts that tell a person something are collected under the notify category, and parts that run on their own without a click sit under the auto trigger category.

Questions people ask

What happens when five notices land at the same second?

Cap how many stay on screen and either drop the oldest — 01 trims to three — or fold them into a deck the way 07 does. Both are written so that the cap is a single number you can change. Leave it uncapped and the column grows until it covers the buttons at the bottom of the page.

How long should a toast stay before closing itself?

Item 03 uses 4.2 seconds. The right number depends on how much there is to read, but anything with an action button needs room to reach for it, so 05 holds for four seconds and pausing on hover is the safer default. Errors that cannot be retried are better off not auto-closing at all.

Can these nine replace a toast library outright?

If one screen owns one toast area and you have four or five kinds of message, yes — stack handling, auto-dismiss, undo, and live regions are all here with no dependencies. Where a library still earns its keep is calling a toast from anywhere in a large app or wiring it to server events. In that case keep the library's queue and drop this markup into its render slot for the look.

Enter the archive password

The password is inside this article. You will find it as you read.