BE AWESOME
Settings
Web Tools
📅 May 9, 2026 · ⏱️ 4 min read

Image Editor: Everyday Photo Edits, Zero Uploads

Brightness, contrast, crop, rotate — the edits people actually reach for, computed live on the browser's own canvas instead of a server.

This post is about Image Editor: Everyday Photo Edits, Zero Uploads
Try the Image Editor →

The pitch for this one was: cover the photo edits people actually reach for — brightness, contrast, a crop, a rotate — without asking anyone to install anything or hand a file over to a server first. Brightness, contrast, saturation, hue shift, blur, sepia, grayscale, invert, flip, rotate, and crop. That's the whole feature set, and every single one of those runs on the canvas element already built into the browser. No upload step exists in this tool at all, because there was never a server round-trip to build one for.

The browser's canvas is more capable than people assume

A lot of people's mental model of "editing an image in a browser" still assumes it has to mean uploading to some service and waiting for a processed result to come back. The canvas API makes that assumption wrong for a huge share of common edits. Reading pixel data, running a brightness or contrast transform across it, and repainting the result is something a browser has been able to do natively for a long time — it's just underused for exactly this kind of tool. Grayscale and invert are close to a one-line math operation per pixel. Sepia is a fixed color matrix multiplication. None of it needs a server; it needs a canvas element and a loop, and the loop runs fast enough on a modern device that sliders can drive it live instead of needing an explicit "apply" button per adjustment.

Why every slider recomputes from the original, not the last result

The trap I almost fell into: applying each adjustment on top of whatever the canvas currently shows, so dragging the brightness slider back and forth would compound rounding error into the image on every single frame. Pixel math isn't perfectly reversible once you've quantized back down to 8-bit color channels — nudge brightness up and back down enough times against a live, already-adjusted canvas and you'll slowly crush detail that was never supposed to be lost. The fix is to always recompute every active adjustment fresh from the original, untouched image data on every slider movement, rather than stacking transforms on top of each other. It costs a little more computation per frame. It also means the tool doesn't quietly degrade an image just because you spent a while fine-tuning a slider.

Crop had to be its own subsystem

Every other tool here is a slider or a single-click transform. Crop needs a draggable selection box with resize handles, aspect-ratio presets, and a clear boundary between "you're positioning the crop" and "you've applied it," which is a meaningfully different interaction model from everything else in the editor. I built it as its own mode you explicitly enter and exit — Start Crop, drag or resize the box, Apply — rather than trying to make it feel like just another slider, because a crop is a destructive, deliberate action in a way that nudging saturation up ten percent isn't. Conflating the two would make crop feel more casual than it should, or make the sliders feel more consequential than they are.

Hue shift is the one that isn't just per-pixel math

Brightness and contrast are simple linear operations on RGB values directly, which is why they're cheap and why I mentioned them almost dismissively earlier. Hue shift isn't that simple, because hue isn't a channel that exists in RGB at all — it's a property of a completely different color representation. Shifting hue means converting each pixel from RGB into HSL, rotating the hue component around the color wheel, and converting back to RGB before painting it, for every pixel, on every slider movement. That's meaningfully more expensive than the other filters, and it's the one adjustment where I actually had to think about performance rather than just writing the obvious version and moving on.

I profiled it against a few different image sizes before shipping, specifically because a slider that's supposed to feel live becomes useless the moment it starts stuttering — at that point you're not editing an image anymore, you're waiting for one. The conversion math itself I couldn't simplify without changing what hue shift actually means, so the real lever was making sure the surrounding code — reading pixel data out, writing it back — wasn't doing any unnecessary extra work on top of the unavoidable per-pixel conversion.

What "no uploads" actually cost me in development time

Committing to zero server involvement meant giving up some conveniences a server-side image tool gets for free, like being able to run a heavier format-conversion library or offload an expensive filter to a beefier machine than whatever the visitor happens to be using. Every operation here has to run acceptably fast on an average laptop or phone, in JavaScript, in real time. That constraint shaped which features made the first cut — anything I couldn't make feel instant on modest hardware got pushed to "maybe later" rather than shipped sluggish. I'd rather have fewer filters that all feel immediate than a longer feature list where half of it makes people wait.

Reset All exists for a similar reason — once you're a few sliders and a crop deep into editing something, it's very easy to lose track of whether the current result is actually better than where you started, or just different. A single button that snaps everything back to the untouched original gives a real, honest reference point to compare against, instead of relying on memory of what the sliders looked like three adjustments ago. It's a tiny feature that mostly exists to make the rest of the tool feel less risky to experiment with.

PNG and JPG are the only export options right now, and that's another deliberate narrowing rather than an oversight. PNG for anything that needs to stay lossless or keep transparency, JPG for a smaller file size when neither of those matters. WebP would arguably be the better default for most cases, but export format support is a much smaller decision to get right than the actual editing pipeline, and I'd rather ship two formats that work everywhere without caveats than three where one of them behaves inconsistently across older browsers I haven't personally tested against yet.

Hello!