Without React
Edit and export photos from plain JavaScript with createPhoto().
createPhoto() from photocn/photo loads an image and returns a Photo: a stateful object you edit with method calls. It doesn't depend on React, so you can use it in Vue, Svelte, web components, plain scripts, or a React app that only needs to process images.
"use client";
import { useEffect, useRef } from "react";
import { createPhoto } from "photocn/photo";
// Plain DOM: no React state, no components. Works the same in Vue, Svelte,
// a web component or a <script type="module">.
async function mountPhotoEditor(root: HTMLElement, signal: AbortSignal) {
root.innerHTML = `
<canvas class="aspect-[3/2] w-full rounded-lg bg-muted object-contain"></canvas>
<div class="flex flex-wrap items-center gap-3 text-sm">
<label class="flex items-center gap-2">Exposure <input name="exposure" type="range" min="-1" max="1" step="0.01" value="0" /></label>
<label class="flex items-center gap-2">Saturation <input name="saturation" type="range" min="-1" max="1" step="0.01" value="0" /></label>
<button data-filter="juno" class="rounded-md border px-2.5 py-1">Juno</button>
<button data-filter="moon" class="rounded-md border px-2.5 py-1">Moon</button>
<button data-action="rotate" class="rounded-md border px-2.5 py-1">Rotate</button>
<button data-action="undo" class="rounded-md border px-2.5 py-1">Undo</button>
<button data-action="export" class="rounded-md border bg-primary px-2.5 py-1 text-primary-foreground">Export</button>
<output class="text-muted-foreground"></output>
</div>`;
const canvas = root.querySelector("canvas")!;
const inputs = root.querySelectorAll<HTMLInputElement>("input[type=range]");
const output = root.querySelector("output")!;
const photo = await createPhoto("/samples/street.jpg");
if (signal.aborted) return photo.dispose();
signal.addEventListener("abort", () => photo.dispose());
photo.attach(canvas);
inputs.forEach((input) =>
input.addEventListener("input", () => photo.adjust({ [input.name]: Number(input.value) }), { signal }),
);
root.addEventListener("click", async (event) => {
const button = (event.target as HTMLElement).closest("button");
if (!button) return;
if (button.dataset.filter) photo.filter(button.dataset.filter);
if (button.dataset.action === "rotate") photo.rotate(1);
if (button.dataset.action === "undo") photo.undo();
if (button.dataset.action === "export") {
const { blob, width, height } = await photo.export({ format: "jpeg", width: 1200 });
output.textContent = `${width}×${height}, ${Math.round(blob.size / 1024)} KB`;
}
}, { signal });
// Keep the controls in sync after undo.
photo.subscribe(() => {
inputs[0]!.value = String(photo.params.lights.exposure);
inputs[1]!.value = String(photo.params.colors.saturation);
});
}
// Only this wrapper is React, to show the demo on this page.
export default function VanillaExample() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const controller = new AbortController();
void mountPhotoEditor(ref.current!, controller.signal);
return () => controller.abort();
}, []);
return <div className="flex flex-col gap-3" ref={ref} />;
}Edit and export
Every edit returns the photo, so calls chain. Nothing is baked in until you export.
import { createPhoto } from "photocn/photo";
const photo = await createPhoto(file); // File, Blob, URL, ArrayBuffer or <img>
photo
.adjust({ exposure: 0.3, contrast: 0.2, saturation: -1 })
.filter("juno", 0.8)
.straighten(4)
.aspectRatio("1:1");
const { blob, width, height, filename } = await photo.export({ format: "jpeg", width: 1080 });
photo.dispose();
To process many photos the same way, export one edit as a recipe and apply it to the others:
const look = photo.recipe;
for (const file of files) {
const next = await createPhoto(file);
await next.applyRecipe(look!);
upload(await next.export({ format: "webp" }));
next.dispose();
}
Live preview
attach(canvas) renders every change into a <canvas>, in a Web Worker when the browser supports it. subscribe() tells you when to update your own controls.
const detach = photo.attach(document.querySelector("canvas")!);
const unsubscribe = photo.subscribe((photo) => {
exposureInput.value = String(photo.params.lights.exposure);
});
A canvas handed to the worker can't be reused, so give each attach() a fresh <canvas>.
API
| Member | Description |
|---|---|
adjust(values) | Light, color and effect sliders, each -1..1: brightness, exposure, gamma, contrast, shadows, highlights, bloom, temperature, tint, vibrance, saturation, sepia, clarity, noise, vignette. |
filter(name, strength?) | Apply a look ("juno", "moon"…) at 0..1 strength; null removes it. |
filterStrength(strength) | Change the strength of the current look. |
curves(channels) | Tone curves [rgb, r, g, b], each a list of [input, output] points or null. |
blur({ bokeh, gaussian, focus, centerX, centerY }) | Lens or gaussian blur around a sharp center. |
rotate(1 | -1), flip(axis) | Quarter turns and mirroring. |
straighten(degrees) | ±45°. The crop shrinks to stay inside the photo. |
perspective({ x, y }) | Keystone correction, -1..1. |
crop(rect), aspectRatio(ratio) | Normalized crop rect, or a ratio (16 / 9, "4:3", "original", null). |
undo(), redo(), reset() | One step per call. |
params, setParams(params) | The full edit as plain data (save it, restore it later). |
recipe, applyRecipe(recipe) | A portable copy of the edit. |
export(options) | { format, quality, width, height, preserveExif } → { blob, type, width, height, filename }. |
histogram() | RGB histogram of the result. |
attach(canvas), detach() | Live preview. |
subscribe(listener) | Called after every change; returns an unsubscribe function. |
ready() | Resolves once pending filters have loaded. export() waits for it. |
size, outputSize, metadata, name | Source size, size after crop, EXIF, filename. |
dispose() | Free the renderer and worker. |
In React, use useImageEditorState instead: it adds tools, transient slider edits, crop interactions and everything the components need.