<bmx-image-editor>
A picture editor for the page: crop to a shape, rotate, flip and straighten; adjust light and colour; apply a look; draw arrows, boxes, text, highlights and numbered steps; redact anything private for good; and save at a size and format, as a file, a blob or straight into a form.
23 properties · 7 events · 18 methods · 5 parts
Example
Try it with your own files in confidence: they are opened by your browser, on your device. Nothing is uploaded, no copy reaches us or anyone else, and nothing is kept once you close this tab.
Open one of your own phone photos (drop it on the editor, paste it, or press Choose a picture after Clear) to see the location and camera details it carries, and that the saved copy has none.
Edit each picture before it is uploaded
Show markup
<bmx-image-editor
id="ex-image"
lang="en-GB"
label="Photo editor"
aspect-ratios="free,original,1:1,4:5,3:2,16:9"
actions="reset,download"
type="image/jpeg"
style="--bmx-image-editor-height: 40rem"
></bmx-image-editor>
<p class="note" id="ex-image-out">Open one of your own phone photos (drop it on the editor, paste it, or press Choose a picture after Clear) to see the location and camera details it carries, and that the saved copy has none.</p>
<div class="row" style="gap: 0.5rem; flex-wrap: wrap">
<bmx-button variant="outline" tone="neutral" id="ex-image-clear">Clear</bmx-button>
<bmx-button variant="outline" tone="neutral" id="ex-image-json">Copy the edits as JSON</bmx-button>
</div>
<h3>Edit each picture before it is uploaded</h3>
<bmx-upload
id="ex-image-upload"
label="Profile picture"
accept="image/*"
max-size="2097152"
auto-upload="false"
description="Choose a photo: crop it round, adjust it, then press Done. It is resized to 512 pixels, cleaned of its metadata, and kept under 2 MB."
></bmx-upload>
<bmx-dialog id="ex-image-dialog" heading="Crop your picture" size="lg" dismissible="false">
<bmx-image-editor
id="ex-image-avatar"
tools="crop,adjust,filter"
aspect="1:1"
lock-aspect
crop-shape="circle"
max-width="512"
max-height="512"
type="image/png"
actions="cancel,apply"
style="--bmx-image-editor-height: 30rem"
></bmx-image-editor>
</bmx-dialog>
<script type="module">
await Promise.all(['bmx-image-editor', 'bmx-upload', 'bmx-dialog'].map(tag => customElements.whenDefined(tag)));
const editor = document.getElementById('ex-image');
const out = document.getElementById('ex-image-out');
// A stand-in photo drawn on the page, with a parcel label to redact.
const sample = () => {
const c = document.createElement('canvas');
c.width = 1600;
c.height = 1067;
const g = c.getContext('2d');
const sky = g.createLinearGradient(0, 0, 0, 640);
sky.addColorStop(0, '#5b8fc7');
sky.addColorStop(1, '#f2c48d');
g.fillStyle = sky;
g.fillRect(0, 0, 1600, 1067);
g.fillStyle = '#fff3c4';
g.beginPath();
g.arc(1180, 330, 70, 0, Math.PI * 2);
g.fill();
const hill = (color, base, peaks) => {
g.fillStyle = color;
g.beginPath();
g.moveTo(0, 1067);
peaks.forEach(([x, y]) => g.lineTo(x, y));
g.lineTo(1600, base);
g.lineTo(1600, 1067);
g.fill();
};
hill('#7d6a8f', 560, [[0, 520], [260, 330], [480, 470], [760, 260], [1040, 480], [1300, 380], [1600, 520]]);
hill('#4c5d6e', 640, [[0, 640], [300, 520], [620, 620], [900, 500], [1240, 640], [1600, 560]]);
const lake = g.createLinearGradient(0, 660, 0, 1067);
lake.addColorStop(0, '#6f97b8');
lake.addColorStop(1, '#2d4a63');
g.fillStyle = lake;
g.fillRect(0, 680, 1600, 387);
g.fillStyle = '#2f3b2a';
g.fillRect(0, 900, 1600, 167);
g.save();
g.translate(980, 780);
g.rotate(-0.06);
g.fillStyle = '#f7f3ea';
g.fillRect(0, 0, 440, 230);
g.fillStyle = '#1f2933';
g.font = '600 30px system-ui, sans-serif';
g.fillText('DELIVER TO', 28, 52);
g.font = '28px system-ui, sans-serif';
['Ann Lee', '14 Harbour Road', 'Portree IV51 9EX'].forEach((line, i) => g.fillText(line, 28, 100 + i * 40));
g.restore();
return new Promise(resolve => c.toBlob(resolve, 'image/jpeg', 0.92));
};
await editor.loadImage(await sample(), 'lakeside.jpg');
editor.addEventListener('bmxImageLoad', e => {
const m = e.detail.metadata;
out.textContent = m?.identifying
? `Opened ${e.detail.name}: ${[m.location && 'a location', m.camera && `the camera (${m.camera})`, m.serialNumber && 'a serial number', m.taken && 'the time it was taken'].filter(Boolean).join(', ')}. None of it is saved.`
: `Opened ${e.detail.name}, ${e.detail.width} × ${e.detail.height}. It carries nothing private.`;
});
editor.addEventListener('bmxImageExport', e => (out.textContent = `Saved ${e.detail.file.name}, ${e.detail.width} × ${e.detail.height}, ${Math.round(e.detail.file.size / 1024)} KB.`));
document.getElementById('ex-image-clear').addEventListener('click', () => editor.clear());
document.getElementById('ex-image-json').addEventListener('click', async () => {
const json = JSON.stringify(await editor.getEdits());
await navigator.clipboard?.writeText(json).catch(() => undefined);
out.textContent = `Edits: ${json.length > 160 ? `${json.slice(0, 160)}…` : json}`;
});
// The upload hands each file to the editor and waits for Done or Cancel.
const dialog = document.getElementById('ex-image-dialog');
const avatar = document.getElementById('ex-image-avatar');
document.getElementById('ex-image-upload').prepareFile = async file => {
if (!file.type.startsWith('image/')) return file;
await dialog.openDialog();
const edited = await avatar.editFile(file);
await dialog.closeDialog();
return edited;
};
</script>
Everything happens in the browser. The picture is never sent anywhere, and what is saved carries no location, camera, owner or other details the original held - the editor reports what it found and removes it. A picture that was not changed is cleaned without being compressed again.
const file = await editor.editFile(fileFromUpload); // resolves on Done, or null on Cancel
The edits are plain JSON (getEdits, setEdits), so they can be stored
and opened again on the same picture later.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
actions |
actions |
readonly BmxImageAction[] | string |
'reset,download' |
The buttons in the footer: any of reset, cancel, apply, download. editFile adds cancel and apply while it waits. |
aspect |
aspect |
string |
— | The crop shape at the start ("1:1"). With lock-aspect, the only one. |
aspectRatios |
aspect-ratios |
readonly string[] | string |
'free,original,1:1,4:5,3:2,4:3,16:9' |
The crop shapes offered: "free", "original", or ratios such as "16:9". |
background |
background |
string |
'#ffffff' |
Painted behind transparent parts when saving as JPEG. |
cropShape |
crop-shape |
'rect' | 'circle' |
'rect' |
Show the crop as a circle, and save the picture round (transparent outside it in PNG and WebP). |
disabled |
disabled |
boolean |
false |
|
edits |
edits |
Partial<BmxImageEdits> | string |
— | Edits to apply to the picture when it opens (JSON in markup). Kept up to date as the picture is edited. |
fileName |
file-name |
string |
— | The saved file's name. Default: the original's, with the new format's extension. |
fontFamily |
font-family |
string |
'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' |
The font text marks are drawn in, as CSS font-family. |
label |
label |
string |
— | What the editor is, for screen readers. |
lockAspect |
lock-aspect |
boolean |
false |
Keep the crop to aspect and offer no other shape: an avatar, a banner. |
maxHeight |
max-height |
number |
— | The tallest the saved picture may be. |
maxPixels |
max-pixels |
number |
16_777_216 |
The most pixels the saved picture may have. Default 16.7 million, the most some phones can draw. |
maxWidth |
max-width |
number |
— | The widest the saved picture may be. |
name |
name |
string |
— | The form field name. The edited picture is submitted as a file. |
palette |
palette |
readonly string[] | string |
['#e03131', '#f08c00', '#ffd43b', '#2f9e44', '#1971c2', '#7048e8', '#111111', '#ffffff'] |
Colours offered for marks: a list, or comma-separated in markup. |
quality |
quality |
number |
0.9 |
Quality for JPEG and WebP, from 0 to 1. |
required |
required |
boolean |
false |
A picture must be opened before the form can be sent. |
src |
src |
string |
— | The picture to open: a URL (a picture from another site must allow CORS), a data URL or an object URL. Or call loadImage with a file. |
strings |
strings |
Partial<BmxImageEditorStrings> | string |
— | Words the editor shows or says: any of BmxImageEditorStrings. JSON in markup. |
tool |
tool |
BmxImageTool |
— | The tool open at the start. |
tools |
tools |
readonly BmxImageTool[] | string |
['crop', 'adjust', 'filter', 'annotate', 'redact', 'resize'] |
The tools offered, in order: any of crop, adjust, filter, annotate, redact, resize. A list, or comma-separated in markup. |
type |
type |
string |
— | The format saved: image/jpeg, image/png or image/webp. Default: the original's, or PNG. |
Events
| Event | Detail | Description |
|---|---|---|
bmxImageApply |
BmxImageFileDetail |
The reader pressed Done: the finished file. |
bmxImageCancel |
void |
The reader pressed Cancel. |
bmxImageEdit |
BmxImageEditDetail |
The edits changed. |
bmxImageError |
BmxImageErrorDetail |
A picture could not be opened or saved. |
bmxImageExport |
BmxImageFileDetail |
The picture was saved: downloaded, or made into a file or blob. |
bmxImageLoad |
BmxImageLoadDetail |
A picture opened. metadata says what it carried besides pixels. |
bmxImageToolChange |
BmxImageToolDetail |
The reader chose another tool. |
Methods
| Method | Signature | Description |
|---|---|---|
cleanFile |
cleanFile(file: Blob, name?: string) => Promise<File> |
Takes the location, camera and other details out of any JPEG, PNG or WebP file, without changing a pixel or compressing it again. Needs no picture open. |
clear |
clear() => Promise<void> |
Closes the picture. |
download |
download(name?: string, options?: BmxImageExportOptions) => Promise<void> |
Saves the edited picture to the reader's downloads. |
editFile |
editFile(file: Blob, name?: string) => Promise<File | null> |
Opens a file to edit and waits: resolves with the edited file when the reader presses Done, or null when they press Cancel. For an upload field: upload.prepareFile = file => editor.editFile(file). |
flip |
flip(axis?: "horizontal" | "vertical") => Promise<void> |
Mirrors the picture. |
getEdits |
getEdits() => Promise<BmxImageEdits> |
The edits, as JSON-ready data. |
getMetadata |
getMetadata() => Promise<BmxImageMetadata | null> |
What the open picture carried besides pixels (location, camera, owner), or null. |
loadImage |
loadImage(source: BmxImageSource, name?: string) => Promise<void> |
Opens a picture: a file, a blob, a URL, an <img>, a canvas, an ImageBitmap or ImageData. Resolves when it is shown. |
redo |
redo() => Promise<void> |
|
reset |
reset() => Promise<void> |
Back to the picture as it opened. Undo can step back from it. |
rotate |
rotate(direction?: "left" | "right") => Promise<void> |
Turns the picture a quarter. |
setEdits |
setEdits(edits: Partial<BmxImageEdits> | string) => Promise<void> |
Replaces the edits, from data or JSON. Undo can step back from it. |
setFocus |
setFocus(options?: FocusOptions) => Promise<void> |
|
setTool |
setTool(tool: BmxImageTool) => Promise<void> |
Opens a tool. |
toBlob |
toBlob(options?: BmxImageExportOptions) => Promise<Blob> |
The edited picture as a Blob. |
toDataURL |
toDataURL(options?: BmxImageExportOptions) => Promise<string> |
The edited picture as a data URL. |
toFile |
toFile(name?: string, options?: BmxImageExportOptions) => Promise<File> |
The edited picture as a File. |
undo |
undo() => Promise<void> |
CSS shadow parts
| Part | Description |
|---|---|
empty |
what shows before a picture is opened. |
footer |
the size, the privacy note and the actions. |
panel |
the current tool's settings. |
stage |
the area the picture is shown in. |
toolbar |
the row of tools and commands. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-image-editor-crop |
The crop's edge and handles. |
--bmx-image-editor-height |
How tall the editor is. Default 36rem. |
--bmx-image-editor-selection |
The outline and handles of the selected mark. |
--bmx-image-editor-shade |
Over the part the crop leaves out. |
--bmx-image-editor-stage |
Behind the picture. |