<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
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. |