npm version license CI provenance

A non-destructive image editor
that drops into React

Crop, rotate, colour-grade, redact with mosaic, and annotate screenshots with numbered step markers — then export PNG, JPEG or WebP. Zero runtime dependencies, TypeScript-first, ESM + CJS, keyboard accessible and fully translatable.

install
npm install react-image-studio
use
import { ImageStudio } from 'react-image-studio';
import 'react-image-studio/style.css';

export default function App() {
  return (
    <div style={{ height: '80vh' }}>
      <ImageStudio src="/photo.jpg" />
    </div>
  );
}

Live demo

Everything below runs the real component from docs/lib/image-studio.js — the same build that ships to npm. Draw a mosaic over the sun, then drop 1 · 2 · 3 markers to caption the steps.

Ready — try crop, filters, mosaic and markers.
Loading the editor…

What is inside

Eleven tools, one component, no runtime dependencies.

Crop

Eight drag handles, rule-of-thirds guides, drag-to-create inside the frame and aspect presets (1:1, 4:3, 3:2, 16:9, 3:4, 9:16). Crops stay pixel-aligned, so nothing is resampled.

Rotate & flip

Quarter turns and mirroring. Brush strokes, mosaic strokes, text and markers are remapped into the new coordinate space instead of drifting.

Adjust

Brightness, contrast, saturation, warmth, hue, blur, grayscale, sepia, invert and opacity. Sliders are real <input type="range"> elements — double-click a value to reset it.

Filters

Twelve presets (Vivid, Warm, Cool, Mono, Noir, Sepia, Vintage, Dramatic, Fade, Sunset, Matrix…) rendered as live thumbnails of your image.

Brush

Freehand painting with colour, size and opacity — plus an eraser that only removes paint, never the photo underneath.

new in 1.1

Mosaic redaction

A dedicated mosaic tool: paint areas you need to hide. Choose pixelate (with block size) or blur (with strength), then adjust the brush diameter. Strokes are stored, not baked.

new in 1.1

Step markers

Click to drop numbered badges 1 · 2 · 3 … for step-by-step screenshots. Circle, square or ring shapes, custom colour and size, drag to reposition, auto-renumbering when you delete one.

Text layers

Multiple text items with font family, size, weight, italic, colour, alignment, rotation and opacity. Drag on the canvas, double-click to edit.

Undo that respects intent

Sixty steps of history where a whole slider drag, a text edit session or a stroke is one undo step — not one per pointer event.

Zoom & pan

Wheel and pinch zoom anchored at the pointer, drag to pan, fit-to-screen, actual size, and clamped bounds so the image cannot be lost off-screen.

Export

PNG with transparency, JPEG flattened onto white (no black backgrounds), WebP with a quality slider, custom filenames, plus copy-to-clipboard.

Theming, i18n, a11y

Light / dark / auto themes through CSS custom properties, English and zh-CN strings with full overrides, ARIA roles, visible focus rings and prefers-reduced-motion support.

Usage

Four ways in, from “just render it” to “drive it yourself”.

Basic

import { ImageStudio } from 'react-image-studio';
import 'react-image-studio/style.css';

<ImageStudio src={fileOrUrl} />

No global CSS import? Use the auto-injecting entry instead: import 'react-image-studio/auto-style';

Configured

<ImageStudio
  src={file}
  theme="dark"                    // 'light' | 'dark' | 'auto'
  labels="zh-CN"                  // or Partial<Labels>
  tools={['crop', 'mosaic', 'marker', 'export']}
  defaultTool="marker"
  defaultFormat="webp"
  onSave={(result) => upload(result.blob)}
  onDocChange={(doc) => setDraft(doc)}
  onError={(error) => toast.error(error.message)}
/>

Imperative handle

const editor = useRef<ImageStudioHandle>(null);
<ImageStudio ref={editor} />

await editor.current.load(newFile);
const blob = await editor.current.export('png');
editor.current.rotate(1);
editor.current.crop({ x: 10, y: 10, width: 800, height: 600 });
const snapshot = editor.current.getDoc();

Custom chrome

import { useImageStudio, buildFilterString } from 'react-image-studio';

const api = useImageStudio({ src });
api.setTool('marker');
api.addMarker({ x: 200, y: 140 });      // numbered automatically
api.setMosaic({ style: 'blur', blur: 14 });
api.renumberMarkers();
await api.download('webp', 0.85);

The hook exposes the exact same state the built-in UI uses.

API

Props, the imperative handle, and the hook faces added in 1.1.

Props

PropTypeDefaultDescription
srcstring | Blob | File | null–Image to load (URL, data URL, blob or file).
imageCanvasImageSource–Alternative entry point for an already decoded image.
docEditorDoc–Restore a previously saved document.
onDocChange(doc) => void–Fired on every committed change.
onSave(result) => void–Fired after a successful export.
onError(error) => void–Load / export failures.
toolsToolId[]allmove · crop · rotate · adjust · filter · brush · mosaic · text · marker · export
defaultToolToolId'move'Tool selected on mount.
theme'light' | 'dark' | 'auto''auto'Chrome colour scheme.
labels'zh-CN' | Partial<Labels>EnglishUI strings.
className / style––Applied to the root element.
defaultFormat'png' | 'jpeg' | 'webp''png'Initial export format.
maxSourceSizenumber0Longest edge used when decoding (0 = keep original).
allowDropbooleantrueEnable drag & drop loading.
disableShortcutsbooleanfalseTurn off keyboard shortcuts.
emptyStateReactNode–Custom empty state.

Ref handle

load · export · download · getDoc · setDoc · undo · redo · reset · rotate · flip · crop · setTool · fit · setZoom · canUndo · canRedo

Hook faces added in 1.1

FaceSignatureDescription
mosaicMosaicSettings{ size, style: 'pixelate' | 'blur', block, blur }
setMosaic(patch: Partial<MosaicSettings>) => voidUpdate brush diameter, style, block size or blur strength.
clearMosaic() => voidRemove every mosaic stroke (brush paint is kept).
markerMarkerSettings{ color, size, shape: 'circle' | 'square' | 'ring' }
setMarker(patch: Partial<MarkerSettings>) => voidDefaults for the next marker.
addMarker(patch?: Partial<Marker>) => Marker | nullDrop a marker; the number is assigned for you.
updateMarker(id, patch, merge?) => voidMove or restyle one marker.
removeMarker(id: string) => voidDelete a marker.
renumberMarkers() => voidRe-sequence the badges as 1..n.
clearMarkers() => voidRemove all markers.
selectedMarkerId / selectMarkerstring | null / (id) => voidSelection state for custom panels.

Theming

Every colour is a CSS custom property, so the whole chrome can be re-skinned from the outside. .ris-theme-dark flips the palette; prefers-color-scheme drives the auto mode.

.my-brand .ris-root {
  --ris-accent: #e11d48;
  --ris-radius: 16px;
  --ris-font: 'Inter', sans-serif;
}

Tokens: --ris-bg --ris-surface --ris-surface-2 --ris-surface-3 --ris-border --ris-text --ris-muted --ris-accent --ris-accent-hover --ris-accent-contrast --ris-danger --ris-radius --ris-shadow --ris-font

Localisation

Pass 'zh-CN' for the bundled translation, or override any string:

<ImageStudio labels={{
  app: { ...EN_LABELS.app, download: 'Export image' },
  presets: { ...EN_LABELS.presets, vivid: 'Punchy' },
}} />

Mosaic and marker tool labels are part of the same Labels object.

How it stays fast

The preview layer is rendered once per structural change; colour adjustments are applied as a CSS filter while blitting, so dragging a slider never re-renders the bitmap. Export always re-renders at full resolution, which is why every tool is non-destructive.

src/
  core/            framework-free engine (no React imports)
    geometry.ts      crop maths, hit testing, view transform
    filters.ts       adjustments, CSS filter string, presets
    render.ts        layer compositing (base → mosaic → paint → text → markers)
    transform.ts     rotate / flip / crop pixel ops
    history.ts       undo stack
    image.ts         loading, encoding, download helpers
  hooks/           useImageStudio — all editor state
  components/      canvas stage, toolbar, panels
  ImageStudio.tsx

Browser support: Chrome/Edge 88+, Firefox 90+, Safari 15.4+. Canvas ctx.filter is used for adjustments, with a per-pixel fallback where it is missing. Remote images load with crossOrigin="anonymous" so exports stay untainted.