Blog |

Inside ss editor: a screenshot tool under 1MB

The average Electron screenshot tool weighs 120–200 MB on disk, spawns a full Chromium instance to run, and phones home before you've even opened an image. ss editor does the same job in a single HTML file under 1MB. Here's exactly how.

ss editor running in browser — hello world, we are live

The problem with screenshot tools

Open your Applications folder. Find your screenshot tool. Right-click → Get Info. I'll wait.

If it's Electron-based — and most of them are — you're looking at 150–250 MB for what is fundamentally an image editing canvas with four or five tools. That's not a tool. That's a runtime environment cosplaying as a tool. You're shipping an entire browser just to draw a red arrow on a PNG.

The web platform has had everything you need to build this since 2015. canvas, Clipboard API, FileReader, drag-and-drop events, pointer events for precise drawing. No framework. No build step. No installer. It was all sitting there, waiting for someone to just use it.

So we did.

The architecture (if you can call it that)

ss editor is one HTML file. Everything — HTML structure, CSS, and all JavaScript — lives in that single file. There is no bundler, no npm, no node_modules folder eating 400 MB of your SSD. You open it in a browser and it works. Even from file://. Even offline. Even in three years when half the internet is gone.

The core is a <canvas> element that fills the viewport. On top of it sits a transparent overlay div that captures pointer events for drawing. The image lives on the canvas. The annotations live on the canvas. When you export, the whole canvas flattens to a PNG. That's the entire architecture. A canvas, an overlay, and a clipboard call.

There's no state management library. State is just variables. There's no component system. There's one file. The complexity ceiling is low enough that you can read the entire thing in about 15 minutes and understand every line.

Getting images in: paste and drop

This is where most people expect magic. It's not. It's two event listeners.

Paste works by listening to the paste event on the document and reading event.clipboardData.items. You loop through the items, find the one with a type that starts with image/, call .getAsFile() on it, shove it into a FileReader, and draw the result onto the canvas when the reader fires onload. Twenty lines. Done.

document.addEventListener('paste', e => { const items = [...e.clipboardData.items]; const imageItem = items.find(i => i.type.startsWith('image/')); if (!imageItem) return; const file = imageItem.getAsFile(); const reader = new FileReader(); reader.onload = e => loadImage(e.target.result); reader.readAsDataURL(file); });

Drop is the same story. Listen to dragover, prevent the default (otherwise the browser navigates to the file), then on drop read event.dataTransfer.files[0] and hand it to the same FileReader. One code path for both inputs.

The annotation layer

Every tool — arrow, rectangle, text, blur — follows the same pattern. On pointerdown, record the start coordinates. On pointermove, redraw the canvas with a preview. On pointerup, commit the shape to a persistent array and stop previewing.

The redraw-on-every-move approach sounds expensive. It's not. Canvas is fast. Redrawing a 1080p image plus twenty annotations on every mousemove event is imperceptible on any hardware made in the last eight years. You don't need a dirty-rect optimization. You don't need a scene graph. You just clear and redraw.

Blur is the only one that requires a trick. You can't truly blur a region of a canvas mid-draw without compositing. The approach: on blur tool commit, use ctx.filter = 'blur(8px)', draw the source image clipped to the selected region onto a temporary canvas, then stamp that blurred region back onto the main canvas. It's a two-canvas trick and it works perfectly.

// blur a region: draw clipped blurred image onto temp canvas const tmp = document.createElement('canvas'); tmp.width = w; tmp.height = h; const tmpCtx = tmp.getContext('2d'); tmpCtx.filter = 'blur(8px)'; tmpCtx.drawImage(canvas, x, y, w, h, 0, 0, w, h); ctx.drawImage(tmp, x, y);

Export: why copy-to-clipboard is non-trivial

Saving as PNG is easy. canvas.toDataURL('image/png') gives you a base64 string, you create a fake anchor element, set its href to the data URL, programmatically click it, and the browser downloads the file. Ugly but effective and it's been working since IE10.

Copy to clipboard is the interesting one. The old way — document.execCommand('copy') — is deprecated and never supported image data properly anyway. The modern way is the Clipboard API: navigator.clipboard.write() with a ClipboardItem containing a blob.

The catch: the Clipboard API requires a secure context. https:// or localhost. Not file://. So for offline use we fall back to the old method and accept the limitation gracefully. When you're on https://sseditor.pages.dev, the modern path runs and you get a proper PNG on your clipboard that pastes into Slack, Figma, Linear, wherever.

// modern clipboard write canvas.toBlob(async blob => { await navigator.clipboard.write([ new ClipboardItem({ 'image/png': blob }) ]); }, 'image/png');

Under 1MB: how and why it matters

The file is under 1MB because we made deliberate choices at every step. No external fonts — system monospace stack. No icon library — the two icons we need are inline SVGs. No utility CSS framework — styles are written directly. No polyfills — we target modern browsers and accept that IE11 users will have a bad time.

The result is a file that loads in a single HTTP request, cached permanently on first visit, and works entirely offline after that. There's no waterfall. No JavaScript bundle to parse. No CSS to calculate after a framework hydrates. The browser gets one file and it runs immediately.

Under 1MB also means you can email it. Put it on a USB drive. Commit it to a repo and not feel bad about it. Host it on GitHub Pages for free. Send it to a friend over iMessage. The portability is a direct consequence of the size constraint, and the size constraint is a direct consequence of not reaching for dependencies as a first instinct.

What we gave up

Undo history. A proper undo stack would require storing snapshots of the canvas after every operation, which balloons memory fast. We kept it simple: you can clear and start over. That's not a great answer and we know it. It's on the list.

Layers. Every annotation bakes into the canvas on commit. You can't go back and move a box you drew three steps ago. Again — canvas is a raster surface, not a vector one. If you want layers you want Figma. ss editor is for the moment between "I need to annotate this" and "I need to send this".

Mobile. Pointer events work on touch but the UI is built for a mouse. We haven't optimised the touch experience and we're not apologising for it. It's a developer tool. Developers have keyboards.

Try it

Open sseditor.pages.dev, paste a screenshot, draw something, copy it. The whole thing takes under five seconds. If you want to poke around the source, view-source is right there in your browser. No build artifacts. No minification. Just the code.

If you find something broken or something you'd add — open an issue or a PR on GitHub. The rules are: don't add telemetry and don't add a dependency you can't justify in one sentence.