A vanilla, framework-free, reactive image editor: crop, resize, rotate, flip, colour filters, finetune (brightness/contrast/saturation/blur/warmth) and text annotations. Inspired by the Filerobot Image Editor UI, built from scratch with no React, no runtime dependencies.
🌐 Live demo & docs: https://xdsoft.net/jodit/image-editor/
The whole editor is view = f(state): a pure reducer produces immutable
state, a pure view turns state into a virtual DOM, and a tiny reconciler patches
the real DOM. Image processing is a separate pure pipeline over a DOM-free
pixel buffer, so every operation, filter and rule is unit-tested in Node.
Blob ──decode──▶ RasterImage ──pipeline(design)──▶ RasterImage ──encode──▶ Blob
update(patch) ─▶ reducer ─▶ Store ─scheduler(batch)─▶ render(state) ─▶ VNode ─diff─▶ DOM
- ✂️ Crop (interactive handles), Resize (with aspect lock), Rotate, Flip X/Y
- 🎨 Filters: Original, Invert, B&W, Sepia, Solarize, Clarendon, Gingham (+ register your own)
- 🎚️ Finetune: brightness, contrast, saturation, blur, warmth
- 🔤 Annotate / Watermark: text labels
- ↩️ Undo / redo modelled as state, navigated via
update(not methods) - 🧩 Plugin API for tools and filters
- 📱 Mobile-first responsive UI, light & dark themes
- 🧪 140+ unit tests, SOLID, logic/render/event-loop cleanly separated
- 📦 Ships ESM + an ES2021 build + types; CSS is injected from JS (no
.cssfile)
npm install @jodit/image-editorOr use it straight from a CDN — no build step (CSS is injected from JS):
<div id="editor" style="height: 600px"></div>
<script type="module">
import { ImageEditor } from 'https://cdn.jsdelivr.net/npm/@jodit/image-editor/+esm';
const editor = new ImageEditor({ container: '#editor' });
document
.querySelector('input[type=file]')
.addEventListener('change', (e) => editor.fromBlob(e.target.files[0]));
</script>New here? See docs/getting-started.md for a copy-paste demo, CDN/jsDelivr options and the full API at a glance.
import { ImageEditor } from '@jodit/image-editor';
const editor = new ImageEditor({
container: '#editor', // element or selector
onSave: (blob) => console.log('exported', blob),
});
// Input is a blob, output is a blob.
const blob = await fetch('/photo.jpg').then((r) => r.blob());
await editor.fromBlob(blob);
// …user edits…
const result = await editor.toBlob({ type: 'image/png' });| Member | Description |
|---|---|
new ImageEditor(props) / init(props) |
Create + mount the editor. |
editor.state |
Current immutable EditorState snapshot. |
editor.update(patch) |
The one universal mutation. Returns this. |
editor.fromBlob(blob) |
Decode an image blob into the editor. |
editor.toBlob(opts?) |
Render the current design at full resolution → Blob. |
editor.save() |
Export + invoke onSave (fires jie:save). |
editor.saveAs() |
Export + invoke onSaveAs (fires jie:saveas). |
editor.reset() |
Reset every edit, behind the confirm gate. |
editor.use(plugin) |
Apply an extension. |
editor.destroy() |
Tear down listeners, observers and the DOM tree. |
The top bar (Save / size / zoom / undo-redo) is just state.showToolbar. Hide it
and drive everything from your own UI — the host app subscribes to the store and
calls the same public API:
const editor = new ImageEditor({
container: '#editor',
state: { showToolbar: false }, // no built-in top bar
onSave: (blob) => overwrite(blob),
onSaveAs: (blob) => saveUnderNewName(blob),
});
// Your own buttons drive it:
saveButton.onclick = () => editor.save();
saveAsButton.onclick = () => editor.saveAs();
undoButton.onclick = () => editor.update({ history: { step: -1 } });
// Keep your button states in sync with the editor:
import { selectors } from '@jodit/image-editor';
editor.store.subscribe((state) => {
undoButton.disabled = !selectors.selectCanUndo(state);
saveButton.disabled = !state.source;
});showToolbar is also a normal patch: editor.update({ showToolbar: false }).
History lives inside state as { entries, index }. You navigate it with the
same update you use for everything else — so any screen/state is reachable by
calling update:
editor.update({ history: { step: -1 } }); // undo
editor.update({ history: { step: +1 } }); // redo
editor.update({ history: { index: 0 } }); // jump to the start
editor.update({ activeTab: 'filters' }); // open any tab
editor.update({ design: { rotate: 90 } }); // an edit (pushes history)
editor.update({ resetDesign: true }); // back to the originalprops accepts initial state too: new ImageEditor({ container, state: { theme: 'dark' } }).
import type { EditorPlugin } from '@jodit/image-editor';
const stickerPlugin: EditorPlugin = {
name: 'sticker',
setup(api) {
api.registerFilter({ id: 'duotone', label: 'Duotone', apply: (raster) => /* … */ raster });
api.registerTool({ id: 'sticker', label: 'Sticker', icon: '<svg…>', renderPanel: () => null });
},
};
new ImageEditor({ container: '#editor', plugins: [stickerPlugin] });The core bundle ships English only (gettext-style: the English string is the
key). Five locales ship as separate, opt-in modules — ru, es, fr, de,
zh — and switching language is just an update:
import { ImageEditor } from '@jodit/image-editor';
import ru from '@jodit/image-editor/locales/ru';
const editor = new ImageEditor({ container: '#editor', locales: [ru], locale: 'ru' });
editor.update({ locale: 'en' }); // back to built-in EnglishFull guide: docs/06-i18n.md.
A guided, four-part deep-dive lives in docs/:
state & reducer ·
store & scheduler ·
virtual DOM ·
pixel core & pipeline.
Each module also has its own README:
| Area | Module | Responsibility |
|---|---|---|
| State | core/state |
Types, reducer, history, selectors |
| Reactivity | core/store · core/scheduler |
Reactive store + event-loop batching |
| Pixels | core/raster · core/operations · core/filters |
DOM-free pixel buffer, transforms, filters |
| Geometry | core/geometry |
Crop/resize/fit math |
| Pipeline | core/pipeline |
Pure design → pixels |
| Annotations | core/annotations |
Annotation list operations |
| Render | render/vdom |
h(), diff/patch, host abstraction |
| UI | ui |
Design system, icons, components, view |
| Plugins | plugins |
Tool registry + extension API |
| Image I/O | image |
Blob ⇄ raster, annotation compositing |
| Facade | editor |
Wires it all into ImageEditor |
npm start # Vite dev server for the demo stand
npm test # Vitest (run once)
npm run build # ESM + ES2021 + .d.ts into dist/
npm run lint # ESLint + Prettier check
# Bundle-size monitoring (Statoscope)
npm run analyze # build + open the interactive Statoscope report
npm run report # build + write a shareable statoscope/report.html
npm run stats # build + emit statoscope/stats.json only
npm run size # build + fail if shipped JS exceeds the budget (SIZE_BUDGET_KB, default 90)Bundle size is tracked with Statoscope. Since the
build runs on Vite/Rollup, rollup-plugin-webpack-stats
emits a webpack-compatible statoscope/stats.json (only in --mode analyze, so
normal builds and published artifacts stay clean), which the Statoscope CLI turns
into a report:
npm run analyze— serves the interactive report (modules, treemap, sizes).npm run report— writes a staticstatoscope/report.htmlfor CI artifacts.npm run size— a hard budget gate over the stats (scripts/check-size.mjs), handy in CI. Override withSIZE_BUDGET_KB=120 npm run size.
The statoscope/ folder is git-ignored.
Each entry is an ESM file with CSS injected as a <style> element (no separate
.css). Readable builds ship for debuggable consumption and bundler
tree-shaking; the .min builds are ready for direct <script type="module"> /
CDN use (unpkg/jsdelivr point at the ES2021 min build).
| File | Target | Minified |
|---|---|---|
dist/jodit-image-editor.js |
esnext |
no |
dist/jodit-image-editor.min.js |
esnext |
yes |
dist/jodit-image-editor.es2021.js |
ES2021 | no |
dist/jodit-image-editor.es2021.min.js |
ES2021 | yes |
dist/index.d.ts |
— | bundled type declarations |
"Green" / evergreen browsers — the last two versions of Chrome, Firefox, Safari
and Edge (see .browserslistrc).
MIT © Valeriy Chupurnov and contributors.