interior[.]dev

Action Feedback·01.4

Like Burst

Optimistic like that survives rapid taps.

128 likes, not liked
like-burst

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Usage

stats.tsx
"use client";

import { LikeBurst } from "@/components/interior/like-burst";

export function PostActions({ postId, likes, liked }: { postId: string; likes: number; liked: boolean }) {
  return (
    <LikeBurst
      initialLiked={liked}
      initialCount={likes}
      settle={400}
      onCommit={async (next, signal) => {
        const res = await fetch(`/api/posts/${postId}/like`, {
          method: next ? "POST" : "DELETE",
          signal,
        });
        if (!res.ok) throw new Error("like failed");
      }}
      onError={() => toast("Could not save your like")}
    />
  );
}

Source

components/interior/like-burst.tsx
"use client";

import {
  useCallback,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

const EASE = [0.23, 1, 0.32, 1] as const;
const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const;
const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const;
const INSTANT = { duration: 0 } as const;

const HEART =
  "M12 20.3 4.3 12.6a4.8 4.8 0 0 1 6.8-6.8l.9.9.9-.9a4.8 4.8 0 0 1 6.8 6.8Z";

const SPARKS = Array.from({ length: 8 }, (_, i) => {
  const h = (((i + 1) * 2654435761) % 997) / 997;
  const angle = (i / 8) * Math.PI * 2 - Math.PI / 2 + (h - 0.5) * 0.4;
  const distance = 13 + h * 9;
  return {
    x: Math.round(Math.cos(angle) * distance * 10) / 10,
    y: Math.round(Math.sin(angle) * distance * 10) / 10,
    size: h > 0.5 ? 4 : 3,
    delay: Math.round(h * 50) / 1000,
  };
});

const DEFAULT_FORMAT = (value: number) =>
  new Intl.NumberFormat("en-US").format(value);

export type LikeCommit = (
  liked: boolean,
  signal: AbortSignal,
) => Promise<unknown>;

export type LikeBurstHandle = {
  toggle: () => void;
};

export type UseOptimisticLikeOptions = {
  initialLiked?: boolean;
  initialCount?: number;
  onCommit?: LikeCommit;
  onError?: (error: unknown) => void;
  settle?: number;
};

export type OptimisticLike = {
  liked: boolean;
  count: number;
  base: number;
  pending: boolean;
  burst: number;
  settled: { liked: boolean; count: number };
  toggle: () => void;
};

export function useOptimisticLike({
  initialLiked = false,
  initialCount = 0,
  onCommit,
  onError,
  settle = 400,
}: UseOptimisticLikeOptions = {}): OptimisticLike {
  const [liked, setLiked] = useState(initialLiked);
  const [count, setCount] = useState(initialCount);
  const [pending, setPending] = useState(false);
  const [burst, setBurst] = useState(0);
  const [settled, setSettled] = useState({
    liked: initialLiked,
    count: initialCount,
  });

  const likedNow = useRef(initialLiked);
  const countNow = useRef(initialCount);
  const truth = useRef({ liked: initialLiked, count: initialCount });
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const inFlight = useRef<AbortController | null>(null);
  const seq = useRef(0);

  const commit = useRef(onCommit);
  commit.current = onCommit;
  const failed = useRef(onError);
  failed.current = onError;

  const flush = useCallback(() => {
    timer.current = null;
    inFlight.current?.abort();
    inFlight.current = null;
    seq.current += 1;

    const intent = likedNow.current;

    if (intent === truth.current.liked) {
      countNow.current = truth.current.count;
      setLiked(truth.current.liked);
      setCount(truth.current.count);
      setPending(false);
      return;
    }

    const target = { liked: intent, count: countNow.current };
    const run = commit.current;

    if (!run) {
      truth.current = target;
      setSettled(target);
      setPending(false);
      return;
    }

    const controller = new AbortController();
    const id = seq.current;
    inFlight.current = controller;
    setPending(true);

    run(intent, controller.signal).then(
      () => {
        if (id !== seq.current) return;
        inFlight.current = null;
        truth.current = target;
        setSettled(target);
        setPending(false);
      },
      (error: unknown) => {
        if (id !== seq.current) return;
        inFlight.current = null;
        likedNow.current = truth.current.liked;
        countNow.current = truth.current.count;
        setLiked(truth.current.liked);
        setCount(truth.current.count);
        setPending(false);
        failed.current?.(error);
      },
    );
  }, []);

  const toggle = useCallback(() => {
    const next = !likedNow.current;
    likedNow.current = next;
    countNow.current += next ? 1 : -1;

    setLiked(next);
    setCount(countNow.current);
    setPending(true);
    if (next) setBurst((b) => b + 1);

    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(flush, settle);
  }, [flush, settle]);

  useEffect(
    () => () => {
      if (timer.current) clearTimeout(timer.current);
      timer.current = null;
      seq.current += 1;
      inFlight.current?.abort();
      inFlight.current = null;
    },
    [],
  );

  return {
    liked,
    count,
    base: liked ? count - 1 : count,
    pending,
    burst,
    settled,
    toggle,
  };
}

export type LikeBurstProps = {
  initialLiked?: boolean;
  initialCount?: number;
  onCommit?: LikeCommit;
  onError?: (error: unknown) => void;
  onToggle?: (liked: boolean) => void;
  settle?: number;
  label?: string;
  activeLabel?: string;
  format?: (value: number) => string;
  disabled?: boolean;
  className?: string;
};

export function LikeBurst({
  initialLiked = false,
  initialCount = 0,
  onCommit,
  onError,
  onToggle,
  settle = 400,
  label = "Like",
  activeLabel = "Liked",
  format = DEFAULT_FORMAT,
  disabled = false,
  className = "",
  ref,
}: LikeBurstProps & { ref?: React.Ref<LikeBurstHandle> }) {
  const reduced = useReducedMotion();
  const { liked, count, base, pending, burst, settled, toggle } =
    useOptimisticLike({ initialLiked, initialCount, onCommit, onError, settle });

  useImperativeHandle(ref, () => ({ toggle }), [toggle]);

  const low = format(base);
  const high = format(base + 1);
  const widest = high.length >= low.length ? high : low;
  const shown = format(count);

  return (
    <span className={`inline-flex items-center ${className}`}>
      <button
        type="button"
        disabled={disabled}
        aria-pressed={liked}
        aria-busy={pending}
        aria-label={label}
        onClick={() => {
          toggle();
          onToggle?.(!liked);
        }}
        style={{ touchAction: "manipulation" }}
        className="inline-flex h-9 select-none items-center gap-2 rounded-[9px] border border-stone-200 bg-white px-3 text-[13px] font-medium text-stone-700 outline-none focus-visible:ring-2 focus-visible:ring-stone-400 disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:focus-visible:ring-stone-500"
      >
        <span aria-hidden className="relative block size-[18px]">
          <motion.svg
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth={1.7}
            strokeLinejoin="round"
            className="absolute inset-0 size-[18px] text-stone-500 dark:text-stone-400"
            initial={false}
            animate={{ opacity: liked ? 0 : 1 }}
            transition={reduced ? INSTANT : CROSSFADE}
          >
            <path d={HEART} />
          </motion.svg>

          <motion.svg
            viewBox="0 0 24 24"
            fill="currentColor"
            className="absolute inset-0 size-[18px] text-stone-800 dark:text-stone-100"
            initial={false}
            animate={{ opacity: liked ? 1 : 0, scale: liked ? 1 : 0.55 }}
            transition={reduced ? INSTANT : CELL}
          >
            <path d={HEART} />
          </motion.svg>

          {!reduced && burst > 0 ? (
            <span
              key={burst}
              className="pointer-events-none absolute left-1/2 top-1/2 block size-0"
            >
              {SPARKS.map((spark, i) => (
                <motion.span
                  key={i}
                  className="absolute block rounded-[1.5px] bg-stone-800 dark:bg-stone-100"
                  style={{
                    width: spark.size,
                    height: spark.size,
                    marginLeft: -spark.size / 2,
                    marginTop: -spark.size / 2,
                  }}
                  initial={{ x: 0, y: 0, scale: 0.6, opacity: 0.85 }}
                  animate={{ x: spark.x, y: spark.y, scale: 1, opacity: 0 }}
                  transition={{ duration: 0.44, delay: spark.delay, ease: EASE }}
                />
              ))}
            </span>
          ) : null}
        </span>

        <span aria-hidden className="grid">
          <motion.span
            className="col-start-1 row-start-1"
            initial={false}
            animate={{ opacity: liked ? 0 : 1 }}
            transition={reduced ? INSTANT : CROSSFADE}
          >
            {label}
          </motion.span>
          <motion.span
            className="col-start-1 row-start-1"
            initial={false}
            animate={{ opacity: liked ? 1 : 0 }}
            transition={reduced ? INSTANT : CROSSFADE}
          >
            {activeLabel}
          </motion.span>
        </span>

        <span
          aria-hidden
          className="grid overflow-hidden text-[12px] tabular-nums text-stone-500 dark:text-stone-400"
        >
          <span className="invisible col-start-1 row-start-1">{widest}</span>
          <AnimatePresence initial={false}>
            <motion.span
              key={shown}
              className="col-start-1 row-start-1 justify-self-end"
              initial={{ opacity: 0, y: reduced ? 0 : -7 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: reduced ? 0 : 7 }}
              transition={reduced ? INSTANT : CROSSFADE}
            >
              {shown}
            </motion.span>
          </AnimatePresence>
        </span>

      </button>

      <span role="status" aria-live="polite" className="sr-only">
        {`${format(settled.count)} likes, ${settled.liked ? "liked" : "not liked"}`}
      </span>
    </span>
  );
}

Props

initialLikedfalse
boolean

Server truth at mount. Read once; the hook owns the value afterwards so a re-render never yanks the count back mid-gesture.

initialCount0
number

Server truth at mount. Both reachable counts, base and base + 1, are measured up front to reserve width.

onCommitundefined
(liked: boolean, signal: AbortSignal) => Promise<unknown>

Called once per settled intent, never once per tap. Reject to trigger rollback; the signal aborts when a newer intent supersedes this one.

onErrorundefined
(error: unknown) => void

Fires after the UI has already rolled back to the last confirmed state, so the handler only has to explain, not repair.

onToggleundefined
(liked: boolean) => void

Fires on every tap with the intended state. Use for analytics; do not use for writes.

settle400
number

Milliseconds of quiet before intent is committed. A burst of taps inside this window collapses into at most one request.

label"Like"
string

The button's accessible name in both states; pressed state is carried by aria-pressed, not by the name.

activeLabel"Liked"
string

Visible label when liked. Shares a grid cell with label, so the button width is the wider of the two at all times.

formatIntl.NumberFormat("en-US")
(value: number) => string

Formats the count. Must be pure and locale-fixed; it runs during render on both server and client.

disabledfalse
boolean

Blocks new intent. In-flight commits still settle and still reconcile.

className""
string

Appended last on the wrapper so callers win on every conflicting utility.