How to Create Canvas Screenshots with Movable Elements
Build a draggable canvas editor, keep element positions in state, and export the finished composition as a PNG or JPEG.
To create a screenshot of a canvas with movable elements, model every shape, text item, and image as an object with coordinates; update that object while the pointer drags; redraw the canvas; then export the final composition with toBlob() or toDataURL(). A scene-graph library such as Konva supplies dragging and hit detection. With the raw Canvas API, you implement those pieces yourself.
This guide builds a complete dependency-free editor first, then shows the equivalent Konva approach, export settings, accessibility requirements, troubleshooting, and an API option for capturing the finished page.
1. A complete draggable canvas editor
Save this as index.html and open it in a browser. It supports rectangles, circles, and text; mouse, pen, and touch input through Pointer Events; selection; keyboard movement; PNG and JPEG export; cropping; and a higher-resolution export ratio.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Movable canvas screenshot</title>
<style>
body { font: 16px system-ui, sans-serif; margin: 2rem; background: #f3f4f6; }
.toolbar { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; }
button, select { padding: .55rem .8rem; }
canvas { display: block; max-width: 100%; border: 1px solid #9ca3af; background: white; touch-action: none; }
.status { min-height: 1.5em; }
</style>
<div class="toolbar">
<button id="png">Download PNG</button>
<button id="jpeg">Download JPEG</button>
<label>Pixel ratio <select id="ratio"><option>1</option><option selected>2</option><option>3</option></select></label>
</div>
<canvas id="editor" width="900" height="560" aria-label="Movable composition editor"></canvas>
<p class="status" id="status" role="status">Select an object, then drag it or use the arrow keys.</p>
<script>
const canvas = document.querySelector('#editor');
const ctx = canvas.getContext('2d');
const status = document.querySelector('#status');
const objects = [
{ id: 'card', kind: 'rect', x: 120, y: 110, w: 260, h: 150, fill: '#2563eb' },
{ id: 'dot', kind: 'circle', x: 560, y: 220, r: 70, fill: '#f97316' },
{ id: 'label', kind: 'text', x: 170, y: 190, text: 'Drag me', size: 34, fill: '#fff' }
];
let selected = null;
let drag = null;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const o of objects) {
ctx.save(); ctx.fillStyle = o.fill;
if (o.kind === 'rect') ctx.fillRect(o.x, o.y, o.w, o.h);
if (o.kind === 'circle') { ctx.beginPath(); ctx.arc(o.x, o.y, o.r, 0, Math.PI * 2); ctx.fill(); }
if (o.kind === 'text') { ctx.font = `${o.size}px system-ui`; ctx.fillText(o.text, o.x, o.y); }
if (o === selected) {
const b = bounds(o); ctx.strokeStyle = '#111827'; ctx.setLineDash([6, 4]);
ctx.strokeRect(b.x - 5, b.y - 5, b.w + 10, b.h + 10);
}
ctx.restore();
}
}
function bounds(o) {
if (o.kind === 'rect') return { x: o.x, y: o.y, w: o.w, h: o.h };
if (o.kind === 'circle') return { x: o.x - o.r, y: o.y - o.r, w: o.r * 2, h: o.r * 2 };
ctx.font = `${o.size}px system-ui`;
return { x: o.x, y: o.y - o.size, w: ctx.measureText(o.text).width, h: o.size };
}
function point(event) {
const r = canvas.getBoundingClientRect();
return { x: (event.clientX - r.left) * canvas.width / r.width, y: (event.clientY - r.top) * canvas.height / r.height };
}
function hit(p) {
for (let i = objects.length - 1; i >= 0; i--) {
const b = bounds(objects[i]);
if (p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h) return objects[i];
}
return null;
}
canvas.addEventListener('pointerdown', event => {
const p = point(event); selected = hit(p); if (!selected) { draw(); return; }
canvas.setPointerCapture(event.pointerId);
drag = { pointerId: event.pointerId, dx: p.x - selected.x, dy: p.y - selected.y };
status.textContent = `Selected ${selected.id}. Use arrow keys for precise movement.`; draw();
});
canvas.addEventListener('pointermove', event => {
if (!drag || !selected || event.pointerId !== drag.pointerId) return;
const p = point(event); selected.x = p.x - drag.dx; selected.y = p.y - drag.dy; draw();
});
canvas.addEventListener('pointerup', event => { if (drag?.pointerId === event.pointerId) drag = null; });
canvas.addEventListener('pointercancel', () => { drag = null; });
canvas.tabIndex = 0;
canvas.addEventListener('keydown', event => {
if (!selected) return;
const step = event.shiftKey ? 10 : 1;
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
if (!moves[event.key]) return;
event.preventDefault(); selected.x += moves[event.key][0]; selected.y += moves[event.key][1]; draw();
});
function download(type) {
const ratio = Number(document.querySelector('#ratio').value);
const mime = type === 'jpeg' ? 'image/jpeg' : 'image/png';
const out = document.createElement('canvas'); out.width = canvas.width * ratio; out.height = canvas.height * ratio;
const outCtx = out.getContext('2d'); outCtx.scale(ratio, ratio);
if (mime === 'image/jpeg') { outCtx.fillStyle = '#fff'; outCtx.fillRect(0, 0, canvas.width, canvas.height); }
outCtx.drawImage(canvas, 0, 0);
out.toBlob(blob => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `canvas.${type}`; a.click(); URL.revokeObjectURL(a.href); }, mime, .9);
}
document.querySelector('#png').onclick = () => download('png');
document.querySelector('#jpeg').onclick = () => download('jpeg');
draw();
</script>
</html>
How the interaction works
- Scene state: each object stores its type, position, dimensions, and style.
- Hit testing: on
pointerdown, the code checks objects from front to back and selects the first bounding box containing the pointer. - Coordinate conversion: the event's CSS coordinates are scaled into the canvas's internal coordinate system. This matters when CSS resizes the canvas.
- Dragging: the pointer offset is retained so the object does not jump when grabbed away from its origin.
setPointerCapture()keeps receiving events if the pointer leaves the canvas. - Rendering: every move updates state and redraws the complete scene.
- Export: the final scene is copied into an output canvas at the requested pixel ratio and encoded with
toBlob().
2. Using Konva for built-in dragging
Konva is an open-source 2D HTML5 Canvas framework with an object-oriented API. Its Stage contains Layer objects, and nodes can be made draggable with draggable: true. The official drag guide documents dragstart, dragmove, and dragend events.
import Konva from 'konva';
const stage = new Konva.Stage({ container: 'editor', width: 900, height: 560 });
const layer = new Konva.Layer();
const rectangle = new Konva.Rect({ x: 120, y: 110, width: 260, height: 150, fill: '#2563eb', draggable: true });
const label = new Konva.Text({ x: 170, y: 160, text: 'Drag me', fontSize: 34, fill: 'white', draggable: true });
layer.add(rectangle, label); stage.add(layer);
rectangle.on('dragend', () => savePosition('card', rectangle.position()));
label.on('dragend', () => savePosition('label', label.position()));
function savePosition(id, position) { console.log(id, position); }
function exportPng() { return stage.toDataURL({ mimeType: 'image/png', pixelRatio: 2 }); }
function exportJpeg() { return stage.toDataURL({ mimeType: 'image/jpeg', quality: 0.9, pixelRatio: 2 }); }
Use dragmove when other UI must react continuously; use dragend when you only need to persist the final coordinates. Konva also provides crop coordinates (x, y, width, height) and toBlob() for large exports. See the official drag-and-drop documentation and export documentation.
3. Export PNG, JPEG, a crop, or a high-resolution image
| Requirement | Setting |
|---|---|
| Transparency | PNG; do not paint a background first. |
| Photographic compression | JPEG with a quality value; JPEG cannot preserve transparency. |
| Retina output | Set pixelRatio: 2 or another required ratio. A 2× ratio creates four times the pixels. |
| Only part of the stage | Pass x, y, width, and height to the export method. |
| Large output | Prefer toBlob() to avoid constructing a very large data URL string. |
stage.toBlob({
mimeType: 'image/png',
pixelRatio: 2,
x: 100, y: 80, width: 600, height: 400
}).then(blob => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'selection.png';
link.click();
URL.revokeObjectURL(link.href);
});
Browser canvas dimensions and total pixel area are limited. Excessive dimensions or pixel ratios can produce a blank export, so choose the smallest ratio that meets the output requirement. Konva documents this limitation in its canvas export guidance.
4. Images, fonts, and cross-origin content
An image loaded from another origin can taint the canvas. The image server must send an appropriate Access-Control-Allow-Origin header, and crossOrigin must be set before assigning src.
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => { ctx.drawImage(image, 0, 0); };
image.src = 'https://example.com/image.png';
If either condition is missing, toDataURL() or toBlob() can fail with a security error. Wait for images and fonts to finish loading before enabling export.
5. Accessibility and touch support
- Canvas pixels are not keyboard controls. Provide HTML buttons or sliders for essential movement.
- Give the canvas a label and a keyboard focus target.
- Use arrow keys for one-pixel movement and Shift plus arrows for larger steps.
- Use Pointer Events and
touch-action: noneso touch, pen, and mouse input share one path. - Expose the selected object's name and coordinates in an accessible status region.
6. Persisting and restoring a composition
Store scene data, not a screenshot, when users need to reopen or edit a composition.
localStorage.setItem('scene', JSON.stringify(objects));
const restored = JSON.parse(localStorage.getItem('scene') || '[]');
Validate restored values, clamp objects to the stage bounds if required, and version the saved schema before adding new object types.
7. Raw Canvas, Konva, or Fabric.js?
| Axis | Raw Canvas API | Konva | Fabric.js |
|---|---|---|---|
| Dragging | You implement hit testing, coordinate conversion, pointer capture, and redraw. | draggable: true, hit detection, and drag events. |
Object events and transform events. |
| Export | Browser canvas methods. | toDataURL(), toBlob(), MIME, quality, crop, and pixel ratio. |
toDataURL() with format, quality, multiplier, crop, and retina options. |
| Framework fit | Depends on your application. | Documentation includes React, Vue, Svelte, and Angular examples. | Use its object API with your framework. |
8. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Shapes do not move | No hit test, or the selected object is not updated. | Check pointer coordinates against the object bounds and redraw after changing state. |
| Object jumps on pickup | The drag offset was discarded. | Store pointer minus object origin on pointerdown. |
| Dragging stops at the canvas edge | Pointer events are no longer delivered. | Call setPointerCapture(pointerId). |
Export raises SECURITY_ERR |
A cross-origin image tainted the canvas. | Enable CORS on the image response and set crossOrigin before src. |
| JPEG has a black or unexpected background | JPEG has no alpha channel. | Paint an explicit background before drawing the scene. |
| Export is blank or crashes | Canvas area or pixel ratio is too large. | Reduce dimensions or pixel ratio and export with toBlob(). |
| Text differs between screen and export | Fonts were not loaded before drawing. | Await document.fonts.ready before rendering and exporting. |
9. Or skip the browser setup
If the canvas editor is already rendered on a URL, ScreenshotNeo can capture the page with one request. Its clean-shot process accepts consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the verdict and billing status in X-Page-Verdict and X-Billed headers. See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
You can also set full-page capture, a CSS element, dark mode, a device preset or custom viewport, retina scale, custom CSS and JavaScript, waits, blocked resources, headers, cookies, user agent, timezone, geolocation, transparent background, resizing, caching, signed links, asynchronous webhooks, bulk capture, and PDF options. An MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000.
Create a free ScreenshotNeo account and get 1,000 screenshots a month with no card.
10. Performance, reliability, and cost checklist
- Redraw only the layer or region that changed when your scene is large.
- Throttle expensive layout or persistence work during
dragmove; save ondragendwhen intermediate positions are unnecessary. - Use a 1× export for previews and a higher ratio only for the final asset.
- Prefer blobs for downloads and revoke object URLs after use.
- Wait for every image and font before capture.
- For server captures, choose explicit wait conditions and cache stable pages when appropriate.
- Inspect
X-Page-VerdictandX-Billedwhen accounting for ScreenshotNeo usage.
FAQ
Can a normal HTML canvas drag shapes by itself?
No. Raw Canvas is a drawing surface. You must keep object state, hit-test the pointer, update coordinates, and redraw, or use a scene-graph library.
Should I use PNG or JPEG?
Use PNG for transparency and crisp graphics. Use JPEG when a smaller photographic export matters and an opaque background is acceptable.
Why does a 2× export use so much memory?
Doubling each dimension produces four times as many pixels. Reduce the ratio or export area when memory is limited.
Can keyboard users move canvas objects?
Yes, but you must add HTML or keyboard controls. The canvas bitmap does not expose individual shapes as native controls.
What is the simplest way to capture a finished canvas page?
Use a browser screenshot API such as ScreenshotNeo after the page has rendered, or export directly from the canvas when the image only needs to exist in the current browser.


