undo and redo, page-wide: named steps, one gesture one step, each undo said
npx shadcn add @ag/historyAlready installed? Preview an update
npx shadcn add @ag/history --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-11-04
- New: undo and redo, page-wide.
lib/historyis the kernel, framework-free: named steps (a label, undo, redo, andreveal, 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: trueon a step (a delete) offers undo as it happens.useHistoryStatefor an undo button. - New:
titlenames the part on the page's sheet (key-sheet). Undo and redo are its declared actions (key-router'suseActions): listed there, and an undo with nothing to take back keeps ⌘Z while the part is in hand.
also called undo
shelf controls
uses keys, key-router, button, menu, shortcut, toast, tooltip
used by table
the demo above, verbatim
"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
"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: 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
}