Architecture

The core concepts behind how Kiri is built.

Stage, frame, image layer

Circle and rounded-rectangle frames are real clips, not just an overlay

On screen, a circle frame is a CSS border-radius: 50% border plus a dimming overlay outside it — but export() and upload() actually clip the rendered output to match, using a canvas ellipse() clip path before the final draw. PNG/WebP exports get real transparent corners; JPEG has no alpha channel, so a circle export as JPEG warns and renders solid black corners instead.

If frame.width !== frame.height, the "circle" is really an ellipse (matching what border-radius: 50% does to a non-square box) — the export follows exactly, inscribed in the same width/height. Use equal width/height for a true circle.

A rounded-rectangle frame works the same way — on screen it's a CSS border-radius: <cornerRadius>px (a per-instance pixel value, so it's set as an inline style rather than a fixed CSS rule), and export clips with a canvas roundRect() path using the same radius. If the output size passed to export() differs from frame.width/height (a custom width/ height option), the radius is scaled proportionally so a larger or smaller export still looks the same shape.

Movable frame mode

movableFrame: true inverts which element is interactive: the image is fixed at load — no pan, no zoom — and dragging or the arrow keys move the frame over it instead, via setFramePosition(). Combined with resizableFrame, its corner handles resize the frame too, capped at the image's own bounds instead of growing the image to compensate. The stage itself also stays pinned to the fixed image's own size in this mode, rather than following the frame the way it normally does — letting the stage shrink along with the frame would clip the (unchanged, still full-size) image down to whatever's left, which visually reads as the picture itself shrinking even though its actual rendered size never changes.

Built by reusing the existing offset/frame-clamp geometry rather than duplicating it: exporting/getCropRegion() substitute offset = -framePosition (a moved frame and an oppositely-moved image describe identical relative geometry) and a zoom value that cancels the cover-scale calculation's dependency on the frame's current size back out to the scale frozen at load — otherwise resizing the frame after load would silently rescale the crop math along with it. The live-preview render needs the identical frozen-scale substitution independently, and rotate() needs it a third time (carrying the frozen scale forward across rotations, rather than re-deriving it from whatever the frame's current size happens to be) — three separate call sites that each have to apply the same fix rather than one shared helper, since each reaches the underlying geometry functions through a different path.

Zoom clamping

Zoom is always clamped so the image can never be smaller than the frame — there's no option to disable this. The clamp math lives entirely in pure functions (no DOM), which is what makes it fast to reason about and to test.

Keyboard accessibility

The stage is a focusable, labeled element (tabindex="0", role="application", aria-label) so a keyboard-only user isn't limited to pointer-drag/pinch: arrow keys pan, +/ - zoom, and 0 resets — all going through the same setOffset()/setZoom()/reset() methods code would call, so they respect the same clamping.

Filters: the browser does the pixel math

Both the live preview and the canvas export apply the identical CSS filter string, so they're guaranteed to match — see Filters. sharpness has no native CSS filter to reuse (there's no sharpen() function), so each Kiri instance builds a small SVG feConvolveMatrix filter (a 3×3 unsharp-mask kernel, edgeMode="duplicate" to avoid dark edge fringing, preserveAlpha="true" so sharpening never touches opacity, color-interpolation-filters="sRGB" so it doesn't visibly shift brightness relative to the sRGB-space CSS filters in the chain) and references it with a url(#id) — placed first in the filter string, before brightness/contrast/saturate. That's not a style choice: a url(#id) reference placed after native CSS filter functions in the same chain renders fully blank in Chromium, confirmed directly in a real browser. Still one filter string driving both preview and export, just with one function that isn't a native CSS keyword. The kernel's center/neighbor weights are recomputed from sharpness on every setFilters() call and written onto the existing SVG element (its attribute, not a rebuilt filter), so adjusting the slider stays cheap.

Styling

Kiri ships a real, separate stylesheet (kiri.css/kiri.min.css) rather than injecting a <style> tag at runtime — standard, CSP-safe, and easy to override or theme.

Builds

kiri.mjs (ESM, for bundlers), kiri.js/ kiri.min.js (UMD/CJS, for <script> tags or require()). The package has no top-level "type": "module", so a bare .js file defaults to CommonJS (matching the UMD build's actual content) while .mjs is always ESM — this avoids a dual-package hazard a plain .js UMD file would otherwise hit.

Next: Examples — see it all in a browser.