nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsBackground tasks
Copy promptCopy prompt

Customize

Header title

Start with the list expanded

Loop the scripted demo; it stops on interaction and pauses offscreen

JSX · copied with Copy prompt

<BackgroundTasks autoPlay />

Background tasksNew

Queue of long-running agent jobs with live progress bars, ETA, cancel and retry, an overall progress ring and a completion toast.

Installation

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}

Usage

1"use client";
2
3import { BackgroundTasks, type BackgroundTask } from "@/components/nextui/background-tasks";
4
5export function JobQueue({ tasks }: { tasks: BackgroundTask[] }) {
6 return <BackgroundTasks tasks={tasks} onCancel={cancelJob} onRetry={retryJob} />;
7}

API reference

Edit props live in the preview above, or see all 8 props
PropTypeDefaultDescription
tasksBackgroundTask[]–Jobs with id, title, status (queued, running, done, failed, canceled), progress 0–1, detail and eta
titlestring"Background tasks"Header title
defaultOpenbooleantrueStart 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
autoPlaybooleanfalseLoop the scripted demo; it stops on interaction and pauses offscreen
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/background-tasks.tsx
"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>
  );
}
Sign in