Queue of long-running agent jobs with live progress bars, ETA, cancel and retry, an overall progress ring and a completion toast.
Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.
$pnpm dlx shadcn@latest add @nextui/background-tasks
1{2"registries": {3"@nextui": {4"url": "https://nextui.dev/r/{name}.json",5"headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }6}7}8}
1"use client";23import { BackgroundTasks, type BackgroundTask } from "@/components/nextui/background-tasks";45export function JobQueue({ tasks }: { tasks: BackgroundTask[] }) {6return <BackgroundTasks tasks={tasks} onCancel={cancelJob} onRetry={retryJob} />;7}
| Prop | Type | Default | Description |
|---|---|---|---|
| tasks | BackgroundTask[] | – | Jobs with id, title, status (queued, running, done, failed, canceled), progress 0–1, detail and eta |
| title | string | "Background tasks" | Header title |
| defaultOpen | boolean | true | Start with the list expanded |
| onCancel | (id: string) => void | – | Called from a running or queued task's cancel button |
| onRetry | (id: string) => void | – | Called from a failed task's Retry button |
| onOpen | (id: string) => void | – | Called from the completion toast's Open button |
| autoPlay | boolean | false | Loop the scripted demo; it stops on interaction and pauses offscreen |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, Clock, FileText, Hourglass, Layers, LoaderCircle, RotateCw, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type BackgroundTaskStatus = "queued" | "running" | "done" | "failed" | "canceled";
export type BackgroundTask = {
id: string;
title: string;
status: BackgroundTaskStatus;
/** 0–1. */
progress?: number;
/** Status detail, e.g. "312 / 480 pages" or "3 specs failed". */
detail?: string;
eta?: string;
};
export type BackgroundTasksProps = {
tasks?: BackgroundTask[];
title?: string;
defaultOpen?: boolean;
onCancel?: (id: string) => void;
onRetry?: (id: string) => void;
onOpen?: (id: string) => void;
/** Loop a scripted run: one task finishes, a queued one starts, a failed one is retried. */
autoPlay?: boolean;
className?: string;
};
const DEFAULT_TASKS: BackgroundTask[] = [
{ id: "index", title: "Re-index docs workspace", status: "running", progress: 0.64, detail: "312 / 480 pages", eta: "40s left" },
{ id: "notes", title: "Draft release notes v2.8", status: "running", progress: 0.31, detail: "Reading 42 merged PRs", eta: "2m left" },
{ id: "e2e", title: "Run e2e suite · 48 specs", status: "failed", progress: 0.9, detail: "3 specs failed" },
{ id: "export", title: "Export Q4 report as PDF", status: "queued", detail: "Waiting for a free worker" },
];
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 10400;
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop };
}
const clamp = (n: number) => Math.max(0, Math.min(1, n));
function demoTasks(t: number): BackgroundTask[] {
const idx = clamp(0.64 + (t / 3600) * 0.36);
const indexDone = t >= 3600;
const pages = Math.round(idx * 480);
const notes = clamp(0.31 + (t / 10000) * 0.55);
const exp = clamp((t - 6200) / 3400);
const retry = t >= 6400;
const e2e = clamp((t - 6600) / 5000);
return [
indexDone
? { id: "index", title: "Re-index docs workspace", status: "done", progress: 1, detail: "480 pages · 2,914 chunks" }
: { id: "index", title: "Re-index docs workspace", status: "running", progress: idx, detail: `${pages} / 480 pages`, eta: `${Math.max(1, Math.round((1 - idx) * 110))}s left` },
{ id: "notes", title: "Draft release notes v2.8", status: "running", progress: notes, detail: notes > 0.6 ? "Grouping changes by area" : "Reading 42 merged PRs", eta: `${Math.max(1, Math.round((1 - notes) * 3))}m left` },
retry
? { id: "e2e", title: "Run e2e suite · 48 specs", status: "running", progress: e2e, detail: `Retrying 3 failed specs · ${Math.min(3, Math.floor(e2e * 4))}/3`, eta: "~20s left" }
: { id: "e2e", title: "Run e2e suite · 48 specs", status: "failed", progress: 0.9, detail: "3 specs failed · checkout.spec.ts" },
exp >= 1
? { id: "export", title: "Export Q4 report as PDF", status: "done", progress: 1, detail: "24 pages · 3.1 MB" }
: t >= 6200
? { id: "export", title: "Export Q4 report as PDF", status: "running", progress: exp, detail: `Rendering page ${Math.max(1, Math.round(exp * 24))} of 24`, eta: `${Math.max(1, Math.round((1 - exp) * 60))}s left` }
: { id: "export", title: "Export Q4 report as PDF", status: "queued", detail: "Waiting for a free worker", eta: "next in line" },
];
}
const STATUS = {
queued: { icon: Hourglass, tile: "bg-muted text-muted-foreground", bar: "bg-muted-foreground/30", label: "Queued" },
running: { icon: LoaderCircle, tile: "bg-sky-500/10 text-sky-600 dark:text-sky-400", bar: "bg-gradient-to-r from-sky-500 to-indigo-500", label: "Running" },
done: { icon: Check, tile: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", bar: "bg-emerald-500", label: "Done" },
failed: { icon: X, tile: "bg-rose-500/10 text-rose-600 dark:text-rose-400", bar: "bg-rose-500", label: "Failed" },
canceled: { icon: X, tile: "bg-muted text-muted-foreground", bar: "bg-muted-foreground/30", label: "Canceled" },
} as const;
/** Queue of long-running agent jobs with live progress, ETA, cancel/retry, and a completion toast. */
export function BackgroundTasks({ tasks: tasksProp, title = "Background tasks", defaultOpen = true, onCancel, onRetry, onOpen, autoPlay = false, className }: BackgroundTasksProps) {
const reduce = useReducedMotion() ?? false;
const [touched, setTouched] = useState(false);
const [openState, setOpen] = useState(defaultOpen);
const [local, setLocal] = useState<Record<string, Partial<BackgroundTask>>>({});
const [dismissed, setDismissed] = useState<string | null>(null);
const [frozenT, setFrozenT] = useState<number | null>(null);
const demo = autoPlay && !reduce && !touched && !tasksProp;
const { ref, t } = useDemoClock(demo, LOOP);
const base = demo ? demoTasks(t) : (tasksProp ?? (frozenT !== null ? demoTasks(frozenT) : DEFAULT_TASKS));
const tasks = base.map((task) => ({ ...task, ...local[task.id] }));
const open = openState;
const running = tasks.filter((x) => x.status === "running").length;
const queued = tasks.filter((x) => x.status === "queued").length;
const active = tasks.filter((x) => x.status !== "canceled");
const overall = active.length ? active.reduce((s, x) => s + (x.status === "done" ? 1 : (x.progress ?? 0)), 0) / active.length : 0;
const toastTask = demo ? (t >= 3700 && t < 6000 ? tasks.find((x) => x.id === "index") : undefined) : undefined;
const toast = toastTask && dismissed !== toastTask.id ? toastTask : undefined;
const stop = () => {
if (demo) setFrozenT(t);
setTouched(true);
};
const act = (id: string, patch: Partial<BackgroundTask>) => {
stop();
setLocal((m) => ({ ...m, [id]: { ...m[id], ...patch } }));
};
const R = 15;
const C = 2 * Math.PI * R;
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="relative overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
<button
type="button"
aria-expanded={open}
onClick={() => {
stop();
setOpen(!open);
}}
className="flex w-full cursor-pointer items-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 pt-4 pb-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<span className="relative grid size-9 shrink-0 place-items-center">
<svg viewBox="0 0 36 36" className="absolute inset-0 -rotate-90" aria-hidden>
<circle cx="18" cy="18" r={R} className="fill-none stroke-border" strokeWidth="3" />
<motion.circle
cx="18"
cy="18"
r={R}
className="fill-none stroke-sky-500"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={C}
initial={false}
animate={{ strokeDashoffset: C * (1 - overall) }}
transition={{ duration: demo ? TICK / 1000 : 0.6, ease: demo ? "linear" : EASE }}
/>
</svg>
<Layers className="size-3.5" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold">{title}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{running} running{queued ? ` · ${queued} queued` : ""} · {Math.round(overall * 100)}% overall
</span>
</span>
{!open && (
<span className="flex w-20 shrink-0 gap-0.5" aria-hidden>
{tasks.map((x) => (
<span key={x.id} className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<span className={cn("block h-full rounded-full", STATUS[x.status].bar)} style={{ width: `${(x.status === "done" ? 1 : (x.progress ?? 0)) * 100}%` }} />
</span>
))}
</span>
)}
<ChevronDown className={cn("size-4 shrink-0 text-muted-foreground transition-transform duration-300", open && "rotate-180")} aria-hidden />
</button>
<AnimatePresence initial={false}>
{open && (
<motion.ul
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: EASE }}
className="overflow-hidden px-2 pb-2"
aria-label="Tasks"
>
{tasks.map((task, i) => {
const s = STATUS[task.status];
const Icon = s.icon;
const p = task.status === "done" ? 1 : (task.progress ?? 0);
return (
<motion.li
key={task.id}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05, duration: 0.3, ease: EASE }}
className="group flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-muted/40"
>
<motion.span
key={task.status}
initial={reduce ? false : { scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 22 }}
className={cn("grid size-8 shrink-0 place-items-center rounded-lg", s.tile)}
>
<Icon className={cn("size-3.5", task.status === "running" && !reduce && "animate-spin")} strokeWidth={task.status === "done" || task.status === "failed" ? 3 : 2} aria-hidden />
</motion.span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<p className={cn("min-w-0 flex-1 truncate text-xs font-medium", task.status === "canceled" && "text-muted-foreground line-through")}>{task.title}</p>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
{task.status === "running" ? `${Math.round(p * 100)}%` : task.status === "queued" ? <Clock className="inline size-3" aria-label="Queued" /> : s.label}
</span>
</div>
<div className="relative mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(p * 100)} aria-label={task.title}>
{task.status === "queued" ? (
<span className="absolute inset-0 bg-[repeating-linear-gradient(90deg,var(--color-border)_0_6px,transparent_6px_10px)]" />
) : (
<motion.span
className={cn("absolute inset-y-0 left-0 overflow-hidden rounded-full", s.bar)}
initial={false}
animate={{ width: `${p * 100}%` }}
transition={{ duration: demo ? TICK / 1000 : 0.5, ease: demo ? "linear" : EASE }}
>
{task.status === "running" && !reduce && <span className="absolute inset-0 animate-[bt-shine_1.6s_ease-in-out_infinite] bg-gradient-to-r from-transparent via-white/50 to-transparent" />}
</motion.span>
)}
</div>
<p className={cn("mt-1 truncate text-[10.5px]", task.status === "failed" ? "text-rose-600 dark:text-rose-400" : "text-muted-foreground")}>
{task.detail}
{task.eta && task.status !== "done" ? <span className="text-muted-foreground"> · {task.eta}</span> : null}
</p>
</div>
{task.status === "failed" ? (
<motion.button
type="button"
onClick={() => {
onRetry?.(task.id);
act(task.id, { status: "running", progress: 0, detail: "Retrying…", eta: undefined });
}}
animate={{ scale: demo && t >= 6100 && t < 6400 ? 0.9 : 1 }}
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded-lg border bg-background px-2 text-[11px] font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCw className="size-3" aria-hidden />
Retry
</motion.button>
) : task.status === "running" || task.status === "queued" ? (
<button
type="button"
aria-label={`Cancel ${task.title}`}
onClick={() => {
onCancel?.(task.id);
act(task.id, { status: "canceled", detail: "Canceled", eta: undefined });
}}
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-lg text-muted-foreground opacity-0 outline-none group-hover:opacity-100 hover:bg-muted hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3.5" aria-hidden />
</button>
) : (
<span className="size-7 shrink-0" />
)}
</motion.li>
);
})}
</motion.ul>
)}
</AnimatePresence>
{/* Completion toast */}
<AnimatePresence>
{toast && (
<motion.div
initial={reduce ? false : { opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, y: 16, scale: 0.98 }}
transition={{ type: "spring", stiffness: 420, damping: 32 }}
className="absolute inset-x-3 bottom-3 flex items-center gap-2.5 rounded-xl border bg-foreground px-3 py-2 text-background shadow-xl"
role="status"
>
<span className="relative grid size-6 shrink-0 place-items-center rounded-full bg-emerald-500 text-white">
{!reduce && <span className="absolute inset-0 animate-ping rounded-full bg-emerald-500/60 [animation-iteration-count:2]" aria-hidden />}
<Check className="relative size-3.5" strokeWidth={3} aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium">{toast.title} finished</span>
<span className="block truncate text-[10.5px] opacity-60">{toast.detail}</span>
</span>
<button
type="button"
onClick={() => onOpen?.(toast.id)}
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded-lg bg-background/15 px-2 text-[11px] font-medium outline-none hover:bg-background/25 focus-visible:ring-2 focus-visible:ring-background/60"
>
<FileText className="size-3" aria-hidden />
Open
</button>
<button
type="button"
aria-label="Dismiss"
onClick={() => {
stop();
setDismissed(toast.id);
}}
className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-md opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-background/60"
>
<X className="size-3" aria-hidden />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
<style>{`@keyframes bt-shine{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}`}</style>
</div>
);
}
export default function BackgroundTasksDemo() {
return (
<div className="flex h-[360px] w-full items-start justify-center pt-2">
<BackgroundTasks autoPlay />
</div>
);
}