Live Activity Dock for React and Tailwind CSS (shadcn/ui): dock tiles carry live state — progress ring, rolling badge, running dot, error shake — and grow a live card out of the tile with title, percent, time left and actions. It is built to show how far a task has got without leaving the page. The icon flies into the card, other tiles dim, Escape closes it. Controlled or uncontrolled expandedId, animated progress and badge updates. Keyboard, aria, reduced motion, light and dark. Use it for exports, uploads, sync and builds. The included demo scripts an export, a badge change and a failed upload with retry, and makes no requests. Requires React only, with no animation library.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Live Activity Dock – Dock Navigation with Progress Rings and Expanding Live Cards to your project and fits it to your theme.
Code
How Live Activity Dock – Dock Navigation with Progress Rings and Expanding Live Cards is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import * as React from "react";
import { CalendarDays, Download, Folder, House, Inbox, RefreshCw, Settings } from "lucide-react";
import { LiveActivityDock } from "@/components/ui/live-activity-dock";
import type { DockActivity, DockItem, DockItemStatus } from "@/components/ui/live-activity-dock";
type ExportPhase = "idle" | "exporting" | "ready";
type UploadPhase = "idle" | "uploading" | "failed" | "retrying" | "uploaded";
type Scene = { t: number; exportPhase: ExportPhase; exportProgress: number; inbox: number; sync?: DockItemStatus; uploadPhase: UploadPhase; uploadProgress: number };
const CYCLE = 18000;
const EXPORT_START = 600;
const EXPORT_END = 4800;
const CARD_OPEN = 1200;
const CARD_CLOSE = 6200;
const BADGE_AT = 3000;
const UPLOAD_START = 9000;
const UPLOAD_FAIL = 10600;
const UPLOAD_CARD_OPEN = 11000;
const RETRY_AT = 12400;
const UPLOAD_END = 14200;
const UPLOAD_CARD_CLOSE = 15400;
const FAIL_PROGRESS = 0.62;
const PRESETS = { idle: 0, exporting: 1800, ready: 5200, error: 11400, uploaded: 14600 } as const;
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const easeOut = (x: number) => 1 - (1 - Math.min(1, Math.max(0, x))) ** 3;
function sceneAt(t: number): Scene {
const exportPhase: ExportPhase = t < EXPORT_START ? "idle" : t < EXPORT_END ? "exporting" : "ready";
const exportProgress = exportPhase === "idle" ? 0 : exportPhase === "ready" ? 1 : easeOut((t - EXPORT_START) / (EXPORT_END - EXPORT_START));
const sync: DockItemStatus | undefined = t >= UPLOAD_CARD_CLOSE + 1000 ? undefined : t >= UPLOAD_CARD_CLOSE ? "done" : t >= CARD_CLOSE ? "running" : undefined;
let uploadPhase: UploadPhase = "idle";
let uploadProgress = 0;
if (t >= UPLOAD_END) { uploadPhase = "uploaded"; uploadProgress = 1; }
else if (t >= RETRY_AT) { uploadPhase = "retrying"; uploadProgress = FAIL_PROGRESS + (1 - FAIL_PROGRESS) * easeOut((t - RETRY_AT) / (UPLOAD_END - RETRY_AT)); }
else if (t >= UPLOAD_FAIL) { uploadPhase = "failed"; uploadProgress = FAIL_PROGRESS; }
else if (t >= UPLOAD_START) { uploadPhase = "uploading"; uploadProgress = FAIL_PROGRESS * ((t - UPLOAD_START) / (UPLOAD_FAIL - UPLOAD_START)); }
return { t, exportPhase, exportProgress, inbox: t >= BADGE_AT ? 3 : 2, sync, uploadPhase, uploadProgress };
}
function expandedAt(t: number) {
if (t >= CARD_OPEN && t < CARD_CLOSE) return "exports";
if (t >= UPLOAD_CARD_OPEN && t < UPLOAD_CARD_CLOSE) return "files";
return null;
}
const secondsLeft = (until: number, t: number) => `${Math.max(1, Math.ceil((until - t) / 1000))}s left`;
function Mockup() {
return (
<div aria-hidden="true" data-mockup className="pointer-events-none absolute inset-x-0 top-0 mx-auto w-full max-w-[720px] px-8 pt-10 select-none">
<div className="h-3.5 w-44 rounded-full bg-muted" />
<div className="mt-3 h-2.5 w-72 rounded-full bg-muted/70" />
<div className="mt-8 grid grid-cols-3 gap-4">
{[0, 1, 2].map((index) => (
<div key={index} className="h-32 rounded-2xl bg-muted/40 p-4 ring-1 ring-border/60">
<div className="h-2 w-16 rounded-full bg-muted" />
<div className="mt-5 h-5 w-24 rounded-md bg-muted/80" />
<div className="mt-3 h-2 w-full rounded-full bg-muted/70" />
</div>
))}
</div>
<div className="mt-4 rounded-2xl bg-muted/30 p-4 ring-1 ring-border/60">
{[0, 1, 2, 3].map((index) => (
<div key={index} className={`flex items-center gap-4 ${index > 0 ? "mt-3.5" : ""}`}>
<div className="size-5 rounded-full bg-muted" />
<div className="h-2 flex-1 rounded-full bg-muted/80" />
<div className="h-2 w-16 rounded-full bg-muted/60" />
</div>
))}
</div>
</div>
);
}
export default function Demo({ autoplay = true }: { autoplay?: boolean }) {
const params = React.useMemo(() => new URLSearchParams(typeof window === "undefined" ? "" : window.location.search), []);
const presetName = params.get("scene");
const preset = presetName && presetName in PRESETS ? PRESETS[presetName as keyof typeof PRESETS] : presetName === "edge" ? PRESETS.idle : null;
const edge = presetName === "edge";
const uncontrolled = params.get("uncontrolled") === "1";
const locked = params.get("locked") === "1";
const scripted = autoplay && preset === null && !uncontrolled && !locked;
const initialExpanded = edge ? "home" : preset !== null ? expandedAt(preset) : null;
const [scene, setScene] = React.useState(() => sceneAt(preset ?? 0));
const [expanded, setExpanded] = React.useState(initialExpanded);
const [paused, setPaused] = React.useState(false);
const [entered, setEntered] = React.useState(false);
const [requests, setRequests] = React.useState([] as string[]);
const [motion, setMotion] = React.useState({ ready: false, reduced: true });
const stageRef = React.useRef(null as HTMLDivElement | null);
const manual = React.useRef(0);
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setMotion({ ready: true, reduced: media.matches });
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
React.useEffect(() => {
if (!motion.ready) return;
const node = stageRef.current;
if (scripted && !motion.reduced && node && typeof node.animate === "function") {
node.animate([{ opacity: 0, transform: "translateY(24px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 420, easing: EASE });
}
setEntered(true);
}, [motion.ready, motion.reduced, scripted]);
React.useEffect(() => {
if (!motion.ready || !scripted || paused) return;
const start = performance.now();
let previous = 0;
const tick = () => {
const t = (performance.now() - start) % CYCLE;
setScene(sceneAt(t));
const next = expandedAt(t);
if (expandedAt(previous) !== next) setExpanded(next);
previous = t;
};
tick();
const id = window.setInterval(tick, 100);
return () => window.clearInterval(id);
}, [motion.ready, scripted, paused]);
React.useEffect(() => () => window.clearInterval(manual.current), []);
const takeOver = () => { if (scripted) setPaused(true); };
const retryUpload = () => {
window.clearInterval(manual.current);
const begin = performance.now();
setScene((current) => ({ ...current, uploadPhase: "retrying" }));
manual.current = window.setInterval(() => {
const x = Math.min(1, (performance.now() - begin) / 1800);
setScene((current) => ({ ...current, uploadPhase: x >= 1 ? "uploaded" : "retrying", uploadProgress: FAIL_PROGRESS + (1 - FAIL_PROGRESS) * easeOut(x) }));
if (x >= 1) window.clearInterval(manual.current);
}, 100);
};
const close = () => setExpanded(null);
const cancelExport = () => { setScene((current) => ({ ...current, exportPhase: "idle", exportProgress: 0 })); close(); };
const exportActivity: DockActivity | undefined =
scene.exportPhase === "exporting"
? { title: "Exporting Q4 report", detail: secondsLeft(EXPORT_END, scene.t), progress: scene.exportProgress, actions: [{ label: "Cancel", tone: "ghost", onClick: cancelExport }] }
: scene.exportPhase === "ready"
? { title: "Export ready", detail: "Q4-report.pdf · 2.4 MB", progress: 1, actions: [{ label: "Open", tone: "primary", onClick: close }] }
: undefined;
const uploadActivity: DockActivity | undefined =
scene.uploadPhase === "uploading" || scene.uploadPhase === "retrying"
? { title: "Uploading 3 files", detail: `${Math.min(3, 1 + Math.floor(scene.uploadProgress * 3))} of 3`, progress: scene.uploadProgress, actions: [{ label: "Cancel", tone: "ghost", onClick: close }] }
: scene.uploadPhase === "failed"
? { title: "Upload failed", detail: "Connection lost", progress: scene.uploadProgress, actions: [{ label: "Dismiss", tone: "ghost", onClick: close }, { label: "Retry", tone: "primary", onClick: retryUpload }] }
: scene.uploadPhase === "uploaded"
? { title: "3 files uploaded", detail: "Shared folder · 18.4 MB", progress: 1, actions: [{ label: "Open", tone: "primary", onClick: close }] }
: undefined;
const items: DockItem[] = [
edge
? { id: "home", label: "Home", icon: <House aria-hidden="true" />, progress: 0.41, activity: { title: "Backing up workspace", detail: "4 min left", progress: 0.41, actions: [{ label: "Pause", tone: "ghost", onClick: close }] } }
: { id: "home", label: "Home", icon: <House aria-hidden="true" /> },
{ id: "inbox", label: "Inbox", icon: <Inbox aria-hidden="true" />, badge: scene.inbox },
{ id: "files", label: "Files", icon: <Folder aria-hidden="true" />, progress: scene.uploadPhase === "idle" ? undefined : scene.uploadProgress, status: scene.uploadPhase === "failed" ? "error" : undefined, activity: uploadActivity },
{ id: "exports", label: "Exports", icon: <Download aria-hidden="true" />, progress: scene.exportPhase === "idle" ? undefined : scene.exportProgress, activity: exportActivity },
{ id: "calendar", label: "Calendar", icon: <CalendarDays aria-hidden="true" /> },
{ id: "sync", label: "Sync", icon: <RefreshCw aria-hidden="true" />, status: scene.sync },
{ id: "settings", label: "Settings", icon: <Settings aria-hidden="true" /> },
];
const expandedProps = uncontrolled
? { defaultExpandedId: initialExpanded }
: locked
? { expandedId: initialExpanded, onExpandedChange: (id: string | null) => setRequests((current) => [...current, id ?? "null"]) }
: { expandedId: expanded, onExpandedChange: setExpanded };
return (
<main
data-demo-stage
className="relative flex w-full flex-col items-center justify-end overflow-hidden bg-background text-foreground"
style={{ height: "min(100svh, 760px)" }}
onPointerDownCapture={takeOver}
onKeyDownCapture={takeOver}
>
<Mockup />
<div ref={stageRef} data-dock-enter className={`relative ${params.get("position") === "top" ? "mb-auto mt-8" : "mb-8"}`} style={{ opacity: entered ? 1 : 0 }}>
<LiveActivityDock
items={items}
aria-label="Alder Studio"
position={params.get("position") === "top" ? "top" : "bottom"}
reflection={params.get("reflection") !== "0"}
compact={params.get("compact") === "1"}
{...expandedProps}
/>
</div>
{locked && <output data-requests className="sr-only">{requests.join(" ")}</output>}
</main>
);
}
















