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 & AgentsCheckpoint rewind
Copy promptCopy prompt

Customize

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

JSX · copied with Copy prompt

<CheckpointRewind autoPlay />

Checkpoint rewindNew

Session timeline with restore points: pick a checkpoint, preview the files that will revert, and rewind code or code and chat while HEAD glides back.

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/checkpoint-rewind
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 { CheckpointRewind, type Checkpoint } from "@/components/nextui/checkpoint-rewind";
4
5export function SessionCheckpoints({ checkpoints }: { checkpoints: Checkpoint[] }) {
6 return (
7 <CheckpointRewind
8 checkpoints={checkpoints}
9 onRestore={(id, mode) => restoreSession(id, { includeChat: mode === "all" })}
10 />
11 );
12}

API reference

Edit props live in the preview above, or see all 6 props
PropTypeDefaultDescription
checkpointsCheckpoint[]–Restore points, oldest first, with id, label, time, kind (prompt, edit, auto) and changed files
headIdstring–Checkpoint the workspace sits on. Defaults to the newest
defaultMode"code" | "all""code"Restore code only, or code and conversation
onRestore(id: string, mode: "code" | "all") => void–Called when Restore is pressed
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/checkpoint-rewind.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { Bookmark, Check, History, MessageSquare, Pencil, RotateCcw, Undo2 } from "lucide-react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type CheckpointFile = { path: string; add: number; del: number };

export type Checkpoint = {
  id: string;
  label: string;
  time: string;
  /** prompt = user turn, edit = agent changed files, auto = snapshot taken before a risky step. */
  kind: "prompt" | "edit" | "auto";
  files?: CheckpointFile[];
};

export type RestoreMode = "code" | "all";

export type CheckpointRewindProps = {
  /** Oldest first. */
  checkpoints?: Checkpoint[];
  /** Checkpoint the workspace currently sits on. Defaults to the newest. */
  headId?: string;
  defaultMode?: RestoreMode;
  onRestore?: (id: string, mode: RestoreMode) => void;
  /** Loop a scripted new-checkpoint → pick → rewind demo. */
  autoPlay?: boolean;
  className?: string;
};

const BASE: Checkpoint[] = [
  { id: "c1", label: "Session start", time: "10:02", kind: "prompt" },
  { id: "c2", label: "Before auth refactor", time: "10:14", kind: "auto", files: [{ path: "src/auth/session.ts", add: 12, del: 3 }] },
  {
    id: "c3",
    label: "Add refresh token rotation",
    time: "10:21",
    kind: "edit",
    files: [
      { path: "src/auth/refresh.ts", add: 48, del: 6 },
      { path: "src/auth/session.ts", add: 9, del: 14 },
    ],
  },
  {
    id: "c4",
    label: "Update API middleware",
    time: "10:27",
    kind: "edit",
    files: [
      { path: "src/middleware.ts", add: 22, del: 11 },
      { path: "tests/auth.test.ts", add: 31, del: 0 },
    ],
  },
];
const NEW: Checkpoint = {
  id: "c5",
  label: "Move cookies to httpOnly",
  time: "10:33",
  kind: "edit",
  files: [
    { path: "src/auth/cookies.ts", add: 17, del: 9 },
    { path: "src/middleware.ts", add: 4, del: 2 },
  ],
};

const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 10000;
const STEP = 300;
const KIND_ICON = { prompt: MessageSquare, edit: Pencil, auto: Bookmark } as const;

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 Phase = "idle" | "pressing" | "rewinding" | "restored";
type View = { list: Checkpoint[]; head: string; selected: string | null; mode: RestoreMode; phase: Phase; rewoundFrom: string | null };

function demoView(t: number): View {
  const list = t >= 900 ? [...BASE, NEW] : BASE;
  const top = list[list.length - 1].id;
  let head = top;
  let phase: Phase = "idle";
  if (t >= 4300 && t < 4600) phase = "pressing";
  if (t >= 4600) {
    const steps = Math.floor((t - 4600) / STEP) + 1;
    head = ["c4", "c3", "c2"][Math.min(2, steps - 1)];
    phase = t >= 4600 + STEP * 3 ? "restored" : "rewinding";
  }
  return {
    list,
    head,
    selected: t >= 2200 ? "c2" : null,
    mode: t >= 3400 ? "all" : "code",
    phase,
    rewoundFrom: t >= 4600 ? "c5" : null,
  };
}

function changesSince(list: Checkpoint[], fromId: string, toId: string) {
  const from = list.findIndex((c) => c.id === fromId);
  const to = list.findIndex((c) => c.id === toId);
  const map = new Map<string, CheckpointFile>();
  for (const c of list.slice(from + 1, to + 1)) {
    for (const f of c.files ?? []) {
      const prev = map.get(f.path);
      map.set(f.path, prev ? { path: f.path, add: prev.add + f.add, del: prev.del + f.del } : { ...f });
    }
  }
  return [...map.values()].sort((a, b) => b.add + b.del - (a.add + a.del));
}

/** Session timeline with restore points: pick a checkpoint, see what changed since, and rewind code (or code and chat). */
export function CheckpointRewind({ checkpoints, headId, defaultMode = "code", onRestore, autoPlay = false, className }: CheckpointRewindProps) {
  const reduce = useReducedMotion() ?? false;
  const uid = useId();
  const [touched, setTouched] = useState(false);
  const demo = autoPlay && !reduce && !touched && !checkpoints;
  const { ref, t } = useDemoClock(demo, LOOP);

  const initialList = checkpoints ?? [...BASE, NEW];
  const [view, setView] = useState<View>(() => ({
    list: initialList,
    head: headId ?? initialList[initialList.length - 1].id,
    selected: null,
    mode: defaultMode,
    phase: "idle",
    rewoundFrom: null,
  }));
  // 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 { list, head, selected, mode, phase, rewoundFrom } = view;
  const headIdx = list.findIndex((c) => c.id === head);
  const rewoundIdx = rewoundFrom ? list.findIndex((c) => c.id === rewoundFrom) : -1;
  const target = selected ?? null;
  const diffTo = rewoundFrom ?? list[list.length - 1].id;
  const files = target ? changesSince(list, target, diffTo) : [];
  const totals = files.reduce((s, f) => ({ add: s.add + f.add, del: s.del + f.del }), { add: 0, del: 0 });
  const busy = phase === "pressing" || phase === "rewinding";

  // Interactive rewind: walk HEAD down one checkpoint at a time.
  useEffect(() => {
    if (demo || phase !== "rewinding" || !target) return;
    const id = setTimeout(() => {
      setView((v) => {
        const hi = v.list.findIndex((c) => c.id === v.head);
        const ti = v.list.findIndex((c) => c.id === target);
        if (hi - 1 <= ti) return { ...v, head: target, phase: "restored" };
        return { ...v, head: v.list[hi - 1].id };
      });
    }, reduce ? 0 : STEP);
    return () => clearTimeout(id);
  }, [demo, phase, head, target, reduce]);

  const interact = (fn: (v: View) => View) => {
    setTouched(true);
    setView((v) => fn(v));
  };

  const restore = () => {
    if (!target || target === head || busy) return;
    onRestore?.(target, mode);
    interact((v) => ({ ...v, phase: "rewinding", rewoundFrom: v.rewoundFrom ?? v.head }));
  };

  const undo = () => interact((v) => ({ ...v, head: v.rewoundFrom ?? v.head, phase: "idle", rewoundFrom: null, selected: null }));

  const shown = [...list].reverse();
  const targetLabel = list.find((c) => c.id === target)?.label;

  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)]">
        <div className="flex items-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 pt-4 pb-3">
          <span className="relative grid size-9 shrink-0 place-items-center rounded-xl border bg-background shadow-sm">
            <History className={cn("size-4", phase === "rewinding" && !reduce && "animate-[cr-ccw_0.9s_linear_infinite]")} aria-hidden />
          </span>
          <div className="min-w-0 flex-1">
            <p className="text-sm font-semibold">Checkpoints</p>
            <p className="truncate text-[11px] text-muted-foreground">Restore points for this session</p>
          </div>
          <span className="shrink-0 rounded-full border bg-background px-2 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums">
            {list.length} saved
          </span>
        </div>

        {/* Timeline, newest first */}
        <LayoutGroup id={uid}>
          <ol className="relative px-2 pb-1" aria-label="Checkpoints">
            <span className="absolute top-5 bottom-5 left-[25px] w-0 border-l border-dashed border-foreground/20" aria-hidden />
            <motion.span
              aria-hidden
              className="absolute bottom-5 left-[24.5px] w-[2px] rounded-full bg-gradient-to-b from-sky-500 via-sky-500/60 to-foreground/15"
              initial={false}
              animate={{ top: 20 + (list.length - 1 - headIdx) * 40 }}
              transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 32 }}
            />
            <AnimatePresence initial={false}>
              {shown.map((c) => {
                const idx = list.findIndex((x) => x.id === c.id);
                const isHead = c.id === head;
                const rewound = rewoundIdx >= 0 && idx > headIdx && idx <= rewoundIdx;
                const isSel = c.id === selected;
                const Icon = KIND_ICON[c.kind];
                const delta = (c.files ?? []).reduce((s, f) => ({ add: s.add + f.add, del: s.del + f.del }), { add: 0, del: 0 });
                return (
                  <motion.li
                    key={c.id}
                    layout={reduce ? false : "position"}
                    initial={reduce ? false : { opacity: 0, height: 0 }}
                    animate={{ opacity: 1, height: "auto" }}
                    exit={reduce ? undefined : { opacity: 0, height: 0 }}
                    transition={{ duration: 0.4, ease: EASE }}
                  >
                    <button
                      type="button"
                      disabled={busy || rewound}
                      aria-pressed={isSel}
                      onClick={() => interact((v) => ({ ...v, selected: v.selected === c.id || c.id === v.head ? null : c.id, phase: v.phase === "restored" ? "idle" : v.phase }))}
                      className={cn(
                        "relative flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-xl px-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default",
                        isSel ? "bg-muted" : "hover:bg-muted/50",
                      )}
                    >
                      <span className="relative grid size-6 shrink-0 place-items-center">
                        {isHead && (
                          <motion.span
                            layoutId="head-ring"
                            transition={{ type: "spring", stiffness: 380, damping: 30 }}
                            className="absolute -inset-1 rounded-full bg-sky-500/15 ring-2 ring-sky-500/70"
                          />
                        )}
                        <span
                          className={cn(
                            "relative grid size-6 place-items-center rounded-full border transition-colors duration-300",
                            isHead ? "border-foreground bg-foreground text-background" : rewound ? "border-dashed bg-background text-muted-foreground/50" : "bg-background text-muted-foreground",
                            isSel && !isHead && "border-sky-500 text-sky-600 dark:text-sky-400",
                          )}
                        >
                          <Icon className="size-3" aria-hidden />
                        </span>
                      </span>
                      <span className={cn("min-w-0 flex-1 transition-opacity duration-300", rewound && "opacity-45")}>
                        <span className={cn("block truncate text-xs font-medium", rewound && "line-through decoration-foreground/40")}>{c.label}</span>
                        <span className="block font-mono text-[10px] text-muted-foreground">
                          {c.time}
                          {c.files?.length ? (
                            <>
                              {" · "}
                              {c.files.length} file{c.files.length > 1 ? "s" : ""} <span className="text-emerald-600 dark:text-emerald-400">+{delta.add}</span>{" "}
                              <span className="text-rose-600 dark:text-rose-400">−{delta.del}</span>
                            </>
                          ) : (
                            " · no file changes"
                          )}
                        </span>
                      </span>
                      {isHead ? (
                        <motion.span
                          layoutId="head-pill"
                          transition={{ type: "spring", stiffness: 380, damping: 30 }}
                          className="shrink-0 rounded-full bg-foreground px-1.5 py-px font-mono text-[9px] font-semibold tracking-wider text-background"
                        >
                          HEAD
                        </motion.span>
                      ) : rewound ? (
                        <span className="shrink-0 font-mono text-[9px] tracking-wider text-muted-foreground uppercase">rewound</span>
                      ) : c.kind === "auto" ? (
                        <span className="shrink-0 rounded-full border px-1.5 py-px text-[9px] text-muted-foreground">auto</span>
                      ) : null}
                    </button>
                  </motion.li>
                );
              })}
            </AnimatePresence>
          </ol>
        </LayoutGroup>

        {/* Changes since the picked checkpoint */}
        <AnimatePresence initial={false}>
          {target && phase !== "restored" && (
            <motion.div
              key="diff"
              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"
            >
              <div className="mx-3 mb-2 rounded-xl border bg-background px-3 py-2">
                <p className="mb-1.5 flex items-center justify-between text-[11px] text-muted-foreground">
                  <span>Will revert {files.length} file{files.length === 1 ? "" : "s"}</span>
                  <span className="font-mono">
                    <span className="text-emerald-600 dark:text-emerald-400">+{totals.add}</span> <span className="text-rose-600 dark:text-rose-400">−{totals.del}</span>
                  </span>
                </p>
                <ul className="space-y-1">
                  {files.slice(0, 3).map((f, i) => {
                    const max = Math.max(...files.map((x) => x.add + x.del), 1);
                    return (
                      <motion.li
                        key={f.path}
                        initial={reduce ? false : { opacity: 0, x: -6 }}
                        animate={{ opacity: 1, x: 0 }}
                        transition={{ delay: 0.08 + i * 0.06, duration: 0.3, ease: EASE }}
                        className="flex items-center gap-2 font-mono text-[10.5px]"
                      >
                        <span className="min-w-0 flex-1 truncate">{f.path}</span>
                        <span className="flex h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-muted">
                          <motion.span className="h-full bg-emerald-500" initial={reduce ? false : { width: 0 }} animate={{ width: `${(f.add / max) * 100}%` }} transition={{ delay: 0.15 + i * 0.06, duration: 0.5, ease: EASE }} />
                          <motion.span className="h-full bg-rose-500" initial={reduce ? false : { width: 0 }} animate={{ width: `${(f.del / max) * 100}%` }} transition={{ delay: 0.2 + i * 0.06, duration: 0.5, ease: EASE }} />
                        </span>
                      </motion.li>
                    );
                  })}
                </ul>
                {files.length > 3 && <p className="mt-1 text-[10px] text-muted-foreground">+{files.length - 3} more</p>}
              </div>
            </motion.div>
          )}
        </AnimatePresence>

        {/* Footer */}
        <div className="flex h-12 items-center gap-2 border-t px-3">
          <AnimatePresence mode="popLayout" initial={false}>
            {phase === "restored" ? (
              <motion.div
                key="done"
                initial={reduce ? false : { opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduce ? undefined : { opacity: 0, y: -8 }}
                transition={{ duration: 0.3, ease: EASE }}
                className="flex min-w-0 flex-1 items-center gap-2"
                role="status"
              >
                <motion.span
                  initial={reduce ? false : { scale: 0 }}
                  animate={{ scale: 1 }}
                  transition={{ type: "spring", stiffness: 600, damping: 18, delay: 0.05 }}
                  className="grid size-5 shrink-0 place-items-center rounded-full bg-emerald-500 text-white"
                >
                  <Check className="size-3" strokeWidth={3} aria-hidden />
                </motion.span>
                <span className="min-w-0 flex-1 truncate text-xs">
                  Restored to <span className="font-medium">{targetLabel}</span>
                  <span className="text-muted-foreground"> · {mode === "all" ? "code + chat" : "code only"}</span>
                </span>
                <button
                  type="button"
                  onClick={undo}
                  className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded-lg px-2 text-[11px] text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <Undo2 className="size-3" aria-hidden />
                  Undo
                </button>
              </motion.div>
            ) : (
              <motion.div
                key="controls"
                initial={reduce ? false : { opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduce ? undefined : { opacity: 0, y: -8 }}
                transition={{ duration: 0.3, ease: EASE }}
                className="flex min-w-0 flex-1 items-center gap-2"
              >
                <div role="radiogroup" aria-label="Restore mode" className="relative flex rounded-lg bg-muted p-0.5 text-[11px]">
                  {(["code", "all"] as const).map((m) => (
                    <button
                      key={m}
                      type="button"
                      role="radio"
                      aria-checked={mode === m}
                      disabled={busy}
                      onClick={() => interact((v) => ({ ...v, mode: m }))}
                      className={cn("relative h-6 cursor-pointer rounded-md px-2 outline-none focus-visible:ring-2 focus-visible:ring-ring", mode === m ? "text-foreground" : "text-muted-foreground")}
                    >
                      {mode === m && <motion.span layoutId={`${uid}-mode`} className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 34 }} />}
                      <span className="relative">{m === "code" ? "Code" : "Code + chat"}</span>
                    </button>
                  ))}
                </div>
                <span className="flex-1" />
                <motion.button
                  type="button"
                  onClick={restore}
                  disabled={!target || busy}
                  animate={{ scale: phase === "pressing" ? 0.94 : 1 }}
                  transition={{ type: "spring", stiffness: 500, damping: 26 }}
                  className="inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-foreground px-3 text-xs font-medium text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40"
                >
                  <RotateCcw className={cn("size-3.5", phase === "rewinding" && !reduce && "animate-[cr-ccw_0.6s_linear_infinite]")} aria-hidden />
                  {phase === "rewinding" ? "Rewinding…" : "Restore"}
                </motion.button>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>
      <style>{`@keyframes cr-ccw{to{transform:rotate(-360deg)}}`}</style>
    </div>
  );
}

export default function CheckpointRewindDemo() {
  return (
    <div className="flex h-[420px] w-full items-start justify-center pt-2">
      <CheckpointRewind autoPlay />
    </div>
  );
}
Sign in