## 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" ? (
) : null}
) : null}
{spoken}
);
}
function PhaseGlyph({ phase, reduced }: { phase: ActivityPhase; reduced: boolean }) {
return (
{reduced ? (
) : (
)}
);
}
```