A React library for building interactive SVG canvas applications with pan, zoom, selection, drag-and-drop, resize, and Figma-style snapping.
- Pan & Zoom - Middle mouse/touch panning, mouse wheel/pinch-to-zoom
- Touch Support - Full touch event handling for mobile devices
- Selection System - Multi-select, rectangle selection, selection bounds
- Drag & Drop - Smooth dragging with window-level event handling
- Resize Handles - 8-point resize with min/max constraints
- Rotation - Object and group rotation with snap angles and pivot point manipulation
- Snapping - Figma-style snapping to edges, centers, grid, and matching sizes
- Geometry Utilities - Bounds operations, transforms, coordinate conversion
- Spatial Queries - Hit testing, rectangle selection, culling
- TypeScript - Full type definitions included
npm install react-svg-canvas
# or
pnpm add react-svg-canvas
# or
yarn add react-svg-canvasPeer Dependencies: React 18+
import { SvgCanvas, useSvgCanvas } from 'react-svg-canvas'
function MyCanvas() {
return (
<SvgCanvas
className="my-canvas"
style={{ width: '100%', height: '100%' }}
>
<rect x={100} y={100} width={200} height={150} fill="#3b82f6" />
<circle cx={400} cy={200} r={50} fill="#ef4444" />
</SvgCanvas>
)
}The main canvas component that provides pan, zoom, and coordinate transformation.
import { SvgCanvas, SvgCanvasHandle } from 'react-svg-canvas'
function App() {
const canvasRef = useRef<SvgCanvasHandle>(null)
return (
<SvgCanvas
ref={canvasRef}
className="canvas"
style={{ width: '100vw', height: '100vh' }}
fixed={<MyToolbar />} // Renders in screen space (not transformed)
onToolStart={(e) => console.log('Tool start:', e.x, e.y)}
onToolMove={(e) => console.log('Tool move:', e.x, e.y)}
onToolEnd={() => console.log('Tool end')}
onContextReady={(ctx) => console.log('Scale:', ctx.scale)}
>
{/* Children render in canvas space (transformed) */}
<MyShapes />
</SvgCanvas>
)
}| Prop | Type | Description |
|---|---|---|
className |
string |
CSS class for the SVG element |
style |
CSSProperties |
Inline styles for the SVG element |
children |
ReactNode |
Content rendered in canvas space (pan/zoom applied) |
fixed |
ReactNode |
Content rendered in screen space (UI overlays) |
onToolStart |
(e: ToolEvent) => void |
Called on left mouse/touch start |
onToolMove |
(e: ToolEvent) => void |
Called during drag |
onToolEnd |
() => void |
Called on mouse/touch end |
onContextReady |
(ctx: SvgCanvasContext) => void |
Called when context changes (zoom, pan) |
const canvasRef = useRef<SvgCanvasHandle>(null)
// Center viewport on a point
canvasRef.current?.centerOn(100, 100, 1.5) // x, y, optional zoom
// Fit a rectangle in view
canvasRef.current?.centerOnRect(0, 0, 500, 400, 50) // x, y, w, h, padding
// Get/set transform matrix
const matrix = canvasRef.current?.getMatrix()
canvasRef.current?.setMatrix([1, 0, 0, 1, 0, 0])| Input | Action |
|---|---|
| Left mouse | Tool events (onToolStart/Move/End) |
| Middle mouse | Pan canvas |
| Mouse wheel | Zoom in/out |
| Single touch | Tool events or pan |
| Two-finger pinch | Zoom |
Hook to access canvas context from child components.
function MyShape() {
const { svg, matrix, scale, translateTo, translateFrom } = useSvgCanvas()
// Convert screen coords to canvas coords
const [canvasX, canvasY] = translateTo(screenX, screenY)
// Convert canvas coords to screen coords
const [screenX, screenY] = translateFrom(canvasX, canvasY)
return <rect x={100} y={100} width={100 / scale} height={100 / scale} />
}Manages selection state for canvas objects.
import { useSelection, SpatialObject } from 'react-svg-canvas'
interface MyObject extends SpatialObject {
id: string
bounds: Bounds
color: string
}
function Canvas({ objects }: { objects: MyObject[] }) {
const {
selectedIds,
selectedObjects,
selectionCount,
selectionBounds,
hasSelection,
select,
selectMultiple,
deselect,
toggle,
clear,
selectAll,
selectInRect,
setSelection,
isSelected
} = useSelection({ objects, onChange: (ids) => console.log('Selection:', ids) })
return (
<SvgCanvas>
{objects.map(obj => (
<rect
key={obj.id}
{...obj.bounds}
fill={isSelected(obj.id) ? 'blue' : obj.color}
onClick={(e) => select(obj.id, e.shiftKey)}
/>
))}
{selectionBounds && (
<SelectionBox bounds={selectionBounds} onResizeStart={handleResize} />
)}
</SvgCanvas>
)
}Renders a selection rectangle with resize handles.
import { SelectionBox } from 'react-svg-canvas'
<SelectionBox
bounds={{ x: 100, y: 100, width: 200, height: 150 }}
rotation={45}
stroke="#0066ff"
strokeDasharray="4,4"
showHandles={true}
handleSize={8}
onResizeStart={(handle, e) => console.log('Resize:', handle)}
/>Provides smooth drag interaction with window-level events.
import { useDraggable, svgTransformCoordinates } from 'react-svg-canvas'
function DraggableRect({ x, y, onMove }) {
const { isDragging, dragProps } = useDraggable({
onDragStart: (e) => console.log('Start:', e.x, e.y),
onDragMove: (e) => onMove(e.deltaX, e.deltaY),
onDragEnd: (e) => console.log('End'),
transformCoordinates: svgTransformCoordinates // For SVG coordinate space
})
return (
<rect
x={x} y={y}
width={100} height={80}
fill={isDragging ? 'orange' : 'blue'}
style={{ cursor: 'move' }}
{...dragProps}
/>
)
}Provides resize interaction for selected objects.
import { useResizable } from 'react-svg-canvas'
function ResizableRect({ bounds, onResize }) {
const { isResizing, activeHandle, handleResizeStart } = useResizable({
bounds,
minWidth: 50,
minHeight: 50,
onResize: (e) => onResize(e.bounds),
onResizeEnd: (e) => console.log('Final bounds:', e.bounds)
})
return (
<SelectionBox
bounds={bounds}
onResizeStart={handleResizeStart}
/>
)
}Figma-style snapping with visual guide lines.
import { useSnapping, SnapGuides, DEFAULT_SNAP_CONFIG } from 'react-svg-canvas'
function Canvas({ objects }) {
const { svg, translateFrom } = useSvgCanvas()
const viewBounds = { x: 0, y: 0, width: 1000, height: 800 }
const { snapDrag, snapResize, activeSnaps, allCandidates, clearSnaps } = useSnapping({
objects,
config: DEFAULT_SNAP_CONFIG,
viewBounds
})
function handleDrag(objectId, bounds, delta, grabPoint) {
const result = snapDrag({
bounds: { ...bounds, rotation: 0 },
objectId,
delta,
grabPoint
})
// result.position contains snapped coordinates
// result.activeSnaps contains active snap info
}
return (
<SvgCanvas
fixed={
<SnapGuides
activeSnaps={activeSnaps}
config={DEFAULT_SNAP_CONFIG.guides}
viewBounds={viewBounds}
transformPoint={translateFrom}
/>
}
>
{/* Your objects */}
</SvgCanvas>
)
}Helper hook for calculating the normalized grab point when dragging objects.
import { useGrabPoint } from 'react-svg-canvas'
function MyDraggable({ bounds }) {
const { setGrabPoint, getGrabPoint } = useGrabPoint()
function handleDragStart(mousePos) {
setGrabPoint(mousePos, bounds)
}
function handleDrag(delta) {
const grabPoint = getGrabPoint() // Returns { x: 0-1, y: 0-1 }
// Use with snapDrag...
}
}const config: SnapConfiguration = {
enabled: true,
snapToGrid: true,
snapToObjects: true,
snapToSizes: true, // Snap to matching widths/heights
gridSize: 10,
snapThreshold: 8, // Pixels within which snapping activates
weights: {
distance: 10, // How much distance affects snap priority
direction: 3, // Movement direction influence
velocity: 2, // Faster movement = less sticky
grabProximity: 5, // Snaps near grab point prioritized
hierarchy: 4, // Parent/sibling preference
edgePriority: 1.2,
centerPriority: 1.0,
gridPriority: 0.8,
sizePriority: 0.9
},
guides: {
color: '#ff3366',
strokeWidth: 1,
showDistanceIndicators: true
},
debug: {
enabled: false,
showTopN: 5,
showScores: true,
showScoreBreakdown: false
}
}Object and group rotation with visual snap zones and pivot point manipulation.
Provides rotation interaction with visual snap zones. When the pointer is within the inner portion of the rotation arc (default 75%), angles snap to predefined values.
import { useRotatable, DEFAULT_SNAP_ANGLES } from 'react-svg-canvas'
function RotatableObject({ bounds, rotation, onRotate }) {
const { translateTo, translateFrom } = useSvgCanvas()
const {
rotationState,
handleRotateStart,
rotateProps,
checkSnapZone,
arcRadius,
pivotPosition
} = useRotatable({
bounds,
rotation,
pivotX: 0.5,
pivotY: 0.5,
snapAngles: DEFAULT_SNAP_ANGLES, // 15° intervals: [0, 15, 30, ...]
snapZoneRatio: 0.75, // Inner 75% of arc triggers snapping
translateTo,
translateFrom,
screenSpaceSnapZone: true, // Consistent UX at all zoom levels
onRotate: (angle, isSnapped) => onRotate(angle),
onRotateEnd: (angle) => console.log('Final:', angle)
})
return (
<g>
<rect {...bounds} />
<RotationHandle
position={pivotPosition}
arcRadius={arcRadius}
isInSnapZone={rotationState.isInSnapZone}
onPointerDown={handleRotateStart}
{...rotateProps}
/>
</g>
)
}For collaborative editing with external state (e.g., Yjs), use getter functions to avoid stale closures:
const { handleRotateStart } = useRotatable({
bounds,
rotation,
// Getters are called at drag start for fresh values
getBounds: () => yObject.get('bounds'),
getRotation: () => yObject.get('rotation'),
getPivot: () => ({ x: yObject.get('pivotX'), y: yObject.get('pivotY') }),
onRotate: (angle) => yObject.set('rotation', angle)
})Drag interaction for manipulating an object's rotation pivot point.
import { usePivotDrag, DEFAULT_PIVOT_SNAP_POINTS } from 'react-svg-canvas'
function PivotHandle({ bounds, rotation, pivotX, pivotY, onPivotChange }) {
const { translateTo } = useSvgCanvas()
const {
pivotState,
handlePivotDragStart,
pivotDragProps,
getPositionCompensation
} = usePivotDrag({
bounds,
rotation,
pivotX,
pivotY,
snapPoints: DEFAULT_PIVOT_SNAP_POINTS, // 9 points: corners, edges, center
snapThreshold: 0.08,
translateTo,
onDrag: (pivot, snappedPoint, positionCompensation) => {
// positionCompensation adjusts object position to keep it visually in place
onPivotChange(pivot, positionCompensation)
},
onDragEnd: (pivot, positionCompensation) => {
console.log('Final pivot:', pivot)
}
})
return (
<circle
cx={bounds.x + bounds.width * pivotX}
cy={bounds.y + bounds.height * pivotY}
r={6}
fill={pivotState.snappedPoint ? 'blue' : 'gray'}
onPointerDown={handlePivotDragStart}
{...pivotDragProps}
/>
)
}Manages a shared pivot point for rotating multiple selected objects together.
import { useGroupPivot } from 'react-svg-canvas'
function GroupRotationUI({ selectedObjects, selectionBounds }) {
const {
groupPivotState,
groupPivot,
handleGroupPivotDragStart,
groupPivotDragProps,
rotateObjectsAroundPivot,
resetPivotToCenter,
setGroupPivot
} = useGroupPivot({
objects: selectedObjects, // Array of { id, bounds, rotation, pivotX?, pivotY? }
selectionBounds,
onRotate: (angle, transformedObjects) => {
// transformedObjects: { id, x, y, rotation }[]
updateObjects(transformedObjects)
}
})
return (
<>
{/* Pivot handle */}
<circle
cx={groupPivot.x}
cy={groupPivot.y}
r={8}
fill={groupPivotState.isPivotCustom ? 'orange' : 'white'}
onPointerDown={handleGroupPivotDragStart}
{...groupPivotDragProps}
/>
{/* Reset button */}
<button onClick={resetPivotToCenter}>Reset Pivot</button>
</>
)
}import {
// Constants
DEFAULT_SNAP_ANGLES, // [0, 15, 30, 45, ..., 345]
DEFAULT_SNAP_ZONE_RATIO, // 0.75
DEFAULT_PIVOT_SNAP_THRESHOLD, // 0.08
// Angle utilities
getAngleFromCenter, // (center, point) => degrees
snapAngle, // (angle, snapAngles, isInSnapZone) => angle
findClosestSnapAngle, // (angle, snapAngles) => snapAngle
// Pivot utilities
getPivotPosition, // (bounds, pivotX, pivotY) => Point
calculatePivotCompensation, // Position adjustment when pivot moves
canvasToPivot, // Convert canvas coords to normalized pivot
snapPivot, // Snap to nearest snap point
// Rotation transforms
rotatePointAroundCenter, // (point, center, angleDeg) => Point
rotateObjectAroundPivot // Transform object position during rotation
} from 'react-svg-canvas'import {
getBoundsCenter,
expandBounds,
unionBounds,
unionAllBounds,
boundsIntersect,
boundsContains,
pointInBounds,
boundsFromPoints,
getHandlePositions,
resizeBounds
} from 'react-svg-canvas'
// Get center point
const center = getBoundsCenter({ x: 0, y: 0, width: 100, height: 100 })
// { x: 50, y: 50 }
// Expand bounds by margin
const expanded = expandBounds(bounds, 10)
// Union of two bounds
const combined = unionBounds(boundsA, boundsB)
// Check intersection
if (boundsIntersect(selection, object.bounds)) {
// Object is selected
}
// Create bounds from drag rectangle
const selectionRect = boundsFromPoints(startPoint, endPoint)
// Resize bounds from handle drag
const newBounds = resizeBounds(originalBounds, 'se', deltaX, deltaY, minW, minH)import {
transformPoint,
invertTransform,
composeTransforms,
matrixToTransform,
transformToMatrix,
getAbsolutePosition
} from 'react-svg-canvas'
// Apply transform to point
const worldPoint = transformPoint({ x: 10, y: 10 }, transform)
// Convert SVG matrix to transform object
const transform = matrixToTransform([1, 0, 0, 1, 100, 50])
// Get absolute position walking up hierarchy
const absPos = getAbsolutePosition(item, (item) => itemsById[item.parentId])import {
rotatePoint,
scalePoint,
distance,
snapToGrid,
snapPointToGrid,
lerp,
clamp,
normalizeAngle,
degToRad,
radToDeg
} from 'react-svg-canvas'
// Rotate point around center
const rotated = rotatePoint({ x: 100, y: 0 }, { x: 0, y: 0 }, 90)
// Snap to grid
const snapped = snapPointToGrid({ x: 123, y: 456 }, 10)
// { x: 120, y: 460 }import {
getObjectsAtPoint,
getTopmostAtPoint,
getObjectsIntersectingRect,
getObjectsContainedInRect,
getSelectionBounds,
getObjectsInView,
findNearestObject,
getObjectsInRadius
} from 'react-svg-canvas'
// Hit testing
const clicked = getTopmostAtPoint(objects, { x: mouseX, y: mouseY })
// Rectangle selection
const selected = getObjectsIntersectingRect(objects, selectionRect)
// Viewport culling (render only visible objects)
const visible = getObjectsInView(objects, viewBounds)
// Find nearest object
const nearest = findNearestObject(objects, cursorPos, maxDistance)interface Point {
x: number
y: number
}
interface Bounds {
x: number
y: number
width: number
height: number
}
interface Transform {
x: number
y: number
rotation: number
scaleX: number
scaleY: number
}
interface SpatialObject {
id: string
bounds: Bounds
}
interface ToolEvent {
startX: number
startY: number
x: number
y: number
}
type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
// Rotation types
interface RotationState {
isRotating: boolean
startAngle: number
currentAngle: number
centerX: number
centerY: number
isInSnapZone: boolean
}
interface PivotState {
isDragging: boolean
pivotX: number // 0-1 normalized
pivotY: number // 0-1 normalized
snappedPoint: Point | null
}
interface GroupPivotState {
isDragging: boolean
pivotX: number // Canvas coordinates
pivotY: number // Canvas coordinates
isPivotCustom: boolean // User moved pivot from default center
}import {
SvgCanvas,
SvgCanvasHandle,
useSelection,
useDraggable,
useSnapping,
SelectionBox,
SnapGuides,
DEFAULT_SNAP_CONFIG
} from 'react-svg-canvas'
function Editor() {
const canvasRef = useRef<SvgCanvasHandle>(null)
const [objects, setObjects] = useState<MyObject[]>(initialObjects)
const selection = useSelection({
objects,
onChange: (ids) => console.log('Selected:', ids)
})
const snapping = useSnapping({
objects,
config: DEFAULT_SNAP_CONFIG,
viewBounds: { x: 0, y: 0, width: 1920, height: 1080 }
})
return (
<SvgCanvas
ref={canvasRef}
style={{ width: '100%', height: '100vh' }}
onToolStart={(e) => {
const hit = getTopmostAtPoint(objects, e)
if (hit) selection.select(hit.id, false)
else selection.clear()
}}
fixed={
<SnapGuides
activeSnaps={snapping.activeSnaps}
config={DEFAULT_SNAP_CONFIG.guides}
viewBounds={viewBounds}
/>
}
>
{objects.map(obj => (
<DraggableShape
key={obj.id}
object={obj}
isSelected={selection.isSelected(obj.id)}
onMove={(delta) => {
const result = snapping.snapDrag({
bounds: obj.bounds,
objectId: obj.id,
delta,
grabPoint: { x: 0.5, y: 0.5 }
})
updateObject(obj.id, result.position)
}}
/>
))}
{selection.selectionBounds && (
<SelectionBox
bounds={selection.selectionBounds}
onResizeStart={handleResize}
/>
)}
</SvgCanvas>
)
}- Modern browsers with ES2021 support
- Touch devices (iOS Safari, Android Chrome)
MIT License - see LICENSE for details.
Szilard Hajba szilard@cloudillo.org