React Sketch Canvas
Guides

History

Wire undo, redo, clear, and reset into your own toolbar.

Drawing interfaces need escape hatches. Users should be able to undo a shaky stroke, redo an action they changed their mind about, and clear or reset the canvas when they want to start over.

The canvas exposes these as ref methods. Your toolbar stays as ordinary React UI; the methods act on the canvas's internal history.

Undo and redo

undo() removes the most recent stroke. redo() puts it back. Both are safe to call when there's nothing to undo or redo; they are no-ops.

App.tsx
import { Eraser, Pencil, Redo2, RotateCcw, Trash2, Undo2 } from "lucide-react";import { useRef, useState } from "react";import {	ReactSketchCanvas,	type ReactSketchCanvasRef,} from "react-sketch-canvas";export default function App() {	const canvasRef = useRef<ReactSketchCanvasRef>(null);	const [eraseMode, setEraseMode] = useState(false);	const handleEraserClick = () => {		setEraseMode(true);		canvasRef.current?.eraseMode(true);	};	const handlePenClick = () => {		setEraseMode(false);		canvasRef.current?.eraseMode(false);	};	const handleUndoClick = () => {		canvasRef.current?.undo();	};	const handleRedoClick = () => {		canvasRef.current?.redo();	};	const handleClearClick = () => {		canvasRef.current?.clearCanvas();	};	const handleResetClick = () => {		canvasRef.current?.resetCanvas();	};	return (		<div className="not-prose flex flex-col gap-4 w-full">			{/* Unified History & Utility Drawing Toolbar */}			<div className="flex flex-wrap items-center justify-between gap-4 p-3 rounded-lg border border-fd-border bg-fd-card shadow-sm text-fd-foreground">				{/* Draw vs Erase Segment */}				<div className="flex items-center gap-2">					<div className="inline-flex rounded-md p-1 bg-fd-muted border border-fd-border">						<button							type="button"							onClick={handlePenClick}							className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 ${								!eraseMode									? "bg-fd-primary text-fd-primary-foreground shadow-sm"									: "text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-accent/50"							}`}						>							<Pencil className="w-3.5 h-3.5" />							Pen						</button>						<button							type="button"							onClick={handleEraserClick}							className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 ${								eraseMode									? "bg-fd-primary text-fd-primary-foreground shadow-sm"									: "text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-accent/50"							}`}						>							<Eraser className="w-3.5 h-3.5" />							Eraser						</button>					</div>				</div>				{/* History Actions (Undo/Redo) */}				<div className="flex items-center gap-1.5">					<button						type="button"						onClick={handleUndoClick}						title="Undo (Ctrl+Z)"						className="inline-flex items-center justify-center p-2 rounded-md border border-fd-border bg-fd-card text-fd-foreground hover:bg-fd-accent transition-colors duration-150 shadow-sm"					>						<Undo2 className="w-4 h-4" />						<span className="sr-only">Undo</span>					</button>					<button						type="button"						onClick={handleRedoClick}						title="Redo (Ctrl+Y)"						className="inline-flex items-center justify-center p-2 rounded-md border border-fd-border bg-fd-card text-fd-foreground hover:bg-fd-accent transition-colors duration-150 shadow-sm"					>						<Redo2 className="w-4 h-4" />						<span className="sr-only">Redo</span>					</button>				</div>				{/* Canvas Cleanup Actions */}				<div className="flex items-center gap-2">					<button						type="button"						onClick={handleClearClick}						className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-fd-border bg-fd-card text-xs font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-950/20 transition-all duration-150 shadow-sm"					>						<Trash2 className="w-3.5 h-3.5" />						Clear					</button>					<button						type="button"						onClick={handleResetClick}						className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-fd-border bg-fd-card text-xs font-medium text-fd-foreground hover:bg-fd-accent transition-all duration-150 shadow-sm"					>						<RotateCcw className="w-3.5 h-3.5" />						Reset					</button>				</div>			</div>			{/* Drawing Workspace */}			<div className="relative overflow-hidden rounded-lg border border-fd-border aspect-video min-h-[240px] shadow-sm">				<ReactSketchCanvas					ref={canvasRef}					strokeColor="var(--color-fd-primary)"					canvasColor="transparent"				/>			</div>		</div>	);}

Scrub through saved strokes

exportPaths() returns an ordered list of strokes. If you save that list, you can replay the drawing by loading the first stroke, then the first two strokes, and so on.

This example loads the paths from initialSketch.json and uses a slider to choose how many saved strokes are visible.

App.tsx
import { ListOrdered } from "lucide-react";import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react";import {	type CanvasPath,	ReactSketchCanvas,	type ReactSketchCanvasRef,} from "react-sketch-canvas";import savedPaths from "../assets/initialSketch.json";const paths = savedPaths as CanvasPath[];const previewPadding = 24;const minX = Math.min(	...paths.flatMap((path) => path.paths.map((point) => point.x)),);const minY = Math.min(	...paths.flatMap((path) => path.paths.map((point) => point.y)),);const previewPaths = paths.map((path) => ({	...path,	paths: path.paths.map((point) => ({		x: point.x - minX + previewPadding,		y: point.y - minY + previewPadding,	})),}));const rangeInputClass = "h-9 w-full cursor-pointer accent-fd-primary";function formatPathLabel(path: CanvasPath, index: number) {	const pointCount = path.paths.length;	const mode = path.drawMode ? "Draw" : "Erase";	return `Stroke ${index + 1}: ${mode}, ${pointCount} point${		pointCount === 1 ? "" : "s"	}`;}function getPathKey(path: CanvasPath) {	const firstPoint = path.paths[0];	const lastPoint = path.paths.at(-1);	return [		path.strokeColor,		path.strokeWidth,		path.startTimestamp,		path.endTimestamp,		firstPoint?.x,		firstPoint?.y,		lastPoint?.x,		lastPoint?.y,	].join(":");}export default function HistoryScrubber() {	const canvasRef = useRef<ReactSketchCanvasRef>(null);	const [visiblePathCount, setVisiblePathCount] = useState(paths.length);	const visiblePaths = useMemo(		() => previewPaths.slice(0, visiblePathCount),		[visiblePathCount],	);	useEffect(() => {		canvasRef.current?.resetCanvas();		const frame = window.requestAnimationFrame(() => {			if (visiblePaths.length > 0) {				canvasRef.current?.loadPaths(visiblePaths);			}		});		return () => window.cancelAnimationFrame(frame);	}, [visiblePaths]);	const handleScrubChange = (event: ChangeEvent<HTMLInputElement>) => {		setVisiblePathCount(Number(event.target.value));	};	return (		<div className="not-prose grid w-full gap-4 xl:grid-cols-[minmax(0,1fr)_14rem] xl:items-start">			<div className="grid gap-4">				<div className="relative min-h-[240px] overflow-hidden rounded-lg border border-fd-border bg-fd-card shadow-sm aspect-video">					<ReactSketchCanvas						ref={canvasRef}						canvasColor="transparent"						readOnly						strokeColor="var(--color-fd-primary)"					/>				</div>				<label className="grid gap-2 rounded-lg border border-fd-border bg-fd-card p-3 text-fd-foreground shadow-sm">					<span className="flex flex-wrap items-center justify-between gap-2 text-xs font-semibold uppercase tracking-wider text-fd-muted-foreground">						<span>Playback position</span>						<span className="rounded border border-fd-border bg-fd-muted px-2 py-1 font-mono text-[11px] text-fd-foreground">							{visiblePathCount} / {paths.length} strokes						</span>					</span>					<input						type="range"						min="0"						max={paths.length}						value={visiblePathCount}						onChange={handleScrubChange}						onInput={handleScrubChange}						className={rangeInputClass}						aria-label="Choose how many saved strokes to show"					/>				</label>			</div>			<aside className="rounded-lg border border-fd-border bg-fd-card text-fd-foreground shadow-sm">				<div className="flex items-center gap-2 border-b border-fd-border px-3 py-2.5">					<ListOrdered className="h-4 w-4 text-fd-primary" />					<div>						<h3 className="text-sm font-semibold">Saved stroke list</h3>						<p className="text-xs text-fd-muted-foreground">							The slider replays this array from the first stroke onward.						</p>					</div>				</div>				<ol className="grid max-h-64 grid-cols-1 gap-1 overflow-auto p-2 sm:grid-cols-2 xl:max-h-[18rem] xl:grid-cols-1">					{paths.map((path, index) => {						const isVisible = index < visiblePathCount;						const isCurrent = index === visiblePathCount - 1;						return (							<li key={getPathKey(path)}>								<button									type="button"									onClick={() => setVisiblePathCount(index + 1)}									aria-current={isCurrent ? "step" : undefined}									className={`grid w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-2 rounded-md px-2 py-2 text-left text-xs transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-primary ${										isVisible											? "text-fd-foreground hover:bg-fd-accent"											: "text-fd-muted-foreground hover:bg-fd-muted"									} ${isCurrent ? "bg-fd-accent" : ""}`}								>									<span										className="h-2.5 w-2.5 rounded-full border border-fd-border"										style={{											backgroundColor: path.drawMode												? path.strokeColor												: "var(--color-fd-muted)",										}}									/>									<span className="truncate">										{formatPathLabel(path, index)}									</span>								</button>							</li>						);					})}				</ol>			</aside>		</div>	);}

Clear vs reset

These two methods look similar but differ in one important way.

  • clearCanvas() removes the visible paths while keeping the canvas ready for more drawing. Undo can still bring strokes back.
  • resetCanvas() returns the canvas to its initial state. Use it when you have loaded paths or a preconfigured example and want a true blank slate.

When replacing a drawing loaded with loadPaths(), call resetCanvas() first. See Exporting & saving.

resetCanvas() is destructive. The history is cleared, so undo will not bring strokes back after a reset. Prompt the user if losing the drawing would be unexpected.

HistoryToolbar.tsx
export function () {
  const  = <ReactSketchCanvasRef>(null);

  return (
    <>
      < ={} />
      < ="button" ={() => .?.()}> // [!code highlight]
        Undo
      </>
      < ="button" ={() => .?.()}> // [!code highlight]
        Redo
      </>
      < ="button" ={() => .?.()}> // [!code highlight]
        Clear
      </>
      < ="button" ={() => .?.()}> // [!code highlight]
        Reset
      </>
    </>
  );
}

On this page