## Like Burst — Action Feedback Optimistic like that survives rapid taps. Docs: https://www.interior.dev/docs/like-burst Reference: https://www.interior.dev/reference/like-burst License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/like-burst.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { LikeBurst } from "@/components/interior/like-burst"; export function PostActions({ postId, likes, liked }: { postId: string; likes: number; liked: boolean }) { return ( { 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")} /> ); } ``` ### Props - `initialLiked` (`boolean`) — default: `false` Server truth at mount. Read once; the hook owns the value afterwards so a re-render never yanks the count back mid-gesture. - `initialCount` (`number`) — default: `0` Server truth at mount. Both reachable counts, base and base + 1, are measured up front to reserve width. - `onCommit` (`(liked: boolean, signal: AbortSignal) => Promise`) — default: `undefined` Called once per settled intent, never once per tap. Reject to trigger rollback; the signal aborts when a newer intent supersedes this one. - `onError` (`(error: unknown) => void`) — default: `undefined` Fires after the UI has already rolled back to the last confirmed state, so the handler only has to explain, not repair. - `onToggle` (`(liked: boolean) => void`) — default: `undefined` Fires on every tap with the intended state. Use for analytics; do not use for writes. - `settle` (`number`) — default: `400` Milliseconds of quiet before intent is committed. A burst of taps inside this window collapses into at most one request. - `label` (`string`) — default: `"Like"` The button's accessible name in both states; pressed state is carried by aria-pressed, not by the name. - `activeLabel` (`string`) — default: `"Liked"` Visible label when liked. Shares a grid cell with label, so the button width is the wider of the two at all times. - `format` (`(value: number) => string`) — default: `Intl.NumberFormat("en-US")` Formats the count. Must be pure and locale-fixed; it runs during render on both server and client. - `disabled` (`boolean`) — default: `false` Blocks new intent. In-flight commits still settle and still reconcile. - `className` (`string`) — default: `""` Appended last on the wrapper so callers win on every conflicting utility. ### Behavior notes - Nine taps produce one request. Intent is debounced by settle, and a burst that returns to the confirmed state sends nothing at all, so a double tap is not a write followed by an undo write. - Responses that arrive out of order cannot win. Every flush increments a sequence number and aborts the previous controller, so a slow unlike landing after a fast like is discarded rather than applied. - A rejected commit rolls the count and the fill back to the last confirmed value, not to zero and not to a guess, so the number on screen is never a lie the user has to reload to discover. - The button never changes width. Both labels share one grid cell, and the count cell reserves the wider of base and base + 1 up front, so a like at 999 does not shove the row. - Screen readers get the settled value once from a polite status region; the optimistic count and the burst are aria-hidden, so a fast tapper does not queue nine announcements. - Under prefers-reduced-motion the sparks are not rendered and the fill switches with no transition. The state still arrives, only the trip is skipped. ### Source (`components/interior/like-burst.tsx`) ```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; 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 | null>(null); const inFlight = useRef(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 }) { 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 ( {`${format(settled.count)} likes, ${settled.liked ? "liked" : "not liked"}`} ); } ```