Fabric.js is an interactive canvas library with built-in serialization and drawing — new fabric.Canvas("id", { width, height }) creates the canvas. fabric.Rect, fabric.Circle, fabric.Path, and fabric.Image.fromURL are shape objects. canvas.add(obj) adds objects; canvas.remove(obj) removes them. canvas.isDrawingMode = true activates freehand drawing with canvas.freeDrawingBrush. shape.toJSON() / canvas.loadFromJSON(json) serialize the canvas state for save/load. fabric.Group clusters objects into one selectable unit. fabric.loadSVGFromString(svgString, callback) imports arbitrary SVG. image.filters.push(new fabric.Image.filters.Grayscale()) + image.applyFilters() adds visual effects. canvas.toDataURL({ format: "png", multiplier: 2 }) exports full canvas. canvas.on("object:selected") and canvas.on("object:modified") track selection state. Claude Code generates Fabric.js canvas editors, signature pads, banner designers, photo editors, and diagram tools.
CLAUDE.md for Fabric.js
## Fabric.js Stack
- Version: fabric >= 6.3, @types/fabric >= 5.3
- Init: const canvas = new fabric.Canvas("canvas-id", { width: 800, height: 600, selection: true })
- Add: canvas.add(new fabric.Rect({ left: 100, top: 100, width: 150, height: 80, fill: "#3B82F6" }))
- Events: canvas.on("object:modified", ({ target }) => pushHistory(canvas.toJSON()))
- Drawing: canvas.isDrawingMode = true; canvas.freeDrawingBrush.width = 4; canvas.freeDrawingBrush.color = "#000"
- Serialize: const json = canvas.toJSON(["id", "data"]); canvas.loadFromJSON(json, canvas.renderAll.bind(canvas))
- Export: canvas.toDataURL({ format: "png", multiplier: 2 })
- Image: fabric.Image.fromURL(url, (img) => { img.scale(0.5); canvas.add(img) })
Canvas Editor Setup
// components/canvas/FabricEditor.tsx — full canvas editor
"use client"
import { useEffect, useRef, useState, useCallback } from "react"
import { fabric } from "fabric"
type Tool = "select" | "draw" | "rect" | "circle" | "text" | "image"
interface EditorState {
activeTool: Tool
strokeColor: string
fillColor: string
strokeWidth: number
fontSize: number
canUndo: boolean
canRedo: boolean
}
const MAX_HISTORY = 50
export function FabricEditor() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const fabricRef = useRef<fabric.Canvas | null>(null)
const historyRef = useRef<string[]>([])
const historyIndexRef = useRef(-1)
const isHistoryActionRef = useRef(false)
const [state, setState] = useState<EditorState>({
activeTool: "select",
strokeColor: "#1F2937",
fillColor: "#3B82F6",
strokeWidth: 2,
fontSize: 20,
canUndo: false,
canRedo: false,
})
// Initialize Fabric.js canvas
useEffect(() => {
if (!canvasRef.current) return
const canvas = new fabric.Canvas(canvasRef.current, {
width: 900,
height: 600,
backgroundColor: "#ffffff",
preserveObjectStacking: true,
})
fabricRef.current = canvas
// Push initial state
pushHistory(canvas)
// History tracking
const onModified = () => {
if (!isHistoryActionRef.current) pushHistory(canvas)
}
canvas.on("object:added", onModified)
canvas.on("object:modified", onModified)
canvas.on("object:removed", onModified)
return () => {
canvas.dispose()
fabricRef.current = null
}
}, [])
const pushHistory = useCallback((canvas: fabric.Canvas) => {
const json = JSON.stringify(canvas.toJSON(["id", "lockMovementX", "lockMovementY"]))
const history = historyRef.current
const index = historyIndexRef.current
// Truncate redo history on new action
historyRef.current = history.slice(0, index + 1)
historyRef.current.push(json)
// Cap history
if (historyRef.current.length > MAX_HISTORY) {
historyRef.current = historyRef.current.slice(-MAX_HISTORY)
}
historyIndexRef.current = historyRef.current.length - 1
setState(prev => ({
...prev,
canUndo: historyIndexRef.current > 0,
canRedo: false,
}))
}, [])
const undo = useCallback(() => {
if (historyIndexRef.current <= 0) return
historyIndexRef.current--
restoreHistory()
}, [])
const redo = useCallback(() => {
if (historyIndexRef.current >= historyRef.current.length - 1) return
historyIndexRef.current++
restoreHistory()
}, [])
const restoreHistory = useCallback(() => {
const canvas = fabricRef.current
if (!canvas) return
isHistoryActionRef.current = true
const json = JSON.parse(historyRef.current[historyIndexRef.current])
canvas.loadFromJSON(json, () => {
canvas.renderAll()
isHistoryActionRef.current = false
setState(prev => ({
...prev,
canUndo: historyIndexRef.current > 0,
canRedo: historyIndexRef.current < historyRef.current.length - 1,
}))
})
}, [])
const setTool = useCallback((tool: Tool) => {
const canvas = fabricRef.current
if (!canvas) return
canvas.isDrawingMode = tool === "draw"
canvas.selection = tool === "select"
setState(prev => ({ ...prev, activeTool: tool }))
if (tool === "draw") {
canvas.freeDrawingBrush.width = state.strokeWidth
canvas.freeDrawingBrush.color = state.strokeColor
}
}, [state.strokeColor, state.strokeWidth])
const addShape = useCallback((type: "rect" | "circle") => {
const canvas = fabricRef.current
if (!canvas) return
const center = canvas.getCenter()
const common = {
left: center.left - 60,
top: center.top - 40,
fill: state.fillColor,
stroke: state.strokeColor,
strokeWidth: state.strokeWidth,
}
const shape = type === "rect"
? new fabric.Rect({ ...common, width: 120, height: 80, rx: 6, ry: 6 })
: new fabric.Circle({ ...common, radius: 60 })
canvas.add(shape)
canvas.setActiveObject(shape)
canvas.renderAll()
}, [state.fillColor, state.strokeColor, state.strokeWidth])
const addText = useCallback(() => {
const canvas = fabricRef.current
if (!canvas) return
const center = canvas.getCenter()
const text = new fabric.IText("Click to edit", {
left: center.left - 80,
top: center.top - 12,
fontSize: state.fontSize,
fill: state.strokeColor,
fontFamily: "Inter, sans-serif",
})
canvas.add(text)
canvas.setActiveObject(text)
text.enterEditing()
}, [state.fontSize, state.strokeColor])
const deleteSelected = useCallback(() => {
const canvas = fabricRef.current
if (!canvas) return
const active = canvas.getActiveObjects()
canvas.remove(...active)
canvas.discardActiveObject()
canvas.renderAll()
}, [])
const exportPNG = useCallback(() => {
const canvas = fabricRef.current
if (!canvas) return
const dataUrl = canvas.toDataURL({ format: "png", multiplier: 2 })
const a = document.createElement("a")
a.href = dataUrl
a.download = "canvas.png"
a.click()
}, [])
const loadImage = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const url = URL.createObjectURL(file)
fabric.Image.fromURL(url, (img) => {
const canvas = fabricRef.current
if (!canvas) return
// Scale to fit within canvas
const scale = Math.min(
(canvas.getWidth() * 0.5) / (img.width ?? 1),
(canvas.getHeight() * 0.5) / (img.height ?? 1),
1,
)
img.scale(scale)
img.set({ left: 50, top: 50 })
canvas.add(img)
canvas.setActiveObject(img)
canvas.renderAll()
URL.revokeObjectURL(url)
})
}, [])
return (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex items-center gap-2 p-2 bg-muted rounded-xl flex-wrap">
{(["select", "draw", "rect", "circle", "text"] as const).map(tool => (
<button
key={tool}
onClick={() => tool === "rect" || tool === "circle" ? addShape(tool) : tool === "text" ? addText() : setTool(tool)}
className={`px-3 py-1.5 rounded text-sm capitalize ${state.activeTool === tool ? "bg-primary text-primary-foreground" : "hover:bg-background"}`}
>
{tool}
</button>
))}
<div className="flex items-center gap-1 ml-2">
<label className="flex items-center gap-1 text-xs text-muted-foreground">
Fill
<input
type="color"
value={state.fillColor}
onChange={e => setState(prev => ({ ...prev, fillColor: e.target.value }))}
className="h-7 w-10 cursor-pointer rounded border"
/>
</label>
<label className="flex items-center gap-1 text-xs text-muted-foreground">
Stroke
<input
type="color"
value={state.strokeColor}
onChange={e => setState(prev => ({ ...prev, strokeColor: e.target.value }))}
className="h-7 w-10 cursor-pointer rounded border"
/>
</label>
</div>
<div className="ml-auto flex gap-2">
<button onClick={undo} disabled={!state.canUndo} className="btn-ghost text-sm px-2 py-1 disabled:opacity-40">↩ Undo</button>
<button onClick={redo} disabled={!state.canRedo} className="btn-ghost text-sm px-2 py-1 disabled:opacity-40">↪ Redo</button>
<button onClick={deleteSelected} className="btn-ghost text-sm px-2 py-1 text-red-500">Delete</button>
<label className="btn-outline text-sm px-3 py-1.5 cursor-pointer">
Image
<input type="file" accept="image/*" className="hidden" onChange={loadImage} />
</label>
<button onClick={exportPNG} className="btn-primary text-sm px-3 py-1.5">Export PNG</button>
</div>
</div>
{/* Canvas */}
<div className="rounded-xl border overflow-hidden">
<canvas ref={canvasRef} />
</div>
</div>
)
}
Signature Pad
// components/canvas/SignaturePad.tsx — signature capture
"use client"
import { useEffect, useRef, useCallback, useState } from "react"
import { fabric } from "fabric"
interface SignaturePadProps {
width?: number
height?: number
onSave?: (dataUrl: string) => void
}
export function SignaturePad({ width = 500, height = 200, onSave }: SignaturePadProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const fabricRef = useRef<fabric.Canvas | null>(null)
const [hasSignature, setHasSignature] = useState(false)
useEffect(() => {
if (!canvasRef.current) return
const canvas = new fabric.Canvas(canvasRef.current, {
width,
height,
backgroundColor: "#ffffff",
isDrawingMode: true,
})
canvas.freeDrawingBrush.color = "#1F2937"
canvas.freeDrawingBrush.width = 2
canvas.on("path:created", () => setHasSignature(true))
fabricRef.current = canvas
return () => { canvas.dispose() }
}, [width, height])
const clear = useCallback(() => {
fabricRef.current?.clear()
fabricRef.current?.setBackgroundColor("#ffffff", () => fabricRef.current?.renderAll())
setHasSignature(false)
}, [])
const save = useCallback(() => {
const canvas = fabricRef.current
if (!canvas || !hasSignature) return
const dataUrl = canvas.toDataURL({ format: "png", multiplier: 1 })
onSave?.(dataUrl)
}, [hasSignature, onSave])
return (
<div className="space-y-2">
<div className="border-2 border-dashed rounded-xl overflow-hidden" style={{ width, height }}>
<canvas ref={canvasRef} />
</div>
<div className="flex gap-2">
<button onClick={clear} className="btn-ghost text-sm px-3 py-1.5">Clear</button>
<button onClick={save} disabled={!hasSignature} className="btn-primary text-sm px-3 py-1.5 disabled:opacity-50">Save Signature</button>
</div>
</div>
)
}
For the Konva/react-konva alternative when React-idiomatic canvas with a declarative JSX API, better TypeScript types, and simpler state synchronization in React apps is preferred — Konva directly maps Canvas nodes to React components, while Fabric.js has a more imperative API better suited to complex editing tools with JSON serialization, see the Konva guide. For the Excalidraw alternative when a fully featured, open-source collaborative whiteboard with hand-drawn style and built-in shapes is needed — Excalidraw embeds as a React component with no setup while Fabric.js requires more custom implementation, see the Excalidraw guide. The Claude Skills 360 bundle includes Fabric.js skill sets covering canvas editors, signature pads, and image filters. Start with the free tier to try canvas drawing generation.