WebberUI

Optimistic Mutation Frame

An optimistic update container that inserts a translucent ghost item immediately, solidifies it into a real one when the server confirms, and shakes it back out on failure while handing the error off to a toast.

Loading preview…
npx shadcn@latest add https://webberui.com/r/optimistic-mutation-frame.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

2
8
4000
<OptimisticMutationFrame />

Installation

npx shadcn@latest add https://webberui.com/r/optimistic-mutation-frame.json

Or, once registries are configured in components.json, install it as @webberui/optimistic-mutation-frame.

Usage

OptimisticFrame wraps any list or set of cards, and a ref gives you its imperative operation interface. Calling add immediately inserts a translucent ghost item and then waits on the async task you pass in: on success it solidifies into a real item, and on failure it shakes back out and raises a toast.

import * as React from "react";
import {
  OptimisticFrame,
  type OptimisticFrameHandle,
} from "@/components/ui/optimistic-mutation-frame";

interface Todo {
  id: string;
  text: string;
}

export function Example() {
  const frame = React.useRef<OptimisticFrameHandle<Todo>>(null);

  function handleAdd(todo: Todo) {
    frame.current?.add(todo, async () => {
      const res = await fetch("/api/todos", {
        method: "POST",
        body: JSON.stringify(todo),
      });
      if (!res.ok) throw new Error("Save failed");
      return (await res.json()) as Todo; // the server response overrides the optimistic data
    });
  }

  return (
    <OptimisticFrame<Todo>
      ref={frame}
      defaultItems={seed}
      getKey={(todo) => todo.id}
    >
      {(entry) => (
        <div data-pending={entry.pending}>{entry.data.text}</div>
      )}
    </OptimisticFrame>
  );
}

The value returned by task (if any) overrides the optimistically inserted data, letting you fill real server-generated fields such as ids and timestamps back into the item. Throwing means failure, which triggers the rollback.

Props

OptimisticFrame

PropTypeDefaultDescription
getKey(item: T) => stringExtracts a stable key from the data
children(entry: OptimisticEntry<T>) => ReactNodeA render prop; style each entry based on entry.status / entry.pending
defaultItemsT[][]The initial real items (uncontrolled, read only once)
layout"list" | "grid""list"Layout direction
columnsnumber2Number of columns in grid layout
gapnumber8Spacing between items (px)
toastbooleantrueWhether to show the built-in error toast
toastDurationnumber4000How long before the toast disappears automatically (milliseconds)
onError(error, ctx) => voidFires on failure; hook it up to an external toast or reporting
itemClassNamestringStyling appended to each item's frame
refRef<OptimisticFrameHandle<T>>Gives you the imperative operation interface

OptimisticFrameHandle (through ref)

MethodSignatureDescription
add(data, task, options?) => Promise<boolean>Optimistic insert; task success solidifies it and failure rolls it back, returning whether it was confirmed
remove(id, task, options?) => Promise<boolean>Optimistic removal; success actually removes it and failure restores it with a shake
reset(items: T[]) => voidResets to a new set of real items, clearing every ghost state

options can carry errorMessage (overriding the toast copy) and position ("start" / "end", where an insert goes).

How it works

  • Ghost state: optimistically inserted items are shown at opacity 0.55 with a dashed outline to signal they are not yet confirmed; entry.pending is true.
  • Solidifying: once task resolves, opacity and scale converge to the real state and the dashed outline fades out.
  • Shake and roll back: when task rejects, the outline turns red and shakes horizontally once, then an added item is removed or a removed item is restored, while the error message is handed off to the toast.
  • FLIP repositioning: as items enter and leave, the remaining items spring smoothly into their new positions (layout animation).
  • Every setTimeout is cleared on unmount; if the component has already unmounted when an async task finishes, no state is updated.

Accessibility

  • Ghost items and items being rolled back carry aria-busy="true", so assistive technology knows they are being processed.
  • The container is role="list" and the items are role="listitem"; pass aria-label to name the list.
  • The error toast sits in an aria-live="assertive" region with role="alert" and comes with a keyboard-focusable close button.
  • When the user has "reduce motion" enabled, the reveal displacement, the solidifying scale, and the shake are all disabled in favor of instant state switching; static cues such as the ghost translucency and the red outline are still kept.

On this page