All patterns

Deleted Item Tombstone

A removed row leaves a stub holding its slot open, with a hairline showing how long undo is on offer.

empty-statescalmminimalautomatic · finite · intermediate · ~4.4s
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.

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

/**
 * Vibary · Deleted Item Tombstone
 *
 * Deletion in two steps, so the second one can be refused. The row is
 * replaced in place by a stub carrying the undo, and the slot keeps its
 * exact height — nothing below moves while the offer stands. A hairline
 * runs down to show how long that is true for, and only when it expires
 * does the slot collapse and the list close the gap.
 *
 * Self-contained: depends only on `react` and `motion`. Neutrals are
 * mixed from the inherited text color, so it reads on light and dark
 * pages alike. Works with zero props.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TombstoneRow = {
  title: string;
  meta: string;
};

export type DeletedItemTombstoneProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Your rows. The embedded sample is used when omitted. */
  rows?: TombstoneRow[];
  /** Which row is removed. */
  targetIndex?: number;
  /** Stub text and action label. */
  deletedLabel?: string;
  undoLabel?: string;
  /** Delay before the row is removed, so the list can be read first. */
  deleteAfterMs?: number;
  /** How long undo stays on offer. */
  undoMs?: number;
  /** Fires when the row is restored. */
  onUndo?: () => void;
  /** Fires when the window closes and the slot collapses. */
  onExpire?: () => void;
  /** Height of a row in px. Uniform rows keep the collapse honest. */
  rowHeight?: number;
  /** Block width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** Seconds the row and its stub take to trade places. */
  swapSeconds: number;
  /** Seconds the slot takes to collapse once the window closes. */
  collapseSeconds: number;
};

// Quality rule: no springs. A row closing a gap is a layout change, and
// layout changes that overshoot make a list look unstable — every value
// here rides an ease-out curve and stops. Text only ever crossfades.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // For a bulk-edit view where several rows may go at once.
  subtle: {
    swapSeconds: 0.16,
    collapseSeconds: 0.2,
  },
  // The all-purpose setting.
  default: {
    swapSeconds: 0.22,
    collapseSeconds: 0.28,
  },
  // A slower close, for a list where deleting is rare and deliberate.
  playful: {
    swapSeconds: 0.28,
    collapseSeconds: 0.36,
  },
};

const ROWS: TombstoneRow[] = [
  { title: "Quarterly report", meta: "Shared · 2 days ago" },
  { title: "Vendor agreement", meta: "Draft · 5 days ago" },
  { title: "Team offsite plan", meta: "Shared · last week" },
];

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` keeps rows, the stub and the countdown hairline correct
 *  on light and dark pages alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function DeletedItemTombstone({
  variant = "default",
  rows = ROWS,
  targetIndex = 1,
  deletedLabel = "Vendor agreement deleted",
  undoLabel = "Undo",
  deleteAfterMs = 900,
  undoMs = 3200,
  onUndo,
  onExpire,
  rowHeight = 56,
  width = 320,
}: DeletedItemTombstoneProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [status, setStatus] = useState<"present" | "tombstone" | "removed">(
    "present"
  );

  // The file runs on its own: the row goes after a beat, so the reader
  // sees the list intact first and the change has something to be a
  // change from.
  useEffect(() => {
    if (status !== "present") return;
    const timer = setTimeout(() => setStatus("tombstone"), deleteAfterMs);
    return () => clearTimeout(timer);
  }, [status, deleteAfterMs]);

  useEffect(() => {
    if (status !== "tombstone") return;
    const timer = setTimeout(() => {
      setStatus("removed");
      onExpire?.();
    }, undoMs);
    return () => clearTimeout(timer);
  }, [status, undoMs, onExpire]);

  const undo = () => {
    if (status !== "tombstone") return;
    setStatus("present");
    onUndo?.();
  };

  const swap = { duration: cfg.swapSeconds, ease: "easeOut" as const };

  return (
    <div
      style={{
        position: "relative",
        width,
        boxSizing: "border-box",
        padding: "4px 0",
      }}
    >
      {rows.map((row, index) => {
        if (index !== targetIndex) {
          return <Row key={row.title} row={row} height={rowHeight} />;
        }

        return (
          <motion.div
            key={row.title}
            initial={false}
            animate={{
              height: status === "removed" ? 0 : rowHeight,
              opacity: status === "removed" ? 0 : 1,
            }}
            transition={{
              height: {
                duration: reduceMotion ? 0.001 : cfg.collapseSeconds,
                ease: [0.32, 0.72, 0, 1] as const,
              },
              opacity: { duration: reduceMotion ? 0.001 : cfg.swapSeconds },
            }}
            style={{ overflow: "hidden", display: "grid" }}
          >
            {/* Row and stub occupy the same cell at the same height, so
                swapping one for the other cannot move the rows below. */}
            <motion.div
              aria-hidden={status !== "present"}
              animate={{ opacity: status === "present" ? 1 : 0 }}
              transition={swap}
              style={{ gridArea: "1 / 1" }}
            >
              <Row row={row} height={rowHeight} />
            </motion.div>

            <motion.div
              aria-hidden={status !== "tombstone"}
              animate={{ opacity: status === "tombstone" ? 1 : 0 }}
              transition={swap}
              style={{
                gridArea: "1 / 1",
                position: "relative",
                display: "flex",
                alignItems: "center",
                justifyContent: "space-between",
                gap: 12,
                height: rowHeight,
                boxSizing: "border-box",
                padding: "0 16px",
                background: tone(4),
                borderTop: `1px dashed ${tone(14)}`,
                borderBottom: `1px dashed ${tone(14)}`,
                pointerEvents: status === "tombstone" ? "auto" : "none",
              }}
            >
              <span style={{ fontSize: 12.5, opacity: 0.55 }}>{deletedLabel}</span>

              <button
                type="button"
                onClick={undo}
                style={{
                  font: "inherit",
                  fontSize: 12,
                  fontWeight: 600,
                  padding: 0,
                  color: "inherit",
                  background: "none",
                  border: "none",
                  textDecoration: "underline",
                  textUnderlineOffset: 3,
                  cursor: "pointer",
                }}
              >
                {undoLabel}
              </button>

              {/* How long the offer stands, shown rather than counted.
                  Reduced motion holds it still — the button says the same
                  thing without a moving line. */}
              <motion.span
                aria-hidden
                initial={{ scaleX: 1 }}
                animate={{ scaleX: status === "tombstone" && !reduceMotion ? 0 : 1 }}
                transition={{
                  duration: status === "tombstone" ? undoMs / 1000 : 0,
                  ease: "linear",
                }}
                style={{
                  position: "absolute",
                  left: 0,
                  right: 0,
                  bottom: 0,
                  height: 2,
                  background: tone(18),
                  transformOrigin: "left center",
                }}
              />
            </motion.div>
          </motion.div>
        );
      })}

      <span
        role="status"
        aria-live="polite"
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          overflow: "hidden",
          clipPath: "inset(50%)",
          whiteSpace: "nowrap",
        }}
      >
        {status === "tombstone" ? `${deletedLabel}. ${undoLabel} available.` : ""}
      </span>
    </div>
  );
}

/** One list row. Kept as a local component so the deleted slot and the
 *  untouched rows are literally the same markup at the same height. */
function Row({ row, height }: { row: TombstoneRow; height: number }) {
  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        gap: 11,
        height,
        boxSizing: "border-box",
        padding: "0 16px",
      }}
    >
      <span
        aria-hidden
        style={{
          width: 26,
          height: 26,
          borderRadius: 7,
          background: tone(8),
          flexShrink: 0,
        }}
      />
      <span style={{ minWidth: 0 }}>
        <span style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}>
          {row.title}
        </span>
        <span style={{ display: "block", fontSize: 11, opacity: 0.5, marginTop: 2 }}>
          {row.meta}
        </span>
      </span>
    </div>
  );
}

About this pattern

Deletion in two steps, so the second one can be refused. The row crossfades into a stub carrying the undo, and the slot keeps its exact height — nothing below shifts while the offer stands, which means the reader's eye stays where the change happened. A hairline runs down the base of the stub to show how long that remains true, and only when it expires does the slot collapse and the list close the gap on an eased curve. Undo inside the window restores the row in place. No springs: a list that overshoots while closing a gap looks unstable at exactly the wrong moment.

Row deleted from a listUndo window after removalInline delete in a file browserMessage removed from a thread

Where it shows up

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

  • Ridgeline
    Inbox
    Starred
    Drafts
    Archive
    Sent
    InboxNew
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmed for ThursdayDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    Weekly summaryReports · Mon
    Inbox

    A removed conversation offers an undo for a short window before the list settles.

Related patterns