GODRICH

Image Crop JavaScript: 9 Canvas Editor UIs

An image crop JavaScript widget picks out the part of a photo worth keeping and throws the rest away.

Auto-plays · click a tile to jump to its section · all nine in one zip

Cropping, rotating, filtering, watermarking, and exporting are all here, and the order follows the path a single photo takes through an editor rather than how flashy each part is. The first three shape it: eight handles frame what stays (01), a crooked horizon comes back to level (02), then one tap swaps the whole tone (03). The next three tune the numbers: brightness, contrast, and saturation pull apart (04), an axis flips (05), and the frame pads out to a fixed ratio (06). The last three are what happens right before saving: type goes over the photo (07), the pixel spread shows whether you pushed too far (08), and a format and a quality build the file (09). No external image is loaded anywhere — the photo is composited on canvas from a gradient and a few shapes. For the hand-drawn side of canvas, see Signature Pad JavaScript: 9 Canvas Drawing UIs; for picking colors, see Custom Color Picker: 9 JS Parts, No Library.

01Free crop box

Four corners plus four edges give eight handles for framing what survives the cut. The dark surround is a single ten-point clip-path polygon that walks the outer rectangle and then traces the inner hole backward, and the result panel redraws only the rectangle that nine-argument drawImage carved out. Dragging the bottom-right handle in Playwright pulled the region from 162 × 112 down to 111 × 72, and the preview measured 18.917% changed area with 19 of its 23 frames moving.

setPointerCapturedrawImageclip-path
// The shade that darkens everything outside. Ten points: once around the outer box,
// then backwards around the inner hole, which leaves a rectangular gap
shade.style.clipPath = 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ' +
  x + '% ' + y + '%, ' + x + '% ' + (y + h) + '%, ' + (x + w) + '% ' + (y + h) + '%, ' +
  (x + w) + '% ' + y + '%, ' + x + '% ' + y + '%)';
var sx = photo.width * x / 100, sy = photo.height * y / 100;
var sw = photo.width * w / 100, sh = photo.height * h / 100;
var pw = Math.round(sw), ph = Math.round(sh);
// Size the result canvas to the crop itself — a fixed square would squash the ratio
out.width = pw; out.height = ph;
// Nine-argument drawImage: the rectangle to cut out and the rectangle to draw into
octx.drawImage(photo, sx, sy, sw, sh, 0, 0, pw, ph);

02Rotate and straighten

Sliding the angle tilts the photo, and the zoom needed to keep the corners covered is worked out in the same pass. At 12 degrees that zoom comes to 1.31, and the number is no guess — it falls straight out of how much the tilted rectangle's two sides grow. The grid preview starts tilted, straightens, then tips over again, measuring 22.342% changed area at an average intensity of 65.5.

rotateMath.cosaria-valuetext
// How far must a tilted rectangle grow to still cover the original box?
// The horizontal side stretches to w·|cos| + h·|sin| and the vertical one to w·|sin| + h·|cos| — take the larger
function zoomFor(rad, w, h) {
  var c = Math.abs(Math.cos(rad)), s = Math.abs(Math.sin(rad));
  return Math.max((w * c + h * s) / w, (w * s + h * c) / h);
}
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
vctx.translate(w / 2, h / 2);
vctx.rotate(rad);
vctx.scale(k, k);
vctx.drawImage(src, -w / 2, -h / 2, w, h);

03Six filter presets

Original, mono, sepia, vivid, muted, and vintage are each defined by exactly one filter string, and pressing one changes the big photo and the printed value together. Only the grid preview cycles through all six, and there every keyframe is padded out to the same six filter functions. Those identity values change nothing about the result, but without matching function lists the browser gives up on interpolating and the tone jumps in hard steps (19.97% changed area, 10 of 23 frames moving).

filtersepiasaturate
// Mismatched function lists make the browser give up on interpolation. Pad the gaps with identity values
@keyframes fpChain {
  0%, 10% { filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg); }
  14%, 24% { filter: grayscale(1) sepia(0) saturate(1) contrast(1.08) brightness(1) hue-rotate(0deg); }
  28%, 38% { filter: grayscale(0) sepia(0.75) saturate(1.2) contrast(1) brightness(1) hue-rotate(0deg); }
  42%, 52% { filter: grayscale(0) sepia(0) saturate(1.35) contrast(1.3) brightness(1) hue-rotate(0deg); }
  56%, 66% { filter: grayscale(0) sepia(0) saturate(0.55) contrast(1) brightness(1.08) hue-rotate(0deg); }
  70%, 85% { filter: grayscale(0) sepia(0.4) saturate(1) contrast(0.9) brightness(1) hue-rotate(-14deg); }
  89%, 100% { filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg); }
}

04Brightness, contrast and saturation sliders

The three values never travel separately: they fold into a single filter string, so the order you write them in is the order they apply. The compare button swaps that string for none to show the original in the very same spot, and the corner tag flips with it. Dragging the brightness track to the right updated brightness(1.35) and the spoken value "1.35 times" together in the measurement run (15.922% changed area, average intensity 56.2).

brightnesscontraststeps(1, end)
// Three sliders fold into a single filter string — the order written is the order applied
var chain = 'brightness(' + value.b.toFixed(2) + ') contrast(' + value.c.toFixed(2) + ') saturate(' + value.s.toFixed(2) + ')';
view.style.filter = showOriginal ? 'none' : chain;
code.textContent = chain;
tag.textContent = showOriginal ? TAG_ORIG : TAG_EDIT;
for (var i = 0; i < tracks.length; i++) {
  var key = tracks[i].getAttribute('data-k'), v = value[key];
  thumbs[key].style.left = ((v - 0.5) * 100) + '%';
  tracks[i].setAttribute('aria-valuenow', v.toFixed(2));
  tracks[i].setAttribute('aria-valuetext', VOICE.replace('%v', v.toFixed(2)));
}

05Flip and mirror

Turning one axis negative is the whole trick, except that the drawing then lands off-canvas, so it has to be shifted over by that side's length first. The preview cuts between four states with steps(1, end), because interpolating from 1 to -1 passes through a width of zero and would leave the card image as a single line. Cuts alone don't fill enough frames, so one light sweep runs across to reach 20 of 23 (29.441% changed area).

scaleXsetTransformtranslate
var w = view.width, h = view.height;
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
// A negative axis throws the drawing off-canvas, so shift it over by that side first
vctx.translate(flipX ? w : 0, flipY ? h : 0);
vctx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
vctx.drawImage(src, 0, 0, w, h);

06Square padding fill

When a wide photo has to meet a square spec, this pads the leftover space instead of cutting content away. The scale factor is the smaller of the inner size divided by the photo's width and by its height, so the long side touches first and nothing is lost. Four background colors swap in a single frame each while the padding keeps moving, which kept 22 of the 23 frames moving at an average intensity of 99.5.

fillRectaspect-ratiodrawImage
var s = out.width;
// Paint the whole square in the chosen color first, then seat the photo in the middle
octx.fillStyle = bg;
octx.fillRect(0, 0, s, s);
var inner = s * (1 - pad * 2);
var k = Math.min(inner / src.width, inner / src.height);
var dw = src.width * k, dh = src.height * k;
octx.drawImage(src, (s - dw) / 2, (s - dh) / 2, dw, dh);

07Text watermark

Drag the type where it belongs, tune its size and shadow, and the canvas bakes it in. A pale sky swallows white letters, so shadowBlur and a vertical offset have to ride along for the mark to stay readable; switching the toggle off drops both to zero. During the preview the canvas draws only the photo while a DOM ghost carries the letters, because type already baked into a canvas can't be moved by CSS (6.148% changed area, average intensity 77.7).

fillTextshadowBlurpointermove
vctx.textAlign = 'center';
vctx.textBaseline = 'middle';
vctx.fillStyle = 'rgba(255, 247, 230, 0.94)';
vctx.shadowColor = 'rgba(23, 20, 26, 0.62)';
// Just enough blur to lift the letters — at 0 they sink into the bright sky
vctx.shadowBlur = shadowOn ? 9 : 0;
vctx.shadowOffsetY = shadowOn ? 2 : 0;
vctx.fillText(mark, spot.x * w, spot.y * h);

08RGB histogram

The adjusted photo is redrawn onto a small off-screen canvas, and getImageData reads its pixels straight through, counting each channel into sixteen bins. Shifting a byte four bits to the right gives the bin number for free, and counting all 256 levels would leave the bars too thin to read as a shape. Pushing brightness to 1.55 shrank the painted area of the chart from 16.44% to 8.65% in the measurement run (16.857% changed area, all 23 frames moving).

getImageDatarequestAnimationFramescaleY
// ctx.filter is still missing in Safari (caniuse 81.08%). Without it, scale the bytes we read instead
var CAN_FILTER = typeof wctx.filter === 'string';

function countBins() {
  if (CAN_FILTER) { wctx.filter = 'brightness(' + level.toFixed(2) + ')'; }
  wctx.clearRect(0, 0, work.width, work.height);
  wctx.drawImage(view, 0, 0, work.width, work.height);
  var data = wctx.getImageData(0, 0, work.width, work.height).data;
  var bins = [], i;
  for (i = 0; i < BINS * 3; i++) { bins.push(0); }
  for (var p = 0; p < data.length; p += 4) {
    for (var ch = 0; ch < 3; ch++) {
      var v = CAN_FILTER ? data[p + ch] : Math.min(255, Math.round(data[p + ch] * level));
      bins[Math.min(BINS - 1, v >> 4) * 3 + ch] += 1;
    }
  }
  return bins;
}

09Export panel

Rather than estimating file size from a formula, toBlob builds the file and the panel reads its length. The same 320 × 200 photo came to 12.4 KB as PNG, 5.8 KB as JPG at quality 0.8, 3.3 KB as WEBP at 0.8, and 2.4 KB once quality dropped to 0.5. PNG is lossless and ignores the quality argument entirely, so the slider dims and aria-disabled goes on for that format (2.169% changed area, average intensity 108.0).

toBlobimage/webparia-valuetext
// A format the browser does not know quietly comes back as PNG per spec — image/webp is
// still missing in Safari (caniuse 81.11%). Compare blob.type and name what actually came out
function label(blob) {
  if (!blob) { return '— KB'; }
  return (blob.size / 1024).toFixed(1) + ' KB' + (blob.type === mime ? '' : ' · ' + blob.type);
}

function estimate() {
  view.toBlob(function (blob) { sizeEl.textContent = label(blob); }, mime, quality);
}

Where it breaks — the trap

The quietest breakage here is that two canvas features are missing in Safari alone. CanvasRenderingContext2D.filter sits at 81.08% globally and image/webp in toBlob at 81.11%, and both gaps are Safari-shaped — worse, neither one throws. Item 08 would count an unfiltered original and leave the bars motionless, while 09 would take a WEBP request and hand back a PNG exactly as the spec says, reporting 12.4 KB where the panel promised 3.3 KB. So 08 now checks typeof wctx.filter === 'string' and scales the bytes itself when the property is missing, and 09 compares blob.type against the chosen format and names whatever actually arrived. CSS filter, by contrast, is at 97.02%, so items 03 and 04 can use it as is.

Feature Global support Missing in What these nine do
CSS filter 97.02% nothing 03 and 04 use it directly
ctx.filter 81.08% Safari (still off by default in 18) 08 scales pixels itself
image/webp in toBlob 81.11% every Safari version 09 prints blob.type

The second trap was the preview drifting out of sync with the words on screen. In 03 the selection ring slid smoothly, so the frame where the ring had already landed became the card image and read "ring on Vivid, sepia values underneath"; cutting the ring and the text at the same instant fixed it. The third is contrast. The cream heading over the orange stage in 06 measured 3.11:1 against #ff4d1f, short of AA, and switching it to ink brought it to 5.50:1; the pressed pills in 05 and 07 were cream over #2f6df6 at 4.25:1 and went white for 4.53:1 (evidence: run/295/_수리전실측.json and run/295/_probe_contrast/_대비실측.json). The zip that bundles all nine editors is locked with z3y6mq2c, and inside it each numbered item carries a vanilla build and a React build side by side. Sources: MDN drawImage and caniuse.

Accessibility (reduced-motion)

A canvas is just a picture to a screen reader, so all nine carry role="img" with an aria-label, and in 01, 03, and 05 that label is rewritten every time the value changes (01 announces its crop as 111 × 72 pixels). The eight sliders use role="slider", take their name from aria-labelledby or aria-label, and keep aria-valuenow and aria-valuetext current, while the toggles are real buttons with aria-pressed. The keyboard reaches everything the pointer does: focus a handle in 01, press the right arrow ten times and the region goes from 162 × 112 to exactly 152 × 112, then one Shift plus left arrow puts it back at 162 × 112. Under prefers-reduced-motion all nine preview loops are switched off with !important and the finished edit stays on screen, so 02 holds its tilted photo at -12.0 degrees and 06 holds the ink-backed square with its wider padding.

FAQ

Can a photo really be cropped without sending the file to a server?

Yes. All nine run entirely inside the browser and make no network requests at all. A file the visitor picks can go through URL.createObjectURL into an <img>, and that image becomes the first argument to drawImage; these demos just put a canvas-composited stand-in photo there so they open without a file of your own.

How do I save the crop at the original resolution?

Do the arithmetic against the original pixels instead of the size on screen. Item 01 keeps its crop region purely as percentages and multiplies by photo.width only at draw time, so a 4000px source yields a rectangle at 4000px scale from those very same percentages. The trick is setting the result canvas width and height to that pixel size.

What about comparing before and after side by side?

Item 04 is an A/B toggle that swaps the two in one place. If you want the kind where a handle wipes between two images instead, 9 Before After Slider UIs — Drag to Compare covers that pattern, and since the two answer different questions they sit happily side by side.

Enter the archive password

The password is inside this article. You will find it as you read.