Optimistic Row Insert
The new entry lands at half opacity the instant it is asked for, then firms up when the server agrees.
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, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Optimistic Row Insert
*
* The row lands the instant it is asked for, at reduced opacity and with
* a hollow marker, and firms up when the server agrees. Nothing waits on
* the network to look like it happened; the half state is the honest
* part — it says "asked for, not yet acknowledged" without a blocking
* state anywhere.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the list reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `items`, `confirmMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type OptimisticItem = {
id: number;
label: string;
meta: string;
pending: boolean;
};
export type OptimisticRowInsertProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Rows already acknowledged when the list mounts. */
items?: string[];
/** Labels the add control walks through, wrapping at the end. */
drafts?: string[];
/** How long the pretend round trip takes, in ms. */
confirmMs?: number;
/** Label of the add control. */
addLabel?: string;
/** Accent for the confirmed marker. */
accent?: string;
/** Width — px number or any CSS length. */
width?: number | string;
/** Fires with the label once a row is acknowledged. */
onConfirmed?: (label: string) => void;
};
type VariantConfig = {
/** px a new row rises through as it lands. */
enterY: number;
/** Opacity a row holds while it is unacknowledged. */
pendingOpacity: number;
/** Fade for the firming-up. */
firmSeconds: number;
/** The spring the neighbours reflow on. */
spring: { type: "spring"; stiffness: number; damping: number };
};
// Rows carry words, so the reflow lands rather than bounces: damping
// ratios (ζ = damping / 2√stiffness) sit at or above 0.89. Variants
// change how far the row travels and how faint the pending state is —
// never how many times the list settles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.03 — the list barely moves. For dense admin lists where rows
// are added in bursts.
subtle: {
enterY: 5,
pendingOpacity: 0.62,
firmSeconds: 0.2,
spring: { type: "spring", stiffness: 520, damping: 47 },
},
// ζ ≈ 0.98. The all-purpose setting.
default: {
enterY: 10,
pendingOpacity: 0.52,
firmSeconds: 0.26,
spring: { type: "spring", stiffness: 420, damping: 40 },
},
// ζ ≈ 0.89, more travel — for a short list where each addition is an
// event worth noticing.
playful: {
enterY: 15,
pendingOpacity: 0.45,
firmSeconds: 0.32,
spring: { type: "spring", stiffness: 340, damping: 33 },
},
};
/** 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 ACCENT = "#10B981";
const SAMPLE_ITEMS = ["Quarterly board pack", "Pricing experiment brief"];
const SAMPLE_DRAFTS = [
"Renewal outreach list",
"Onboarding survey results",
"Support macro cleanup",
];
export default function OptimisticRowInsert({
variant = "default",
items = SAMPLE_ITEMS,
drafts = SAMPLE_DRAFTS,
confirmMs = 1100,
addLabel = "Add item",
accent = ACCENT,
width = 320,
onConfirmed,
}: OptimisticRowInsertProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [rows, setRows] = useState<OptimisticItem[]>(() =>
items.map((label, index) => ({
id: index,
label,
meta: "Synced",
pending: false,
}))
);
const nextId = useRef(items.length);
const added = useRef(0);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const onConfirmedRef = useRef(onConfirmed);
useEffect(() => {
onConfirmedRef.current = onConfirmed;
}, [onConfirmed]);
useEffect(
() => () => {
for (const timer of timers.current) clearTimeout(timer);
},
[]
);
const add = () => {
const label = drafts[added.current % drafts.length];
const id = nextId.current++;
added.current++;
// The row is in the list on the same tick as the click. The request
// would go out here; its resolution is the timeout below.
setRows((current) => [
{ id, label, meta: "Saving", pending: true },
...current,
]);
const timer = setTimeout(() => {
setRows((current) =>
current.map((row) =>
row.id === id ? { ...row, pending: false, meta: "Saved just now" } : row
)
);
onConfirmedRef.current?.(label);
}, confirmMs);
timers.current.push(timer);
};
return (
<div style={{ width }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 10,
}}
>
<span style={{ fontSize: 12.5, fontWeight: 650 }}>Saved views</span>
<button
type="button"
onClick={add}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
height: 26,
padding: "0 10px",
borderRadius: 7,
border: `1px solid ${tone(14)}`,
background: tone(6),
color: "inherit",
font: "inherit",
fontSize: 11.5,
fontWeight: 600,
cursor: "pointer",
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M6 2.2v7.6M2.2 6h7.6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
{addLabel}
</button>
</div>
<motion.ul
// The list box grows with its contents rather than jumping to the
// new height, so the rows below are pushed rather than teleported.
layout={reduceMotion ? false : true}
transition={cfg.spring}
style={{
listStyle: "none",
margin: 0,
padding: 6,
display: "flex",
flexDirection: "column",
gap: 4,
borderRadius: 12,
background: tone(4),
border: `1px solid ${tone(10)}`,
}}
>
<AnimatePresence initial={false}>
{rows.map((row) => (
<motion.li
key={row.id}
layout={reduceMotion ? false : "position"}
initial={{ opacity: 0, y: reduceMotion ? 0 : -cfg.enterY }}
animate={{
// The unacknowledged row is legible but visibly provisional.
// The firming-up is the only thing the server's answer
// changes, so a rejection reverses exactly this one value.
opacity: row.pending ? cfg.pendingOpacity : 1,
y: 0,
}}
exit={{ opacity: 0, y: reduceMotion ? 0 : -cfg.enterY }}
transition={{
opacity: { duration: cfg.firmSeconds, ease: "easeOut" },
y: cfg.spring,
layout: cfg.spring,
}}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 10px",
borderRadius: 8,
background: tone(4),
}}
>
<Marker pending={row.pending} accent={accent} still={Boolean(reduceMotion)} />
<span style={{ fontSize: 12.5, fontWeight: 550, minWidth: 0 }}>
{row.label}
</span>
<span
style={{
marginLeft: "auto",
fontSize: 11,
opacity: 0.5,
whiteSpace: "nowrap",
}}
>
{/* The status word crossfades in place: same line, same
size, so nothing in the row shifts when it changes. */}
<span style={{ position: "relative", display: "inline-block" }}>
<span style={{ visibility: "hidden" }}>Saved just now</span>
<AnimatePresence initial={false}>
<motion.span
key={row.meta}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{ position: "absolute", inset: 0, textAlign: "right" }}
>
{row.meta}
</motion.span>
</AnimatePresence>
</span>
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ul>
<span
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{rows.some((row) => row.pending) ? "Saving new item" : ""}
</span>
</div>
);
}
/** Hollow while the row is provisional, filled once it is real. The two
* states are separate layers crossfading, because a color-mix() tint
* cannot be interpolated by an animation. */
function Marker({
pending,
accent,
still,
}: {
pending: boolean;
accent: string;
still: boolean;
}) {
return (
<span
aria-hidden
style={{
position: "relative",
display: "inline-block",
width: 14,
height: 14,
flexShrink: 0,
}}
>
<motion.span
initial={false}
animate={{ opacity: pending ? 1 : 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: 999,
border: `1.5px dashed ${tone(38)}`,
}}
/>
<motion.span
initial={false}
animate={{ opacity: pending ? 0 : 1 }}
transition={{
duration: still ? 0.16 : 0.24,
ease: "easeOut",
delay: pending ? 0 : 0.04,
}}
style={{
position: "absolute",
inset: 0,
borderRadius: 999,
display: "grid",
placeItems: "center",
background: `color-mix(in srgb, ${accent} 18%, transparent)`,
color: accent,
}}
>
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path
d="M2.6 6.3 4.9 8.6 9.4 3.7"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
</span>
);
}About this pattern
The alternative to a disabled button and a wait. On submit the entry is in the list on the same tick, at reduced opacity with a hollow marker — legible, but visibly provisional — and the neighbours reflow around it on a spring that settles once. When the write is acknowledged the opacity firms to full and the marker fills; that single value is the whole difference between asked-for and real, which is what makes a rejection easy to reverse. The status word crossfades inside a reserved box so the entry never changes width, and no part of the sequence blocks anything else on the page.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Issue tracker
A new row appears in the list the moment you submit, before the server has confirmed it.
Related patterns
- Spinner to CheckThe waiting arc finishes its turn, closes into a full ring, and the confirming stroke lands inside it.
- Staggered List EntranceRows fade and lift into place about fifty milliseconds apart, top to bottom.
- File Upload ProgressA file row's bar fills as bytes land while the percentage and the counter roll with it.