Deleted Item Tombstone
A removed row leaves a stub holding its slot open, with a hairline showing how long undo is on offer.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
A removed conversation offers an undo for a short window before the list settles.
Related patterns
- Offline Cached OnlyWhen the network drops, downloaded rows stay crisp while the rest dim in a wave down the list.
- Archive EmptyOne fade and a lid settling three pixels onto a box — the least motion a state can have and still arrive.
- Chart No DataThe axes extend from the origin and a flat dashed baseline runs across, so an empty range still looks like a chart.