Layout
Place the canvas inside scrolling and transformed containers without coordinate drift.
ReactSketchCanvas reads pointer events in viewport-relative coordinates and subtracts the canvas's own getBoundingClientRect(). Both values update together when the page scrolls. They also update when an ancestor element scrolls or a CSS transform moves the canvas. Strokes land where the pointer is, no matter how the canvas is laid out.
These three demos cover the layout patterns most likely to cause coordinate drift in other canvas libraries.
Scrollable parent
A common layout puts the canvas inside an overflow-y: auto container (a side panel, a fixed-height annotation viewport). Whenever that container scrolls, clientX/clientY and getBoundingClientRect() both reflect the new viewport position, so the canvas-relative point stays correct.
Scroll the container, then draw. Every stroke lands exactly where the pointer is, including immediately after a scroll.
import { ArrowDown, ArrowUp, Milestone, RotateCcw, Trash2 } 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 scrollRef = useRef<HTMLDivElement>(null); const [scrollTop, setScrollTop] = useState(0); const handleScrollBy = (delta: number) => { scrollRef.current?.scrollBy({ top: delta, behavior: "smooth" }); }; const handleResetScroll = () => { scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }; const handleScroll = (event: React.UIEvent<HTMLDivElement>) => { setScrollTop(event.currentTarget.scrollTop); }; const handleClear = () => { canvasRef.current?.clearCanvas(); }; return ( <div className="not-prose flex flex-col gap-4 w-full"> {/* Scroll Controls bar */} <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-lg border border-fd-border bg-fd-card shadow-sm text-fd-foreground"> <div className="flex flex-wrap items-center gap-2"> <button type="button" onClick={() => handleScrollBy(-100)} className="inline-flex h-9 items-center gap-1 px-3 rounded-md border border-fd-border bg-fd-card text-xs font-semibold hover:bg-fd-accent shadow-sm transition-colors" > <ArrowUp className="w-3.5 h-3.5" /> Up </button> <button type="button" onClick={() => handleScrollBy(100)} className="inline-flex h-9 items-center gap-1 px-3 rounded-md border border-fd-border bg-fd-card text-xs font-semibold hover:bg-fd-accent shadow-sm transition-colors" > <ArrowDown className="w-3.5 h-3.5" /> Down </button> <button type="button" onClick={handleResetScroll} className="inline-flex h-9 items-center gap-1 px-3 rounded-md border border-fd-border bg-fd-card text-xs font-semibold hover:bg-fd-accent shadow-sm transition-colors" title="Reset Scroll" > <RotateCcw className="w-3.5 h-3.5 text-fd-muted-foreground" /> </button> <button type="button" onClick={handleClear} className="inline-flex h-9 items-center gap-1 px-3 rounded-md border border-fd-border bg-fd-card text-xs font-semibold text-red-500 hover:bg-red-50 dark:hover:bg-red-950/20 shadow-sm transition-all" > <Trash2 className="w-3.5 h-3.5" /> Clear </button> </div> {/* Scroll Meter Display */} <div className="flex items-center gap-3 p-2 px-3.5 rounded-md bg-fd-muted border border-fd-border shadow-inner text-xs min-w-[12rem] justify-between"> <span className="text-[10px] font-semibold uppercase tracking-wider text-fd-muted-foreground flex items-center gap-1.5"> <Milestone className="w-3.5 h-3.5 text-fd-primary" /> Scroll Offset </span> <span className="font-mono font-bold text-fd-foreground"> {Math.round(scrollTop)}px </span> </div> </div> {/* Scrollable Container */} <div ref={scrollRef} onScroll={handleScroll} className="relative h-60 overflow-y-auto rounded-lg border border-dashed border-fd-border/80 bg-fd-muted p-2 shadow-inner" > {/* Visual Grid Sheet Indicator */} <div className="relative h-[600px] w-full rounded border border-fd-border bg-fd-card shadow-sm overflow-hidden"> <div className="absolute inset-0 bg-[radial-gradient(#cbd5e1_1px,transparent_1px)] dark:bg-[radial-gradient(#334155_1px,transparent_1px)] [background-size:16px_16px] pointer-events-none opacity-40" /> {/* Canvas Area with Kalam accent text */} <div className="absolute top-4 left-4 font-display text-xs text-fd-muted-foreground flex items-center gap-1"> <span>Canvas Scroll Area (600px total height)</span> </div> <ReactSketchCanvas ref={canvasRef} width="100%" height="600px" strokeColor="var(--color-fd-primary)" canvasColor="transparent" /> </div> </div> </div> );}Nested scroll containers
Real layouts often have more than one scroll axis (a horizontally scrolling panel inside a vertically scrolling page). Reading viewport-relative coordinates makes the canvas immune to any number of scrolling ancestors: only the immediate getBoundingClientRect() matters, and the browser already accounts for every ancestor in that rectangle.
Scroll the outer container horizontally and the inner container vertically, then draw. Strokes land on the pointer regardless of either scroll position.
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Layers, Trash2,} 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 outerRef = useRef<HTMLDivElement>(null); const innerRef = useRef<HTMLDivElement>(null); const [outerScroll, setOuterScroll] = useState({ top: 0, left: 0 }); const [innerScroll, setInnerScroll] = useState({ top: 0, left: 0 }); const handleScrollOuter = (event: React.UIEvent<HTMLDivElement>) => { setOuterScroll({ top: event.currentTarget.scrollTop, left: event.currentTarget.scrollLeft, }); }; const handleScrollInner = (event: React.UIEvent<HTMLDivElement>) => { setInnerScroll({ top: event.currentTarget.scrollTop, left: event.currentTarget.scrollLeft, }); }; const scrollOuter = (top: number, left: number) => { outerRef.current?.scrollBy({ top, left, behavior: "smooth" }); }; const scrollInner = (top: number, left: number) => { innerRef.current?.scrollBy({ top, left, behavior: "smooth" }); }; const handleClear = () => { canvasRef.current?.clearCanvas(); }; const roundedOuterLeft = Math.round(outerScroll.left); const roundedOuterTop = Math.round(outerScroll.top); const roundedInnerLeft = Math.round(innerScroll.left); const roundedInnerTop = Math.round(innerScroll.top); return ( <div className="not-prose flex flex-col gap-4 w-full"> {/* Scroll Controls Bar */} <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-lg border border-fd-border bg-fd-card shadow-sm text-fd-foreground"> {/* Left: Tactile D-pad Controller */} <div className="flex items-center gap-4"> <div className="grid grid-cols-3 gap-1 w-24 h-24 bg-fd-muted border border-fd-border p-1 rounded-md shadow-inner flex-shrink-0"> {/* Row 1 */} <div /> <button type="button" onClick={() => scrollInner(-80, 0)} title="Scroll Inner Up" className="flex items-center justify-center rounded bg-fd-card border border-fd-border text-fd-foreground hover:bg-fd-accent shadow-sm transition-all focus:outline-none cursor-pointer" > <ArrowUp className="w-3.5 h-3.5" /> </button> <div /> {/* Row 2 */} <button type="button" onClick={() => scrollOuter(0, -80)} title="Scroll Outer Left" className="flex items-center justify-center rounded bg-fd-card border border-fd-border text-fd-foreground hover:bg-fd-accent shadow-sm transition-all focus:outline-none cursor-pointer" > <ArrowLeft className="w-3.5 h-3.5" /> </button> <button type="button" onClick={handleClear} title="Clear Canvas" className="flex items-center justify-center rounded bg-red-500/10 border border-red-500/20 text-red-500 hover:bg-red-500 hover:text-white transition-all focus:outline-none shadow-sm cursor-pointer" > <Trash2 className="w-3.5 h-3.5" /> </button> <button type="button" onClick={() => scrollOuter(0, 80)} title="Scroll Outer Right" className="flex items-center justify-center rounded bg-fd-card border border-fd-border text-fd-foreground hover:bg-fd-accent shadow-sm transition-all focus:outline-none cursor-pointer" > <ArrowRight className="w-3.5 h-3.5" /> </button> {/* Row 3 */} <div /> <button type="button" onClick={() => scrollInner(80, 0)} title="Scroll Inner Down" className="flex items-center justify-center rounded bg-fd-card border border-fd-border text-fd-foreground hover:bg-fd-accent shadow-sm transition-all focus:outline-none cursor-pointer" > <ArrowDown className="w-3.5 h-3.5" /> </button> <div /> </div> <div className="flex flex-col gap-0.5"> <span className="text-xs font-semibold text-fd-foreground"> Scroll D-Pad </span> <span className="text-[10px] text-fd-muted-foreground leading-normal max-w-[12rem]"> Outer pane scrolls horizontally (←/→); inner pane scrolls vertically (↑/↓). </span> </div> </div> {/* Right: Nested Meter readouts */} <div className="flex flex-col gap-2 p-2.5 px-3.5 rounded-md bg-fd-muted border border-fd-border shadow-inner text-xs min-w-[14rem] flex-1 sm:flex-initial"> <span className="text-[10px] font-semibold uppercase tracking-wider text-fd-muted-foreground flex items-center gap-1.5 border-b border-fd-border/30 pb-1.5 w-full"> <Layers className="w-3.5 h-3.5 text-fd-primary" /> Nested Offsets </span> <div className="grid grid-cols-2 gap-3 w-full"> <div className="flex flex-col"> <span className="text-[9px] uppercase font-bold text-fd-muted-foreground tracking-wider"> Outer Scroll </span> <span className="font-mono font-semibold text-xs mt-0.5 text-fd-foreground"> {roundedOuterLeft}, {roundedOuterTop}px </span> </div> <div className="flex flex-col border-l border-fd-border/30 pl-3"> <span className="text-[9px] uppercase font-bold text-fd-muted-foreground tracking-wider"> Inner Scroll </span> <span className="font-mono font-semibold text-xs mt-0.5 text-fd-foreground"> {roundedInnerLeft}, {roundedInnerTop}px </span> </div> </div> </div> </div> {/* Outer scroll pane (Horizontal) */} <div ref={outerRef} onScroll={handleScrollOuter} className="relative w-full overflow-x-auto overflow-y-hidden rounded-lg border border-dashed border-fd-border bg-fd-muted p-2 shadow-inner" > <div className="relative w-[1200px] p-2 bg-fd-card/50 rounded border border-fd-border/30"> <div className="absolute top-3 left-4 font-display text-[10px] text-fd-muted-foreground"> Outer Scroll Pane (1200px width horizontal scroll) </div> {/* Inner scroll pane (Vertical) */} <div ref={innerRef} onScroll={handleScrollInner} className="relative h-60 overflow-y-auto rounded border border-dashed border-sky-400/50 bg-fd-muted p-2 mt-6 shadow-inner" > <div className="relative h-[600px] w-full rounded border border-fd-border bg-fd-card shadow overflow-hidden"> <div className="absolute inset-0 bg-[radial-gradient(#cbd5e1_1px,transparent_1px)] dark:bg-[radial-gradient(#334155_1px,transparent_1px)] [background-size:16px_16px] pointer-events-none opacity-40" /> <div className="absolute top-4 left-4 font-display text-[10px] text-fd-muted-foreground"> Inner Scroll Pane (600px height vertical scroll) </div> <ReactSketchCanvas ref={canvasRef} width="100%" height="600px" strokeColor="#0ea5e9" canvasColor="transparent" /> </div> </div> </div> </div> </div> );}Scaled parent
When an ancestor uses transform: scale(), the canvas renders at the scaled size. Its on-screen position still comes from getBoundingClientRect(), which reports the post-transform rectangle. Pointer events arrive in the same post-transform space, so the visible stroke stays under the pointer at any zoom level.
Drag the slider to scale the wrapper between 0.5× and 1.5×, then draw at each size.
import { Trash2, ZoomIn } from "lucide-react";import { type ChangeEvent, useRef, useState } from "react";import { ReactSketchCanvas, type ReactSketchCanvasRef,} from "react-sketch-canvas";export default function App() { const canvasRef = useRef<ReactSketchCanvasRef>(null); const [scale, setScale] = useState(1); const handleScaleChange = (event: ChangeEvent<HTMLInputElement>) => { setScale(Number(event.target.value)); }; const handleClear = () => { canvasRef.current?.clearCanvas(); }; return ( <div className="not-prose flex flex-col gap-4 w-full"> {/* Scaling Controls Bar */} <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-lg border border-fd-border bg-fd-card shadow-sm text-fd-foreground"> {/* Scale Slider */} <div className="flex flex-1 flex-col gap-1.5 max-w-md"> <div className="flex justify-between items-center text-xs font-semibold uppercase tracking-wider text-fd-muted-foreground"> <span className="flex items-center gap-1.5" htmlFor="scale"> <ZoomIn className="w-3.5 h-3.5 text-fd-primary" /> Parent Scale Factor </span> <span className="font-mono text-fd-foreground bg-fd-muted border border-fd-border px-1.5 py-0.5 rounded text-[10px]"> {scale.toFixed(2)}x </span> </div> <input id="scale" type="range" min="0.5" max="1.5" step="0.05" value={scale} onChange={handleScaleChange} className="w-full accent-fd-primary cursor-pointer" /> </div> {/* Actions */} <div className="flex items-center gap-2"> <button type="button" onClick={handleClear} className="inline-flex h-9 items-center gap-1.5 px-3 rounded-md border border-fd-border bg-fd-card text-xs font-semibold text-red-500 hover:bg-red-50 dark:hover:bg-red-950/20 shadow-sm transition-all" > <Trash2 className="w-3.5 h-3.5" /> Clear Canvas </button> </div> </div> {/* Scaled Canvas Container */} <div className="overflow-hidden rounded-lg border border-fd-border bg-fd-muted p-2 shadow-inner min-h-[280px] flex items-start justify-start"> <div role="presentation" style={{ transform: `scale(${scale})`, transformOrigin: "top left", width: `${100 / scale}%`, }} className="relative rounded border border-dashed border-emerald-500/50 bg-fd-card shadow-sm overflow-hidden transition-transform duration-100 ease-out" > <div className="absolute inset-0 bg-[radial-gradient(#cbd5e1_1px,transparent_1px)] dark:bg-[radial-gradient(#334155_1px,transparent_1px)] [background-size:16px_16px] pointer-events-none opacity-40" /> {/* Scaled tag info */} <div className="absolute top-3 left-4 font-display text-[10px] text-fd-muted-foreground z-10 select-none"> Scaled Parent Viewport ({scale.toFixed(2)}x Zoom) </div> <ReactSketchCanvas ref={canvasRef} width="100%" height="240px" strokeColor="#16a34a" canvasColor="transparent" /> </div> </div> </div> );}