DHxWhy

Live Activity Dock – Dock Navigation with Progress Rings and Expanding Live Cards

Navigation

About

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>
  );
}

Dependencies

More by DHxWhy

View all
  • Product Rail Gallery – Fashion Store Hero Section

    Product Rail Gallery – Fashion Store Hero Section

    2.2K
  • Sushi Conveyor Belt Menu – Restaurant Hero Section

    Sushi Conveyor Belt Menu – Restaurant Hero Section

    900
  • Team Lanyard Rail – Meet the Team Section with Flip Badges

    Team Lanyard Rail – Meet the Team Section with Flip Badges

    851
  • Skateboard Deck Rail Gallery – Skate Shop Hero Section

    Skateboard Deck Rail Gallery – Skate Shop Hero Section

    444
  • Sun Moon Dark Mode Theme Toggle

    Sun Moon Dark Mode Theme Toggle

    331
  • Picture Rail Image Gallery – Art Print Shop Hero Section

    Picture Rail Image Gallery – Art Print Shop Hero Section

    280
  • Pendant Lamp Rail Gallery – Lighting Store Hero Section

    Pendant Lamp Rail Gallery – Lighting Store Hero Section

    248
  • World Testimonials Globe – Social Proof Hero Section

    World Testimonials Globe – Social Proof Hero Section

    222

More in Navigation

View all
  • Sidebar

    Sidebar

    342
  • Pagination

    Pagination

    99
  • Oversized 404 Numeral

    Oversized 404 Numeral

    68
  • Stacked Circular Footer

    Stacked Circular Footer

    443
  • Navbar

    Navbar

    6
  • Navbar

    Navbar

    75
  • Tabs

    Tabs

    33
  • Tabs like bookmark

    Tabs like bookmark

    73