## Live Activity — Notification The system's ongoing work, worn as a small object. Docs: https://www.interior.dev/docs/live-activity Reference: https://www.interior.dev/reference/live-activity 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/live-activity.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { LiveActivity, useLiveActivity } from "@/components/interior/live-activity"; export function UploadShell({ children }: { children: React.ReactNode }) { const pod = useLiveActivity(); async function upload(file: File) { pod.start({ title: "Uploading", detail: file.name, progress: 0 }); try { await sendInChunks(file, (done) => pod.update({ progress: done })); pod.succeed({ detail: `${file.name} is live` }); } catch { pod.fail({ detail: "Connection lost" }, { label: "Retry", onClick: () => upload(file) }); } } return ( <>
{children} ); } ``` ### Props - `activity` (`Activity | null`) The one thing the system is doing, from useLiveActivity. Null means the pod is gone, not hidden. - `onDismiss` (`() => void`) Wired to the close affordance and Escape once the work has resolved. - `width` (`number`) — default: `300` Expanded width in pixels. The pod stays a small object over the page; it never spans it. - `dismissLabel` (`string`) — default: `"Dismiss activity"` Accessible name of the close button. - `label` (`string`) — default: `"Activity"` Accessible name of the region. - `useLiveActivity.linger` (`number`) — default: `2000` How long a finished activity stays before the pod leaves on its own. - `className` (`string`) — default: `""` Appended to the anchoring wrapper. Position it fixed at the top of your app. ### Behavior notes - One pod, not a stack. The latest work replaces the last, because a system genuinely doing four things deserves a page, not a pile of chips — this is the honest difference from a toast. - The surface itself morphs: both faces are always mounted, measured with a ResizeObserver behind an epsilon, and the pod springs its width and height between them — nothing is re-laid-out, nothing pops. - It peeks on every phase change, holds long enough to read, then folds back to a glyph; hover, focus or a failure keep it open, and Escape folds or dismisses depending on whether the work is done. - Progress is worn quietly in the compact face as a tabular percentage and fully in the expanded face as the 4/2 track; an indeterminate wait is a spinner, never a guessed meter. - Success draws its tick and leaves on its own; failure stays, states the reason, and keeps a live Retry — the pod never auto-dismisses bad news. - One announcement per phase per activity, deduplicated the same way the reference toast did it; screen readers hear started, finished, failed — never a progress stream. - Under prefers-reduced-motion the morph lands instantly, the spinner becomes a still ring and the tick appears whole; the pod still tells the same story. ### Source (`components/interior/live-activity.tsx`) ```tsx "use client"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const SURFACE = { type: "spring", stiffness: 420, damping: 36, mass: 0.9 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const SMALL = { type: "spring", stiffness: 700, damping: 46, mass: 0.5 } as const; const FILL = { type: "spring", stiffness: 210, damping: 34, mass: 0.9 } as const; const EASE = [0.23, 1, 0.32, 1] as const; const LEAVE = [0.4, 0, 1, 1] as const; const DRAW = { duration: 0.3, ease: EASE } as const; const INSTANT = { duration: 0 } as const; const SPIN = { duration: 0.85, ease: "linear", repeat: Infinity } as const; const PEEK_FOR = 2600; const LEAVE_DELAY = 160; const face = (on: boolean) => (on ? "" : "pointer-events-none"); const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; export type ActivityPhase = "running" | "success" | "error"; export type Activity = { id: string; title: string; detail?: string; progress?: number | null; phase: ActivityPhase; action?: { label: string; onClick: () => void }; }; export type ActivityInput = { title: string; detail?: string; progress?: number | null; }; export type UseLiveActivityOptions = { linger?: number; }; export function useLiveActivity({ linger = 2000 }: UseLiveActivityOptions = {}) { const [activity, setActivity] = useState(null); const seq = useRef(0); const timer = useRef | null>(null); const clear = useCallback(() => { if (timer.current) clearTimeout(timer.current); timer.current = null; }, []); const start = useCallback( (input: ActivityInput) => { clear(); seq.current += 1; const id = `activity-${seq.current}`; setActivity({ progress: null, ...input, id, phase: "running" }); return id; }, [clear], ); const update = useCallback((patch: Partial) => { setActivity((prev) => (prev ? { ...prev, ...patch } : prev)); }, []); const dismiss = useCallback(() => { clear(); setActivity(null); }, [clear]); const succeed = useCallback( (patch?: Partial) => { setActivity((prev) => prev ? { ...prev, ...patch, phase: "success", progress: 1 } : prev, ); clear(); timer.current = setTimeout(() => { timer.current = null; setActivity(null); }, linger); }, [clear, linger], ); const fail = useCallback( (patch?: Partial, action?: Activity["action"]) => { clear(); setActivity((prev) => prev ? { ...prev, ...patch, phase: "error", action } : prev, ); }, [clear], ); useEffect(() => clear, [clear]); return { activity, start, update, succeed, fail, dismiss }; } export type UseLiveActivityReturn = ReturnType; export type LiveActivityProps = { activity: Activity | null; onDismiss?: () => void; width?: number; dismissLabel?: string; label?: string; className?: string; }; export function LiveActivity({ activity, onDismiss, width = 300, dismissLabel = "Dismiss activity", label = "Activity", className = "", }: LiveActivityProps) { const reduced = useReducedMotion() === true; const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const [peeking, setPeeking] = useState(false); const [spoken, setSpoken] = useState(""); const compactRef = useRef(null); const expandedRef = useRef(null); const sizes = useRef({ c: { w: 0, h: 0 }, e: { w: 0, h: 0 } }); const [dims, setDims] = useState<{ w: number; h: number } | null>(null); const leaveTimer = useRef | null>(null); const peekTimer = useRef | null>(null); const said = useRef>(new Set()); const phase = activity?.phase ?? "running"; const expanded = activity !== null && (hovered || focused || peeking || phase === "error"); const apply = useCallback((open: boolean) => { const target = open ? sizes.current.e : sizes.current.c; if (target.w === 0 || target.h === 0) return; setDims((prev) => prev && Math.abs(prev.w - target.w) < 0.5 && Math.abs(prev.h - target.h) < 0.5 ? prev : { w: target.w, h: target.h }, ); }, []); useIsoLayoutEffect(() => { if (!activity) return; const read = () => { const c = compactRef.current; const e = expandedRef.current; if (c) sizes.current.c = { w: c.offsetWidth, h: c.offsetHeight }; if (e) sizes.current.e = { w: e.offsetWidth, h: e.offsetHeight }; apply(expanded); }; read(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(read); if (compactRef.current) observer.observe(compactRef.current); if (expandedRef.current) observer.observe(expandedRef.current); return () => observer.disconnect(); }, [activity, expanded, apply]); useEffect(() => { if (!activity) { setPeeking(false); setHovered(false); setFocused(false); setDims(null); return; } setPeeking(true); if (peekTimer.current) clearTimeout(peekTimer.current); peekTimer.current = setTimeout(() => { peekTimer.current = null; setPeeking(false); }, PEEK_FOR); }, [activity?.id, activity?.phase, activity]); useEffect( () => () => { if (leaveTimer.current) clearTimeout(leaveTimer.current); if (peekTimer.current) clearTimeout(peekTimer.current); }, [], ); useEffect(() => { if (!activity) return; const key = `${activity.id}:${activity.phase}`; if (said.current.has(key)) return; if (said.current.size > 64) said.current.clear(); said.current.add(key); setSpoken( activity.phase === "running" ? `${activity.title} started.` : activity.phase === "success" ? `${activity.title} finished.` : `${activity.title} failed.`, ); }, [activity]); const enter = () => { if (leaveTimer.current) clearTimeout(leaveTimer.current); leaveTimer.current = null; setHovered(true); }; const leave = () => { if (leaveTimer.current) clearTimeout(leaveTimer.current); leaveTimer.current = setTimeout(() => { leaveTimer.current = null; setHovered(false); }, LEAVE_DELAY); }; const percent = activity?.progress === null || activity?.progress === undefined ? null : Math.round(Math.min(1, Math.max(0, activity.progress)) * 100); return (
{activity ? ( setFocused(true)} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setFocused(false); } }} onKeyDown={(e) => { if (e.key !== "Escape") return; e.preventDefault(); if (phase === "running") setHovered(false); else onDismiss?.(); }} className="pointer-events-auto relative overflow-hidden rounded-[11px] border border-stone-200 bg-white shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),0_1px_2px_rgba(28,25,23,0.07),0_16px_36px_-18px_rgba(28,25,23,0.5)] dark:border-white/[0.16] dark:bg-[#252522] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_2px_12px_rgba(0,0,0,0.55)]" > {percent !== null && phase === "running" ? ( {percent}% ) : ( {activity.title} )}
{activity.title} {activity.action ? ( ) : null} {onDismiss && phase !== "running" ? ( ) : null}
{activity.detail ? (

{activity.detail}

) : null} {percent !== null && phase !== "error" ? (
{percent}%
) : null}
) : null}
{spoken}
); } function PhaseGlyph({ phase, reduced }: { phase: ActivityPhase; reduced: boolean }) { return ( {reduced ? ( ) : ( )} ); } ```