Recurring background agent card with human-readable schedule, cron badge, recent run history, Pause/Resume toggle and a Run now button.
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/scheduled-trigger
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 { ScheduledTrigger } from "@/components/nextui/scheduled-trigger";45export function StandingAutomation() {6return (7<ScheduledTrigger8title="Weekly PR digest"9schedule="Every Monday at 08:00"10cron="0 8 * * 1"11/>12);13}
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | "Weekly PR digest" | Standing automation name |
| schedule | string | "Every Monday at 08:00" | Human-readable schedule |
| cron | string | "0 8 * * 1" | 5-field cron expression badge |
| nextRun | string | "Mon 08:00" | Next scheduled trigger time |
| nextRunIn | string | '"in 1d 18h"' | Relative countdown next to the next run |
| runs | ScheduledRun[] | – | Recent runs, oldest first. The last five are shown as status dots |
| runDayOffset | number | "2" | Days between the shown runs, used for the run timestamps |
| paused | boolean | – | Start paused |
| onRunNow | () => void | – | Called when Run now is pressed |
| onTogglePause | (paused: boolean) => void | – | Called when Pause/Resume is pressed |
| autoPlay | boolean | false | Loop a scripted trigger execution demo |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useRef, useState } from "react";
import { CalendarClock, Check, Pause, Play, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type ScheduledRun = {
id: string;
when: string;
status: "success" | "running" | "failed";
summary: string;
duration?: string;
};
export type ScheduledTriggerProps = {
title?: string;
schedule?: string;
cron?: string;
nextRun?: string;
/** Countdown to the next run, e.g. "in 1d 18h". */
nextRunIn?: string;
/** Recent runs, oldest first. The last five are shown. */
runs?: ScheduledRun[];
/** Days from today to the scheduled weekday (0 = today), for the week strip. */
runDayOffset?: number;
paused?: boolean;
onRunNow?: () => void;
onTogglePause?: (paused: boolean) => void;
/** Loop a scripted run-now → pause → resume demo. */
autoPlay?: boolean;
className?: string;
};
const DEFAULT_RUNS: ScheduledRun[] = [
{ id: "r1", when: "Oct 6", status: "success", summary: "Summarized 18 PRs · 2 need review", duration: "41s" },
{ id: "r2", when: "Oct 13", status: "success", summary: "Summarized 11 PRs · all merged", duration: "33s" },
{ id: "r3", when: "Oct 20", status: "failed", summary: "Code host token expired · re-auth needed", duration: "4s" },
{ id: "r4", when: "Oct 27", status: "success", summary: "Summarized 14 PRs · 1 stale for 9 days", duration: "38s" },
{ id: "r5", when: "Nov 3", status: "success", summary: "Summarized 9 PRs · 3 need review", duration: "29s" },
];
const DAYS = ["Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"];
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 9600;
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 };
}
type View = { runs: ScheduledRun[]; paused: boolean; selected: string | null; press: "run" | "pause" | null };
const LIVE: ScheduledRun = { id: "r6", when: "Now", status: "running", summary: "Collecting merged PRs since Nov 3…" };
const LIVE_DONE: ScheduledRun = { id: "r6", when: "Nov 7", status: "success", summary: "Summarized 6 PRs · posted to #eng-weekly", duration: "27s" };
function demoView(t: number): View {
let runs = DEFAULT_RUNS;
if (t >= 1400) runs = [...DEFAULT_RUNS.slice(1), t >= 3600 ? LIVE_DONE : LIVE];
return {
runs,
paused: t >= 5400 && t < 7800,
selected: t >= 1400 ? "r6" : "r5",
press: t >= 1100 && t < 1400 ? "run" : (t >= 5100 && t < 5400) || (t >= 7500 && t < 7800) ? "pause" : null,
};
}
/** A scheduled background agent: cadence and cron, a week strip to the next run, the last five runs as status dots, and Run now / Pause. */
export function ScheduledTrigger({
title = "Weekly PR digest",
schedule = "Every Monday at 08:00",
cron = "0 8 * * 1",
nextRun = "Mon 08:00",
nextRunIn = "in 1d 18h",
runs: runsProp,
runDayOffset = 2,
paused: pausedProp,
onRunNow,
onTogglePause,
autoPlay = false,
className,
}: ScheduledTriggerProps) {
const reduce = useReducedMotion() ?? false;
const [touched, setTouched] = useState(false);
const [view, setView] = useState<View>({ runs: runsProp ?? DEFAULT_RUNS, paused: false, selected: null, press: null });
const demo = autoPlay && !reduce && !touched && !runsProp;
const { ref, t } = useDemoClock(demo, LOOP);
// Sync the scripted frame during render (no effect → no extra commit per tick).
const [syncedT, setSyncedT] = useState(-1);
if (demo && t !== syncedT) {
setSyncedT(t);
setView(demoView(t));
}
const runs = (runsProp ?? view.runs).slice(-5);
const paused = pausedProp ?? view.paused;
const selected = runs.find((r) => r.id === view.selected) ?? runs[runs.length - 1];
const running = runs.some((r) => r.status === "running");
const okRate = Math.round((runs.filter((r) => r.status === "success").length / Math.max(1, runs.filter((r) => r.status !== "running").length)) * 100);
const runNow = () => {
if (running) return;
setTouched(true);
onRunNow?.();
const id = `run-${Date.now()}`;
setView((v) => ({ ...v, selected: id, runs: [...v.runs, { ...LIVE, id }] }));
setTimeout(() => {
setView((v) => ({ ...v, runs: v.runs.map((r) => (r.id === id ? { ...LIVE_DONE, id } : r)) }));
}, 2200);
};
const togglePause = () => {
setTouched(true);
onTogglePause?.(!paused);
setView((v) => ({ ...v, paused: !paused }));
};
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="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)]">
{/* Header */}
<div className="flex items-start gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 pt-3.5 pb-3">
<span className="grid size-9 shrink-0 place-items-center rounded-xl border bg-background shadow-xs">
<CalendarClock className="size-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{title}</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={paused ? "p" : "a"}
initial={reduce ? false : { opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, scale: 0.8 }}
className={cn(
"inline-flex h-5 shrink-0 items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium",
paused ? "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300" : "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
<span className="relative grid size-1.5 place-items-center">
{!paused && !reduce && <span className="absolute size-1.5 animate-ping rounded-full bg-emerald-500 [animation-duration:2s]" />}
<span className={cn("relative size-1.5 rounded-full", paused ? "bg-amber-500" : "bg-emerald-500")} />
</span>
{paused ? "Paused" : "Active"}
</motion.span>
</AnimatePresence>
</div>
<p className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{schedule}</span>
<code className="shrink-0 rounded border bg-background px-1 font-mono text-[10px]">{cron}</code>
</p>
</div>
</div>
{/* Week strip */}
<motion.div animate={{ opacity: paused ? 0.45 : 1 }} transition={{ duration: 0.4 }} className="px-4">
<div className="relative grid grid-cols-7 gap-1" aria-hidden>
{DAYS.map((d, i) => {
const runDay = i === runDayOffset;
return (
<div key={d} className="flex flex-col items-center gap-1">
<div className={cn("relative h-9 w-full overflow-hidden rounded-md border", runDay ? "border-foreground/30 bg-foreground/[0.04]" : "bg-muted/40")}>
{i === 0 && <span className="absolute inset-y-0 left-0 w-[58%] bg-[repeating-linear-gradient(135deg,transparent_0_3px,color-mix(in_oklab,var(--foreground)_8%,transparent)_3px_4px)]" />}
{runDay && (
<motion.span
className="absolute top-[33%] left-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground"
animate={paused || reduce ? { scale: 1 } : { scale: [1, 1.25, 1] }}
transition={paused || reduce ? undefined : { duration: 1.6, repeat: 2, ease: "easeInOut" }}
/>
)}
</div>
<span className={cn("font-mono text-[9px]", runDay ? "font-semibold text-foreground" : "text-muted-foreground")}>{i === 0 ? "Today" : d}</span>
</div>
);
})}
{/* Now marker */}
<span className="absolute top-0 h-9 w-px bg-red-500" style={{ left: `${(0.58 / 7) * 100}%` }}>
<span className="absolute -top-1 -left-[3px] size-[7px] rounded-full bg-red-500" />
</span>
{/* Countdown bar from now to the run */}
<motion.span
className="absolute top-[11px] h-px origin-left bg-foreground/40"
style={{ left: `${(0.58 / 7) * 100}%`, width: `${((runDayOffset + 0.5 - 0.58) / 7) * 100}%` }}
initial={false}
animate={{ scaleX: paused ? 0 : 1 }}
transition={{ duration: 0.6, ease: EASE }}
/>
</div>
<p className="mt-2 flex items-center justify-between text-[11px]">
<span className="text-muted-foreground">Next run</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={paused ? "p" : "a"}
initial={reduce ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -4 }}
className="font-medium tabular-nums"
>
{paused ? "Paused · no upcoming runs" : `${nextRun} · ${nextRunIn}`}
</motion.span>
</AnimatePresence>
</p>
</motion.div>
{/* History */}
<div className="mt-3 border-t px-4 pt-2.5 pb-3">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">Last {runs.length} runs</span>
<span className="font-mono text-[10px] text-muted-foreground tabular-nums">{okRate}% success</span>
</div>
<div className="mt-2 flex items-center gap-1.5" role="listbox" aria-label="Recent runs">
<AnimatePresence initial={false} mode="popLayout">
{runs.map((r) => {
const on = selected?.id === r.id;
return (
<motion.button
key={r.id}
layout={!reduce}
type="button"
role="option"
aria-selected={on}
aria-label={`${r.when}: ${r.status}`}
onClick={() => {
setTouched(true);
setView((v) => ({ ...v, selected: r.id }));
}}
initial={reduce ? false : { opacity: 0, scale: 0.4 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, scale: 0.4, width: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 30 }}
className={cn(
"flex h-11 flex-1 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
on ? "border-foreground/30 bg-muted" : "border-transparent hover:bg-muted/60",
)}
>
<RunDot status={r.status} reduce={reduce} />
<span className="font-mono text-[9px] text-muted-foreground">{r.when}</span>
</motion.button>
);
})}
</AnimatePresence>
</div>
<div className="mt-2 h-8 overflow-hidden rounded-lg bg-muted/50 px-2.5">
<AnimatePresence mode="popLayout" initial={false}>
{selected && (
<motion.p
key={`${selected.id}-${selected.status}`}
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -10 }}
transition={{ duration: 0.25, ease: EASE }}
className="flex h-8 items-center gap-2 text-[11px]"
>
<span className="min-w-0 flex-1 truncate">{selected.summary}</span>
{selected.duration && <span className="shrink-0 font-mono text-[10px] text-muted-foreground">{selected.duration}</span>}
</motion.p>
)}
</AnimatePresence>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 border-t bg-muted/30 px-4 py-2.5">
<motion.button
type="button"
onClick={togglePause}
animate={{ scale: view.press === "pause" ? 0.92 : 1 }}
className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border bg-background px-2.5 text-xs font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
{paused ? <Play className="size-3" aria-hidden /> : <Pause className="size-3" aria-hidden />}
{paused ? "Resume" : "Pause"}
</motion.button>
<motion.button
type="button"
onClick={runNow}
disabled={running}
animate={{ scale: view.press === "run" ? 0.92 : 1 }}
className="ml-auto inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-foreground px-3 text-xs font-medium text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-70"
>
{running ? (
<span className={cn("size-3 rounded-full border-[1.5px] border-background/30 border-t-background", !reduce && "animate-spin")} />
) : (
<Play className="size-3 fill-current" aria-hidden />
)}
{running ? "Running…" : "Run now"}
</motion.button>
</div>
</div>
</div>
);
}
function RunDot({ status, reduce }: { status: ScheduledRun["status"]; reduce: boolean }) {
if (status === "running")
return (
<span className="relative grid size-4 place-items-center">
{!reduce && <span className="absolute size-4 animate-ping rounded-full bg-sky-500/50" />}
<span className="relative size-3 rounded-full bg-sky-500" />
</span>
);
return (
<motion.span
key={status}
initial={reduce ? false : { scale: 0.3 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 600, damping: 16 }}
className={cn("grid size-4 place-items-center rounded-full text-white", status === "success" ? "bg-emerald-500" : "bg-red-500")}
>
{status === "success" ? <Check className="size-2.5" strokeWidth={3.5} aria-hidden /> : <X className="size-2.5" strokeWidth={3.5} aria-hidden />}
</motion.span>
);
}
export default function ScheduledTriggerDemo() {
return (
<div className="flex h-[380px] w-full items-start justify-center pt-2">
<ScheduledTrigger autoPlay />
</div>
);
}