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 & AgentsAgent memory
Copy promptCopy prompt

Customize

Inline summary line shown in the header bar

Whether the memory drawer starts expanded

Loop a scripted memory-capture animation

JSX · copied with Copy prompt

<AgentMemory autoPlay />

Agent memoryNew

Inline 'Updated memory' pill that expands into a persistent memory drawer: inspect workspace rules and user preferences learned by the agent, add new rules, or forget entries.

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/agent-memory
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 { useState } from "react";
4import { AgentMemory, type MemoryEntry } from "@/components/nextui/agent-memory";
5
6export function MemoryDrawer({ initial }: { initial: MemoryEntry[] }) {
7 const [entries, setEntries] = useState(initial);
8
9 return (
10 <AgentMemory
11 entries={entries}
12 onAdd={(fact) =>
13 setEntries((list) => [{ id: crypto.randomUUID(), fact, scope: "user", justAdded: true }, ...list])
14 }
15 onRemove={(id) => setEntries((list) => list.filter((m) => m.id !== id))}
16 />
17 );
18}

API reference

Edit props live in the preview above, or see all 8 props
PropTypeDefaultDescription
entriesMemoryEntry[]–Saved memory items with id, fact, scope (workspace, user, session) and justAdded
latestNoticestring–Inline summary line shown in the header bar
defaultOpenbooleantrueWhether the memory drawer starts expanded
onAdd(fact: string) => void–Called when the user submits a new memory rule
onRemove(id: string) => void–Called when the user deletes a memory entry
onEdit(id: string, fact: string) => void–Called when a fact is edited inline
autoPlaybooleanfalseLoop a scripted memory-capture animation
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/agent-memory.tsx
"use client";

import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { BookmarkCheck, Brain, Check, ChevronDown, Pencil, Plus, Trash2, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type MemoryEntry = {
  id: string;
  fact: string;
  scope?: "workspace" | "user" | "session";
  justAdded?: boolean;
};

export type AgentMemoryProps = {
  entries?: MemoryEntry[];
  /** Fact shown in the "Saved to memory" chip. Leave unset to hide the chip. */
  latestNotice?: string;
  defaultOpen?: boolean;
  onAdd?: (fact: string) => void;
  onEdit?: (id: string, fact: string) => void;
  onRemove?: (id: string) => void;
  /** Loop a scripted save → edit → forget demo. */
  autoPlay?: boolean;
  className?: string;
};

const INITIAL: MemoryEntry[] = [
  { id: "m1", fact: "Always use pnpm, never npm or yarn", scope: "workspace" },
  { id: "m2", fact: "Prefer server components; use client only for interactivity", scope: "workspace" },
  { id: "m3", fact: "Code style: TypeScript strict", scope: "user" },
];
const NEW_FACT: MemoryEntry = { id: "m4", fact: "Run vitest --run before opening a PR", scope: "workspace" };
const EDITED = "Code style: TypeScript strict, no implicit any";

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 Chip = null | "saving" | "saved";
type View = {
  entries: MemoryEntry[];
  chip: Chip;
  chipText: string;
  editingId: string | null;
  editDraft: string;
  flashId: string | null;
  armedId: string | null;
};

function demoView(t: number): View {
  let entries = INITIAL.map((e) => ({ ...e }));
  if (t >= 1700) entries = [{ ...NEW_FACT }, ...entries];
  const typing = Math.max(0, Math.min(1, (t - 4000) / 1100));
  const base = INITIAL[2].fact;
  const editDraft = base + EDITED.slice(base.length, base.length + Math.round((EDITED.length - base.length) * typing));
  if (t >= 5700) entries = entries.map((e) => (e.id === "m3" ? { ...e, fact: EDITED } : e));
  if (t >= 7300) entries = entries.filter((e) => e.id !== "m2");
  return {
    entries,
    chip: t >= 800 && t < 1700 ? "saving" : t >= 1700 && t < 3400 ? "saved" : null,
    chipText: NEW_FACT.fact,
    editingId: t >= 3600 && t < 5700 ? "m3" : null,
    editDraft,
    flashId: t >= 1700 && t < 3400 ? "m4" : t >= 5700 && t < 6800 ? "m3" : null,
    armedId: t >= 6600 && t < 7300 ? "m2" : null,
  };
}

/** Long-term agent memory: a "Saved to memory" chip plus a drawer to review, edit inline and forget facts. */
export function AgentMemory({
  entries: controlledEntries,
  latestNotice,
  defaultOpen = true,
  onAdd,
  onEdit,
  onRemove,
  autoPlay = false,
  className,
}: AgentMemoryProps) {
  const reduce = useReducedMotion() ?? false;
  const [open, setOpen] = useState(defaultOpen);
  const [draft, setDraft] = useState("");
  const [touched, setTouched] = useState(false);
  const [view, setView] = useState<View>({
    entries: [NEW_FACT, ...INITIAL],
    chip: latestNotice ? "saved" : null,
    chipText: latestNotice ?? "",
    editingId: null,
    editDraft: "",
    flashId: null,
    armedId: null,
  });
  const demo = autoPlay && !reduce && !touched && !controlledEntries;
  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 entries = controlledEntries ?? view.entries;
  const patch = (p: Partial<View>) => {
    setTouched(true);
    setView((v) => ({ ...v, ...p }));
  };

  const add = (e: FormEvent) => {
    e.preventDefault();
    const fact = draft.trim();
    if (!fact) return;
    onAdd?.(fact);
    const id = `m-${Date.now()}`;
    setTouched(true);
    setView((v) => ({
      ...v,
      entries: controlledEntries ? v.entries : [{ id, fact, scope: "user" }, ...v.entries],
      chip: "saved",
      chipText: fact,
      flashId: id,
    }));
    setDraft("");
  };

  const commitEdit = () => {
    const id = view.editingId;
    const fact = view.editDraft.trim();
    if (!id) return;
    if (fact) onEdit?.(id, fact);
    patch({
      editingId: null,
      flashId: fact ? id : null,
      entries: controlledEntries || !fact ? view.entries : view.entries.map((e) => (e.id === id ? { ...e, fact } : e)),
    });
  };

  const remove = (id: string) => {
    onRemove?.(id);
    patch({ entries: controlledEntries ? view.entries : view.entries.filter((e) => e.id !== id), armedId: null });
  };

  const onEditKey = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") {
      e.preventDefault();
      commitEdit();
    } else if (e.key === "Escape") patch({ editingId: null });
  };

  return (
    <div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
      {/* Saved-to-memory chip */}
      <div className="mb-2 flex h-8 justify-center">
        <AnimatePresence>
          {view.chip && (
            <motion.div
              key="chip"
              role="status"
              initial={reduce ? false : { opacity: 0, y: 10, scale: 0.92 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={reduce ? undefined : { opacity: 0, y: -6, scale: 0.96 }}
              transition={{ type: "spring", stiffness: 420, damping: 30 }}
              className="relative inline-flex max-w-full items-center gap-2 overflow-hidden rounded-full border bg-card py-1 pr-3 pl-1 text-xs shadow-[0_6px_20px_-10px_rgb(0_0_0/0.35)]"
            >
              <span className="relative grid size-6 shrink-0 place-items-center rounded-full bg-foreground text-background">
                <AnimatePresence mode="popLayout" initial={false}>
                  {view.chip === "saving" ? (
                    <motion.svg key="ring" viewBox="0 0 24 24" className="size-4" exit={{ opacity: 0, scale: 0.5 }} aria-hidden>
                      <circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeOpacity={0.25} strokeWidth="2.5" />
                      <motion.circle
                        cx="12"
                        cy="12"
                        r="8"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="2.5"
                        strokeLinecap="round"
                        initial={{ pathLength: 0 }}
                        animate={{ pathLength: 0.9 }}
                        transition={{ duration: 0.9, ease: "easeOut" }}
                        style={{ rotate: -90, transformOrigin: "center" }}
                      />
                    </motion.svg>
                  ) : (
                    <motion.span
                      key="check"
                      initial={reduce ? false : { scale: 0.3, rotate: -30 }}
                      animate={{ scale: 1, rotate: 0 }}
                      transition={{ type: "spring", stiffness: 600, damping: 16 }}
                    >
                      <BookmarkCheck className="size-3.5" aria-hidden />
                    </motion.span>
                  )}
                </AnimatePresence>
              </span>
              <span className="shrink-0 font-medium">{view.chip === "saving" ? "Saving to memory…" : "Saved to memory"}</span>
              <span className="truncate text-muted-foreground">“{view.chipText}”</span>
              {view.chip === "saved" && !reduce && <span className="mem-shine pointer-events-none absolute inset-0" aria-hidden />}
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <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)]">
        <button
          type="button"
          aria-expanded={open}
          onClick={() => {
            setTouched(true);
            setOpen((v) => !v);
          }}
          className="flex w-full cursor-pointer items-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
        >
          <span className="grid size-8 shrink-0 place-items-center rounded-lg border bg-background shadow-xs">
            <Brain className="size-4" aria-hidden />
          </span>
          <span className="min-w-0 flex-1">
            <span className="block font-medium leading-tight">Memory</span>
            <span className="mt-0.5 block text-xs text-muted-foreground">Facts the agent keeps across sessions</span>
          </span>
          <motion.span
            key={entries.length}
            initial={reduce ? false : { scale: 1.35 }}
            animate={{ scale: 1 }}
            transition={{ type: "spring", stiffness: 600, damping: 18 }}
            className="rounded-full border bg-background px-2 font-mono text-[11px] tabular-nums"
          >
            {entries.length}
          </motion.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.div
              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 border-t"
            >
              <ul className="px-2 py-1.5">
                <AnimatePresence initial={false}>
                  {entries.map((item) => {
                    const editing = view.editingId === item.id;
                    const armed = view.armedId === item.id;
                    return (
                      <motion.li
                        key={item.id}
                        layout={!reduce}
                        initial={reduce ? false : { opacity: 0, height: 0, y: -8 }}
                        animate={{ opacity: 1, height: "auto", y: 0 }}
                        exit={reduce ? undefined : { opacity: 0, height: 0, x: -24, transition: { duration: 0.3, ease: EASE } }}
                        transition={{ duration: 0.4, ease: EASE }}
                        className="overflow-hidden"
                      >
                        <div
                          className={cn(
                            "group relative flex min-h-10 items-center gap-2.5 rounded-lg px-2 py-1.5 text-xs transition-colors duration-300",
                            editing ? "bg-muted/70" : "hover:bg-muted/50",
                            armed && "bg-red-500/[0.07]",
                          )}
                        >
                          {view.flashId === item.id && !reduce && <span className="mem-flash pointer-events-none absolute inset-0 rounded-lg" aria-hidden />}
                          <span
                            className={cn(
                              "relative size-1.5 shrink-0 rounded-full transition-colors",
                              view.flashId === item.id ? "bg-foreground" : "bg-muted-foreground/40",
                            )}
                          />
                          {editing ? (
                            <input
                              autoFocus={touched}
                              aria-label="Edit memory"
                              value={view.editDraft}
                              onChange={(e) => patch({ editDraft: e.target.value })}
                              onKeyDown={onEditKey}
                              className="relative h-7 min-w-0 flex-1 rounded-md border border-foreground/25 bg-background px-2 text-xs shadow-xs outline-none focus:border-foreground/50"
                            />
                          ) : (
                            <span className="relative min-w-0 flex-1 truncate">{item.fact}</span>
                          )}
                          {!editing && item.scope && (
                            <span className="relative shrink-0 rounded-full border bg-background px-1.5 font-mono text-[10px] text-muted-foreground transition-opacity group-hover:opacity-0">
                              {item.scope}
                            </span>
                          )}
                          <div
                            className={cn(
                              "flex shrink-0 items-center gap-0.5",
                              editing || armed ? "relative" : "absolute right-2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100",
                            )}
                          >
                            {editing ? (
                              <>
                                <IconButton label="Save memory" onClick={commitEdit}>
                                  <Check className="size-3.5" aria-hidden />
                                </IconButton>
                                <IconButton label="Cancel edit" onClick={() => patch({ editingId: null })}>
                                  <X className="size-3.5" aria-hidden />
                                </IconButton>
                              </>
                            ) : (
                              <>
                                <IconButton label={`Edit: ${item.fact}`} onClick={() => patch({ editingId: item.id, editDraft: item.fact })}>
                                  <Pencil className="size-3" aria-hidden />
                                </IconButton>
                                <IconButton label={`Forget: ${item.fact}`} onClick={() => remove(item.id)} danger armed={armed}>
                                  <Trash2 className="size-3" aria-hidden />
                                </IconButton>
                              </>
                            )}
                          </div>
                        </div>
                      </motion.li>
                    );
                  })}
                </AnimatePresence>
              </ul>

              <form onSubmit={add} className="flex items-center gap-2 border-t px-4 py-2.5">
                <Plus className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
                <input
                  type="text"
                  value={draft}
                  onChange={(e) => setDraft(e.target.value)}
                  placeholder="Teach the agent a rule or preference…"
                  aria-label="New memory"
                  className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
                />
                <button
                  type="submit"
                  disabled={!draft.trim()}
                  className="h-7 shrink-0 cursor-pointer rounded-md bg-foreground px-2.5 text-[11px] font-medium text-background transition-opacity outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-30"
                >
                  Save
                </button>
              </form>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
      <style>{`
        .mem-flash { background: color-mix(in oklab, var(--foreground) 9%, transparent); animation: mem-flash 1.6s ease-out forwards; }
        @keyframes mem-flash { from { opacity: 1; } to { opacity: 0; } }
        .mem-shine { background: linear-gradient(100deg, transparent 35%, color-mix(in oklab, var(--foreground) 10%, transparent) 50%, transparent 65%); background-size: 250% 100%; animation: mem-shine 1.1s ease-out 1 forwards; }
        @keyframes mem-shine { from { background-position: 130% 0; } to { background-position: -30% 0; } }
      `}</style>
    </div>
  );
}

function IconButton({
  label,
  onClick,
  children,
  danger,
  armed,
}: {
  label: string;
  onClick: () => void;
  children: React.ReactNode;
  danger?: boolean;
  armed?: boolean;
}) {
  return (
    <motion.button
      type="button"
      aria-label={label}
      title={label}
      onClick={onClick}
      animate={{ scale: armed ? [1, 0.85, 1] : 1 }}
      transition={{ duration: 0.4 }}
      className={cn(
        "grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
        danger && "hover:text-red-600 dark:hover:text-red-400",
        armed && "bg-red-500/10 text-red-600 dark:text-red-400",
      )}
    >
      {children}
    </motion.button>
  );
}

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