All patterns

Clipboard Toasts Stack

Repeat copies push a short stack of confirmations that shuffle down and dim instead of piling up.

feedbackminimalsubtleautomatic · finite · advanced · ~5.2s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

231 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Clipboard Toasts Stack
 *
 * Copy something twice and the second confirmation should not land on
 * top of the first. Each new one arrives at the head of a short stack,
 * pushes the earlier ones down and dims them, and the stack closes
 * itself back up as they expire.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `labels`, `holdMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ClipboardToastStackProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** What each confirmation says, in the order they are pushed. */
  labels?: string[];
  /** Gap between one confirmation and the next, in ms. */
  intervalMs?: number;
  /** How long each one stays, in ms. */
  holdMs?: number;
  /** How many are on screen before the oldest is dropped. */
  maxVisible?: number;
  /** Delay before the first one, in ms. */
  startDelayMs?: number;
  /** Row height in px — also what the stack reserves for each slot. */
  rowHeight?: number;
  /** Fires once the last confirmation has left. */
  onEmpty?: () => void;
};

type Item = { id: number; label: string };

type VariantConfig = {
  /** How far a new confirmation comes from, in px. */
  travel: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Opacity of each slot going back through the stack. */
  depth: number[];
};

// Two things move at once here — the arriving card and the ones being
// pushed down — so both have to land flat or the stack looks unstable.
// Every ratio is at or above 0.8; variants differ in travel and dimming.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Small travel, gentle dimming: the stack barely registers.
  subtle: {
    travel: 8,
    spring: { type: "spring", stiffness: 500, damping: 44 },
    depth: [1, 0.8, 0.62],
  },
  default: {
    travel: 14,
    spring: { type: "spring", stiffness: 400, damping: 36 },
    depth: [1, 0.7, 0.48],
  },
  // Longer drop and a steeper falloff, so the order is unmistakable.
  playful: {
    travel: 22,
    spring: { type: "spring", stiffness: 340, damping: 30 },
    depth: [1, 0.62, 0.36],
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_LABELS = [
  "Share link copied",
  "API key copied",
  "Invoice ID copied",
];

export default function ClipboardToastStack({
  variant = "default",
  labels = DEFAULT_LABELS,
  intervalMs = 1000,
  holdMs = 2800,
  maxVisible = 3,
  startDelayMs = 400,
  rowHeight = 38,
  onEmpty,
}: ClipboardToastStackProps) {
  const [items, setItems] = useState<Item[]>([]);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Labels and the callback live in refs, so an inline array or arrow
  // from the parent cannot restart the run on every render.
  const labelsRef = useRef(labels);
  useEffect(() => {
    labelsRef.current = labels;
  }, [labels]);
  const onEmptyRef = useRef(onEmpty);
  useEffect(() => {
    onEmptyRef.current = onEmpty;
  }, [onEmpty]);

  const nextId = useRef(0);
  const labelsKey = labels.join("|");

  useEffect(() => {
    const timers: ReturnType<typeof setTimeout>[] = [];
    labelsRef.current.forEach((label, index) => {
      timers.push(
        setTimeout(
          () => {
            const id = nextId.current++;
            // The newest goes to the head; anything past the limit falls
            // off the end rather than accumulating out of view.
            setItems((prev) => [{ id, label }, ...prev].slice(0, maxVisible));
            timers.push(
              setTimeout(() => {
                setItems((prev) => {
                  const next = prev.filter((item) => item.id !== id);
                  if (next.length === 0) onEmptyRef.current?.();
                  return next;
                });
              }, holdMs)
            );
          },
          startDelayMs + index * intervalMs
        )
      );
    });
    return () => {
      for (const timer of timers) clearTimeout(timer);
    };
  }, [labelsKey, intervalMs, holdMs, maxVisible, startDelayMs]);

  return (
    // The column reserves every slot up front, so the panel around the
    // stack keeps its size whether one confirmation is showing or three.
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: 8,
        width: 244,
        minHeight: maxVisible * rowHeight + (maxVisible - 1) * 8,
      }}
    >
      <AnimatePresence initial={false} mode="popLayout">
        {items.map((item, index) => (
          <motion.div
            key={item.id}
            role="status"
            aria-live="polite"
            // Position-only layout: the earlier cards travel to their new
            // slot instead of being resized, which is what keeps the text
            // inside them from stretching as the stack reflows.
            layout={reduceMotion ? false : "position"}
            initial={{ opacity: 0, y: reduceMotion ? 0 : -cfg.travel }}
            animate={{
              opacity: cfg.depth[Math.min(index, cfg.depth.length - 1)],
              y: 0,
            }}
            exit={{
              opacity: 0,
              y: reduceMotion ? 0 : 6,
              transition: { duration: 0.16, ease: "easeIn" },
            }}
            transition={
              reduceMotion
                ? { duration: 0.16, ease: "easeOut" }
                : { ...cfg.spring, opacity: { duration: 0.2, ease: "easeOut" } }
            }
            style={{
              display: "flex",
              alignItems: "center",
              gap: 9,
              height: rowHeight,
              padding: "0 12px",
              borderRadius: 10,
              // Opaque, because a confirmation stack sits over page
              // content. `Canvas`/`CanvasText` are the CSS system colors
              // for page background and text, so each card is light in a
              // light app and dark in a dark one.
              background: "Canvas",
              color: "CanvasText",
              border: `1px solid ${tone(13)}`,
              boxShadow: "0 8px 20px rgba(0,0,0,0.16)",
            }}
          >
            <span
              aria-hidden
              style={{
                flexShrink: 0,
                width: 18,
                height: 18,
                borderRadius: 5,
                background: `color-mix(in srgb, ${ACCENT} 16%, transparent)`,
                display: "grid",
                placeItems: "center",
              }}
            >
              <svg width="10" height="10" viewBox="0 0 16 16" fill="none">
                <path
                  d="M3.4 8.4 6.3 11.3 12.6 5"
                  stroke={ACCENT}
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </span>
            <span
              style={{
                fontSize: 12.5,
                fontWeight: 500,
                whiteSpace: "nowrap",
                overflow: "hidden",
                textOverflow: "ellipsis",
              }}
            >
              {item.label}
            </span>
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
}

About this pattern

One confirmation is easy; the third one in four seconds is where most implementations fall apart, landing on top of each other or fighting over the same corner. Here each new card arrives at the head of a short stack, pushes the earlier ones down and dims them by depth, and anything past the limit falls off the end rather than accumulating out of view. The cards travel position-only as the stack reflows, so their text is never stretched by the shuffle, and the column reserves every slot up front so the corner keeps its size whether one card is showing or three. Cards expire on their own clock, which means the stack also closes itself back up.

Copying several credentials in a rowRepeated share linksDuplicating values from a listBack-to-back export actions

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Issues
    Backlog
    Active
    Cycles
    Views
    IssuesNew
    Colourway picker drops a frameRID-412 · PriyaIn progress
    Receipt totals misalign on narrowRID-408 · MarcusTodo
    Session expires without warningRID-401 · DanaIn review
    Export queue stalls past 500 rowsRID-397 · NilsTodo
    Search ranks archived firstRID-390 · PriyaDone
    Issue tracker

    Successive notices stack and collapse instead of overlapping.

Related patterns