Architecture
The core concepts behind how Kiri is built.
Stage, frame, image layer
- Stage — the outer bounding box the user sees. By
default (
autoSizeStage: true) it sizes itself to the frame's dimensions plus a small margin, so it looks right with zero CSS.autoSizeStage: falsereverts to filling its container via CSS (100% width/height) instead. - Frame — the fixed selection window inside the
stage; the region that gets exported.
"rectangle","circle", or"rounded-rectangle". - Image layer — the source image, freely draggable and zoomable behind the frame.
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.