All patterns

Optimistic Row Insert

The new entry lands at half opacity the instant it is asked for, then firms up when the server agrees.

loadingfriendlyminimalinteraction · finite · intermediate · ~1.1s
Interactive · click to play
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.

362 lines · react + motion only
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.

Add to a listCreate a recordInvite a teammateComment submit

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

    A new row appears in the list the moment you submit, before the server has confirmed it.

Related patterns