← ui

history

ui

undo and redo, page-wide: named steps, one gesture one step, each undo said

npx shadcn add @ag/history
Already installed? Preview an update
npx shadcn add @ag/history --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-04
  • New: undo and redo, page-wide. lib/history is the kernel, framework-free: named steps (a label, undo, redo, and reveal, where the change is, brought into view as it is undone or redone), one gesture one step, typing on one target coalescing within a second, a new change clearing what was undone, a limit. useHistory({ root }) owns one for a part of the page and puts ⌘Z and ⇧⌘Z (Ctrl+Y off a Mac) on key-router, so they reach it wherever the focus is (in it, on a button or a bar that acted on it, or the part last used); a field's typing keeps its own undo. Every undo says what it took back in a toast offering redo; offer: true on a step (a delete) offers undo as it happens. useHistoryState for an undo button.
  • New: title names the part on the page's sheet (key-sheet). Undo and redo are its declared actions (key-router's useActions): listed there, and an undo with nothing to take back keeps ⌘Z while the part is in hand.
Updated 2026-11-04 · release notes

also called undo

shelf controls

uses keys, key-router, button, menu, shortcut, toast, tooltip

used by table

the demo above, verbatim

history.demo.tsx
"use client"

import { ArrowUp, Plus, Trash2 } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { Card } from "@/registry/base-nova/ui/card"
import {
  HistoryList,
  UndoRedo,
  useHistory,
} from "@/registry/base-nova/ui/history"

// #region list
// Every change is a named step: the list before and after, so undo and redo are exact.
// A delete offers undo as it happens; ⌘Z and ⇧⌘Z reach this list from anywhere on it,
// its buttons included, and from the page when it was the last thing used.
const START = ["water the basil", "book the ferry", "back up the sd cards"]

function List() {
  const root = React.useRef<HTMLDivElement>(null)
  const history = useHistory({ root })
  const [items, setItems] = React.useState(START)
  const seq = React.useRef(START.length)
  const change = (next: string[], label: string, offer = false) => {
    const before = items
    setItems(next)
    history.record(
      {
        label,
        undo: () => setItems(before),
        redo: () => setItems(next),
      },
      { offer },
    )
  }
  return (
    // A panel of work, undo and redo in its head where a toolbar keeps them, and
    // beside it the history's own panel, filling as the work is done.
    <div
      ref={root}
      className="grid w-full max-w-3xl items-start gap-5 md:grid-cols-[minmax(0,1fr)_16rem]"
    >
      <Card
        title="tasks"
        action={<UndoRedo history={history} />}
        footer={
          <Button
            variant="secondary"
            size="sm"
            onClick={() => {
              const item = `task ${++seq.current}`
              change([...items, item], `add ${item}`)
            }}
          >
            <Plus /> add a task
          </Button>
        }
      >
        <ul className="-mx-2 flex flex-col">
          {items.map((item, i) => (
            <li
              key={item}
              className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-fill-row-hover"
            >
              <span className="flex-1">{item}</span>
              <Button
                variant="ghost"
                size="icon-sm"
                aria-label={`move ${item} up`}
                disabled={i === 0}
                onClick={() =>
                  change(
                    [
                      ...items.slice(0, i - 1),
                      item,
                      items[i - 1] as string,
                      ...items.slice(i + 1),
                    ],
                    `move ${item} up`,
                  )
                }
              >
                <ArrowUp />
              </Button>
              <Button
                variant="ghost"
                size="icon-sm"
                aria-label={`delete ${item}`}
                onClick={() =>
                  change(
                    items.filter((x) => x !== item),
                    `delete ${item}`,
                    true,
                  )
                }
              >
                <Trash2 />
              </Button>
            </li>
          ))}
          {items.length === 0 && (
            <li className="px-2 py-1.5 text-sm text-foreground/45">
              no tasks: ⌘Z brings them back
            </li>
          )}
        </ul>
      </Card>
      <Card
        title="history"
        description="every change lands here; ⌘Z takes back the last, a press goes to any"
      >
        <HistoryList history={history} className="-mx-2" />
      </Card>
    </div>
  )
}
// #endregion

export default function HistoryDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="list"
        title="undo, named"
        description="Add, move and delete, then ⌘Z and ⇧⌘Z, or the pair of buttons, whose tooltips name the step they take back. Each undo says what it took back and offers redo; a delete offers undo as it happens. On the right, the history: a press on any step goes there; what is struck through is what ⇧⌘Z makes again."
        with="list"
      >
        <List />
      </Sample>
    </div>
  )
}

the source, every file an install writes

history.tsx
"use client"

// UNDO, page-wide: a history (lib/history) owned by a part of the page, its ⌘Z and ⇧⌘Z
// (and Ctrl+Y off a Mac) the page's keys, so they reach it wherever the focus is: in
// it, on a bar or a button that acted on it, or nowhere in particular. key-router
// decides whose they are as it decides every key: the part holding the focus, else the
// one under the pointer, else the one last used, else the newest. A press typed into a
// field is the field's own undo, never the history's.
//
//   const history = useHistory({ root: frame })
//   history.record({ label: "delete 3 rows", undo: () => set(before), redo: () => set(after) })
//
// Every undo and redo says what it took back or made again ("undid: delete 3 rows"), in
// a toast that offers the way back (redo, or undo again), and to a screen reader. A
// change a person could regret at once (a delete) is recorded with `offer: true`: the
// toast says it was done and offers undo, as the change happens.

import { Redo2, Undo2 } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  createHistory,
  type History,
  type HistoryState,
  type HistoryStep,
  type RecordOptions,
} from "@/registry/base-nova/lib/history-core"
import { Button } from "@/registry/base-nova/ui/button"
import { useActions } from "@/registry/base-nova/ui/key-router"
import type { MenuVerb } from "@/registry/base-nova/ui/menu"
import { usePlatform } from "@/registry/base-nova/ui/shortcut"
import { toast } from "@/registry/base-nova/ui/toast"
import { Tip } from "@/registry/base-nova/ui/tooltip"

export type {
  History,
  HistoryState,
  HistoryStep,
} from "@/registry/base-nova/lib/history-core"

/** A history whose undo and redo say what they did, and whose record can offer undo. */
export interface PageHistory extends History {
  record(step: HistoryStep, options?: RecordOptions & { offer?: boolean }): void
}

/** One history for a part of the page, its keys on the page's router. `root` is the
 *  part's element (focus, the pointer or a last press inside it puts it first);
 *  without one it answers only what nothing nearer takes. */
export function useHistory({
  root,
  title = "history",
  limit,
  enabled = true,
}: {
  root?: React.RefObject<Element | null>
  /** What the part is called on the page's sheet of keys, where undo and redo are
   *  listed ("budget.csv"). */
  title?: string
  limit?: number
  /** Off, its keys are not on the router (a picture of the part, an inert one). */
  enabled?: boolean
} = {}): PageHistory {
  const platform = usePlatform()
  const [history] = React.useState<PageHistory>(() => {
    const kernel = createHistory({ limit })
    const said = (step: HistoryStep | null, undid: boolean) => {
      if (!step) return step
      toast(`${undid ? "undid" : "redid"}: ${step.label}`, {
        action: {
          label: undid ? "redo" : "undo",
          onClick: () => (undid ? page.redo() : page.undo()),
        },
      })
      return step
    }
    const page: PageHistory = {
      ...kernel,
      record(step, { offer, ...options } = {}) {
        kernel.record(step, options)
        if (offer)
          toast(step.label, {
            action: { label: "undo", onClick: () => page.undo() },
          })
      },
      undo: () => said(kernel.undo(), true),
      redo: () => said(kernel.redo(), false),
      // A jump is said once, by where it landed, never step by step.
      goTo(count) {
        const moved = kernel.goTo(count)
        if (moved > 0) {
          const at = kernel.state().done.at(-1)
          toast(at ? `back to: ${at.label}` : "back to the start")
        }
        return moved
      },
    }
    return page
  })
  // Undo and redo as the part's actions: listed on the page's sheet under its title,
  // and unavailable (nothing to take back) still the part's while it is in hand, so
  // ⌘Z never undoes another part the person is not in.
  const { done, undone } = useHistoryState(history)
  useActions(
    {
      title,
      actions: [
        {
          id: "undo",
          label: done.at(-1) ? `undo: ${done.at(-1)?.label}` : "undo",
          keys: ["mod+z"],
          section: "edit",
          when: () => history.state().done.length > 0,
          run: () => history.undo(),
        },
        {
          id: "redo",
          label: undone.at(-1) ? `redo: ${undone.at(-1)?.label}` : "redo",
          keys:
            platform === "other" ? ["mod+shift+z", "ctrl+y"] : ["mod+shift+z"],
          section: "edit",
          when: () => history.state().undone.length > 0,
          run: () => history.redo(),
        },
      ],
    },
    { root, enabled },
  )
  return history
}

/** The steps now, re-rendering as they change: what an undo button says and whether it
 *  can be pressed. */
export function useHistoryState(history: History): HistoryState {
  return React.useSyncExternalStore(
    history.subscribe,
    history.state,
    history.state,
  )
}

// ── Its faces. One history, three places it shows, each reading it and never keeping
// a copy: a pair of buttons (a toolbar, a panel's head), its steps as a list to jump
// through (a panel), and its verbs for any menu. Each names the step it would take
// back or make again, so a person knows what a press does before pressing.

/** Undo and redo as a pair of buttons, each named by its step ("undo: delete 3 rows")
 *  in its tooltip with its keys, quiet while there is nothing to take back. */
export function UndoRedo({
  history,
  size = "icon-sm",
  className,
}: {
  history: History
  size?: "icon-sm" | "icon" | "icon-lg"
  className?: string
}) {
  const { done, undone } = useHistoryState(history)
  const back = done.at(-1)
  const ahead = undone.at(-1)
  return (
    <div data-slot="undo-redo" className={cn("flex items-center", className)}>
      <Tip
        content={back ? `undo: ${back.label}` : "nothing to undo"}
        keys="mod+z"
      >
        <Button
          variant="ghost"
          size={size}
          aria-label={back ? `undo: ${back.label}` : "undo"}
          disabled={!back}
          onClick={() => history.undo()}
        >
          <Undo2 />
        </Button>
      </Tip>
      <Tip
        content={ahead ? `redo: ${ahead.label}` : "nothing to redo"}
        keys="mod+shift+z"
      >
        <Button
          variant="ghost"
          size={size}
          aria-label={ahead ? `redo: ${ahead.label}` : "redo"}
          disabled={!ahead}
          onClick={() => history.redo()}
        >
          <Redo2 />
        </Button>
      </Tip>
    </div>
  )
}

/** The steps, oldest first, the start above them: a press on one goes there, undoing
 *  or redoing every step between, as a photo editor's history panel. Steps undone are
 *  struck through, there to go back to until a new change replaces them. */
export function HistoryList({
  history,
  className,
}: {
  history: History
  className?: string
}) {
  const { done, undone } = useHistoryState(history)
  const steps = [...done, ...[...undone].reverse()]
  const row = (label: string, count: number) => {
    const here = count === done.length
    const ahead = count > done.length
    return (
      <li key={count}>
        <button
          type="button"
          aria-current={here ? "step" : undefined}
          onClick={() => history.goTo(count)}
          className={cn(
            "flex w-full cursor-pointer items-center rounded-md px-2 py-1 text-left text-[13px] hover:bg-fill-row-hover",
            here ? "bg-fill-row-on text-foreground" : "text-foreground/70",
            ahead && "text-foreground/40 line-through",
          )}
        >
          {label}
        </button>
      </li>
    )
  }
  return (
    <ol
      data-slot="history-list"
      aria-label="history"
      className={cn("flex flex-col", className)}
    >
      {row("the start", 0)}
      {steps.map((s, i) => row(s.label, i + 1))}
    </ol>
  )
}

/** Undo and redo as a menu's verbs, named by their steps, with their keys: spread into
 *  any menu's entries (`[...historyVerbs(history), "separator", ...]`). */
export function historyVerbs(history: History): MenuVerb[] {
  const { done, undone } = history.state()
  const back = done.at(-1)
  const ahead = undone.at(-1)
  return [
    {
      icon: <Undo2 />,
      label: back ? `undo: ${back.label}` : "undo",
      hint: "mod+z",
      disabled: !back,
      onSelect: () => history.undo(),
    },
    {
      icon: <Redo2 />,
      label: ahead ? `redo: ${ahead.label}` : "redo",
      hint: "mod+shift+z",
      disabled: !ahead,
      onSelect: () => history.redo(),
    },
  ]
}
history-core.ts
// HISTORY: what can be undone, as named steps. The kernel under `ui/history` (which
// puts ⌘Z on the page and says what each undo did), with no React and no DOM, so any
// state can keep one: a table's sheet, a board's cards, a document.
//
// A STEP is a change already made, said by its name and how to take it back and make
// it again: `{ label: "delete 3 rows", undo, redo }`. The consumer makes the change,
// then records it; the history never makes one itself, it only replays them. A step's
// two functions are exact inverses the consumer owns (two states, or a move and its
// reverse), so redo is the change itself, never a guess at it.
//
// One gesture is one step. What a person did at once (a paste over a block, a fill, a
// drag that moved a card and opened a gap) is recorded once, its label the gesture's.
// Typing into one place coalesces: a step with the same `merges` key as the last one,
// recorded within `COALESCE_MS` of it, joins it (the first's undo, the newest's redo),
// so ⌘Z takes back the word typed, not its last letter.
//
// Recording a step clears what was undone: the future after a new change is a
// different future. The steps kept are at most `limit`, the oldest dropped first.
//
// Decided, built on this same API when a consumer needs it (TODO.md, "undo"): the
// branch a change after an undo leaves kept as a tree (⌘Z stays linear), steps as
// patches for an undo that survives a reload or is shared, selective undo (only one's
// own changes) for a multiplayer document, and a delayed commit for an effect with no
// inverse (a message sent), which is not undo at all.

export interface HistoryStep {
  /** What the change was, as a verb and its object ("move the card to done"):
   *  said when it is undone or redone. */
  label: string
  undo: () => void
  redo: () => void
  /** Where the change is, brought into view after it is undone or redone: its cells
   *  selected and scrolled to, its card focused, the caret where it was. A change
   *  taken back out of sight is how a person stops trusting undo. */
  reveal?: () => void
}

export interface RecordOptions {
  /** Steps of one kind on one target ("type:B3") that join the last one when it has
   *  the same key and is recent (`COALESCE_MS`). */
  merges?: string
}

export interface HistoryState {
  /** The steps that can be undone, oldest first. */
  done: readonly HistoryStep[]
  /** The steps that can be redone, the next one last. */
  undone: readonly HistoryStep[]
}

export interface History {
  /** The steps now, for a consumer drawing them (a button's label, a list). */
  state(): HistoryState
  record(step: HistoryStep, options?: RecordOptions): void
  /** Takes back the newest step and returns it; nothing to undo returns null. */
  undo(): HistoryStep | null
  /** Makes again the step undone last and returns it; nothing to redo returns null. */
  redo(): HistoryStep | null
  /** Goes to the point where `count` steps are done, undoing or redoing every step
   *  between (a history list's click), and returns how many it moved. */
  goTo(count: number): number
  /** Forgets every step (a document closed, a new one loaded). */
  clear(): void
  /** Called after every change to the steps; returns the way off. */
  subscribe(listener: () => void): () => void
}

/** How recent a step must be to join the last one of its kind, ms. */
export const COALESCE_MS = 1000
/** How many steps a history keeps unless told otherwise. */
export const LIMIT = 200

export function createHistory({
  limit = LIMIT,
  now = () => Date.now(),
}: {
  limit?: number
  now?: () => number
} = {}): History {
  if (!(limit >= 1))
    throw new Error(`history: a limit of ${limit} keeps nothing`)
  let state: HistoryState = { done: [], undone: [] }
  // The last step's merge key and when it was recorded, for coalescing.
  let last: { merges: string; at: number } | null = null
  const listeners = new Set<() => void>()
  const set = (next: HistoryState) => {
    state = next
    for (const l of listeners) l()
  }
  const history: History = {
    state: () => state,
    record(step, { merges } = {}) {
      const at = now()
      const prior = state.done.at(-1)
      const joins =
        merges !== undefined &&
        prior !== undefined &&
        last !== null &&
        last.merges === merges &&
        at - last.at <= COALESCE_MS
      last = merges === undefined ? null : { merges, at }
      if (joins) {
        const joined: HistoryStep = {
          label: step.label,
          undo: prior.undo,
          redo: step.redo,
          reveal: step.reveal,
        }
        set({ done: [...state.done.slice(0, -1), joined], undone: [] })
        return
      }
      set({ done: [...state.done, step].slice(-limit), undone: [] })
    },
    undo() {
      const step = state.done.at(-1)
      if (!step) return null
      last = null
      step.undo()
      step.reveal?.()
      set({ done: state.done.slice(0, -1), undone: [...state.undone, step] })
      return step
    },
    redo() {
      const step = state.undone.at(-1)
      if (!step) return null
      last = null
      step.redo()
      step.reveal?.()
      set({ done: [...state.done, step], undone: state.undone.slice(0, -1) })
      return step
    },
    goTo(count) {
      const total = state.done.length + state.undone.length
      if (!Number.isInteger(count) || count < 0 || count > total)
        throw new Error(`history: no point ${count} among ${total} steps`)
      let moved = 0
      while (state.done.length > count && history.undo()) moved++
      while (state.done.length < count && history.redo()) moved++
      return moved
    },
    clear() {
      last = null
      set({ done: [], undone: [] })
    },
    subscribe(listener) {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
  return history
}