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 & AgentsScheduled trigger
Copy promptCopy prompt

Customize

Standing automation name

Human-readable schedule

5-field cron expression badge

Next scheduled trigger time

Loop a scripted trigger execution demo

JSX · copied with Copy prompt

<ScheduledTrigger autoPlay />

Scheduled triggerNew

Recurring background agent card with human-readable schedule, cron badge, recent run history, Pause/Resume toggle and a Run now button.

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/scheduled-trigger
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 { ScheduledTrigger } from "@/components/nextui/scheduled-trigger";
4
5export function StandingAutomation() {
6 return (
7 <ScheduledTrigger
8 title="Weekly PR digest"
9 schedule="Every Monday at 08:00"
10 cron="0 8 * * 1"
11 />
12 );
13}

API reference

Edit props live in the preview above, or see all 12 props
PropTypeDefaultDescription
titlestring"Weekly PR digest"Standing automation name
schedulestring"Every Monday at 08:00"Human-readable schedule
cronstring"0 8 * * 1"5-field cron expression badge
nextRunstring"Mon 08:00"Next scheduled trigger time
nextRunInstring'"in 1d 18h"'Relative countdown next to the next run
runsScheduledRun[]–Recent runs, oldest first. The last five are shown as status dots
runDayOffsetnumber"2"Days between the shown runs, used for the run timestamps
pausedboolean–Start paused
onRunNow() => void–Called when Run now is pressed
onTogglePause(paused: boolean) => void–Called when Pause/Resume is pressed
autoPlaybooleanfalseLoop a scripted trigger execution demo
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

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