Notification·04.1
Live Activity
The system's ongoing work, worn as a small object.
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionUsage
"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 (
<>
<div className="pointer-events-none fixed inset-x-0 top-3 z-50">
<LiveActivity activity={pod.activity} onDismiss={pod.dismiss} />
</div>
{children}
</>
);
}Source
"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<Activity | null>(null);
const seq = useRef(0);
const timer = useRef<ReturnType<typeof setTimeout> | 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<ActivityInput>) => {
setActivity((prev) => (prev ? { ...prev, ...patch } : prev));
}, []);
const dismiss = useCallback(() => {
clear();
setActivity(null);
}, [clear]);
const succeed = useCallback(
(patch?: Partial<ActivityInput>) => {
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<ActivityInput>, 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<typeof useLiveActivity>;
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<HTMLDivElement>(null);
const expandedRef = useRef<HTMLDivElement>(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<ReturnType<typeof setTimeout> | null>(null);
const peekTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const said = useRef<Set<string>>(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 (
<div
role="region"
aria-label={label}
className={`pointer-events-none flex justify-center ${className}`}
>
<AnimatePresence initial={false}>
{activity ? (
<motion.div
key="pod"
initial={
reduced
? { opacity: 0 }
: { opacity: 0, y: -10, scale: 0.9, filter: "blur(6px)" }
}
animate={{
opacity: 1,
y: 0,
scale: 1,
filter: "blur(0px)",
width: dims?.w,
height: dims?.h,
}}
exit={
reduced
? { opacity: 0, transition: INSTANT }
: {
opacity: 0,
y: -8,
scale: 0.97,
filter: "blur(3px)",
transition: { duration: 0.16, ease: LEAVE },
}
}
transition={
reduced
? INSTANT
: {
...SURFACE,
opacity: { duration: 0.2, ease: EASE },
filter: { duration: 0.2, ease: EASE },
}
}
style={{ transformOrigin: "50% 0%" }}
onPointerEnter={enter}
onPointerLeave={leave}
onFocusCapture={() => 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)]"
>
<motion.div
ref={compactRef}
aria-hidden={expanded}
inert={expanded}
initial={false}
animate={{ opacity: expanded ? 0 : 1 }}
transition={reduced ? INSTANT : CROSSFADE}
className={`absolute left-0 top-0 flex h-8 w-max items-center gap-1.5 px-2.5 ${face(!expanded)}`}
>
<PhaseGlyph phase={phase} reduced={reduced} />
{percent !== null && phase === "running" ? (
<span className="font-mono text-[10.5px] tabular-nums text-stone-500 dark:text-stone-400">
{percent}%
</span>
) : (
<span className="max-w-[120px] truncate text-[12px] font-medium text-stone-700 dark:text-stone-200">
{activity.title}
</span>
)}
</motion.div>
<motion.div
ref={expandedRef}
aria-hidden={!expanded}
inert={!expanded}
initial={false}
animate={{ opacity: expanded ? 1 : 0 }}
transition={reduced ? INSTANT : CROSSFADE}
style={{ width }}
className={`absolute left-0 top-0 px-3.5 py-3 ${face(expanded)}`}
>
<div className="flex items-center gap-2">
<PhaseGlyph phase={phase} reduced={reduced} />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-stone-700 dark:text-stone-100">
{activity.title}
</span>
{activity.action ? (
<button
type="button"
tabIndex={expanded ? 0 : -1}
onClick={activity.action.onClick}
className="inline-flex h-[24px] shrink-0 select-none items-center whitespace-nowrap rounded-[6px] border border-stone-200 bg-white px-2 text-[11px] font-medium text-stone-700 shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),inset_0_-1px_0_rgba(28,25,23,0.06),0_1px_2px_rgba(28,25,23,0.08)] outline-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-stone-50 focus-visible:border-[#4568FF] active:translate-y-px dark:border-white/[0.16] dark:bg-[#2A2A27] dark:text-stone-100 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:bg-[#32322E] dark:focus-visible:border-[#93B0FF]"
>
{activity.action.label}
</button>
) : null}
{onDismiss && phase !== "running" ? (
<button
type="button"
tabIndex={expanded ? 0 : -1}
aria-label={dismissLabel}
onClick={onDismiss}
className="grid size-[22px] shrink-0 place-items-center rounded-[6px] text-stone-400 transition-colors duration-150 hover:bg-stone-100 hover:text-stone-700 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] focus-visible:outline-none dark:text-stone-500 dark:hover:bg-white/10 dark:hover:text-stone-100 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"
>
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M2.8 2.8l6.4 6.4M9.2 2.8l-6.4 6.4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</button>
) : null}
</div>
{activity.detail ? (
<p className="mt-1 truncate pl-[26px] text-[11.5px] text-stone-500 dark:text-stone-400">
{activity.detail}
</p>
) : null}
{percent !== null && phase !== "error" ? (
<div className="mt-2.5 flex items-center gap-2 pl-[26px]">
<div className="min-w-0 flex-1 rounded-[4px] bg-stone-200/60 p-[2px] shadow-[inset_0_1px_2px_rgba(28,25,23,0.1)] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]">
<div className="relative h-[4px] overflow-hidden rounded-[2px]">
<motion.span
aria-hidden
className="absolute inset-0 block origin-left rounded-[2px] bg-[#4568FF] shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] dark:bg-[#93B0FF]"
initial={false}
animate={{ scaleX: (percent ?? 0) / 100 }}
transition={reduced ? INSTANT : FILL}
/>
</div>
</div>
<span className="shrink-0 font-mono text-[10.5px] tabular-nums text-stone-500 dark:text-stone-400">
{percent}%
</span>
</div>
) : null}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
<span role="status" aria-live="polite" className="sr-only">
{spoken}
</span>
</div>
);
}
function PhaseGlyph({ phase, reduced }: { phase: ActivityPhase; reduced: boolean }) {
return (
<span className="grid size-[18px] shrink-0 place-items-center">
<motion.span
className="col-start-1 row-start-1 flex"
initial={false}
animate={{
opacity: phase === "running" ? 1 : 0,
scale: reduced ? 1 : phase === "running" ? 1 : 0.7,
}}
transition={reduced ? INSTANT : SMALL}
>
{reduced ? (
<svg width="13" height="13" viewBox="0 0 12 12" aria-hidden>
<circle cx="6" cy="6" r="4.4" stroke="currentColor" strokeWidth="1.6" fill="none" className="text-stone-400 dark:text-stone-500" opacity="0.4" />
</svg>
) : (
<motion.svg
width="13"
height="13"
viewBox="0 0 12 12"
aria-hidden
style={{ transformOrigin: "50% 50%" }}
animate={{ rotate: 360 }}
transition={SPIN}
className="text-[#4568FF] dark:text-[#93B0FF]"
>
<circle cx="6" cy="6" r="4.4" stroke="currentColor" strokeWidth="1.6" fill="none" opacity="0.25" />
<path d="M6 1.6a4.4 4.4 0 0 1 4.4 4.4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" fill="none" />
</motion.svg>
)}
</motion.span>
<motion.span
className="col-start-1 row-start-1 flex text-emerald-600 dark:text-emerald-400"
initial={false}
animate={{
opacity: phase === "success" ? 1 : 0,
scale: reduced ? 1 : phase === "success" ? 1 : 0.7,
}}
transition={reduced ? INSTANT : SMALL}
>
<svg width="13" height="13" viewBox="0 0 12 12" fill="none" aria-hidden>
<motion.path
d="M2.4 6.4 4.8 8.8 9.6 3.4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: phase === "success" ? 1 : 0 }}
transition={reduced ? INSTANT : DRAW}
/>
</svg>
</motion.span>
<motion.span
className="col-start-1 row-start-1 flex text-red-600 dark:text-red-400"
initial={false}
animate={{
opacity: phase === "error" ? 1 : 0,
scale: reduced ? 1 : phase === "error" ? 1 : 0.7,
}}
transition={reduced ? INSTANT : SMALL}
>
<svg width="13" height="13" viewBox="0 0 12 12" fill="none" aria-hidden>
<path d="M6 2.6v3.6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<rect x="5.2" y="8.2" width="1.6" height="1.6" rx="0.4" fill="currentColor" />
</svg>
</motion.span>
</span>
);
}Props
activityActivity | nullThe one thing the system is doing, from useLiveActivity. Null means the pod is gone, not hidden.
onDismiss() => voidWired to the close affordance and Escape once the work has resolved.
width300numberExpanded width in pixels. The pod stays a small object over the page; it never spans it.
dismissLabel"Dismiss activity"stringAccessible name of the close button.
label"Activity"stringAccessible name of the region.
useLiveActivity.linger2000numberHow long a finished activity stays before the pod leaves on its own.
className""stringAppended to the anchoring wrapper. Position it fixed at the top of your app.