<bmx-document-scanner>
Scans paper with a phone or laptop camera, or from photographs: receipts, invoices, contracts, forms, ID-free paperwork of every kind - for expense apps, onboarding, claims, field service and back offices.
12 properties · 4 events · 16 methods · 5 parts
Example
Try it in confidence: the camera picture, and any photo you add, is worked on by your browser, on your device. Nothing is uploaded or recorded anywhere, no copy reaches us or anyone else, and the camera stops when you leave this page.
Use your camera on a sheet of paper - the page is outlined as it is found and, held still, taken by itself - or add photographs of documents. Each page is straightened and cleaned up; drag its corners if they need it, choose how it looks, and save the pages as one PDF.
Show markup
<bmx-document-scanner id="ex-ds" label="Scan receipts and invoices" file-name="expenses" locale="en-GB"></bmx-document-scanner>
<p class="note" id="ex-ds-out" role="status">Use your camera on a sheet of paper - the page is outlined as it is found and, held still, taken by itself - or add photographs of documents. Each page is straightened and cleaned up; drag its corners if they need it, choose how it looks, and save the pages as one PDF.</p>
<div class="row" style="gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-block: 0.5rem 1.5rem">
<button type="button" id="ex-ds-sample">Add a sample photo of an invoice</button>
<button type="button" id="ex-ds-receipt">Add a sample photo of a receipt</button>
</div>
<script type="module">
await customElements.whenDefined('bmx-document-scanner');
const el = document.getElementById('ex-ds');
const out = document.getElementById('ex-ds-out');
/** A photograph of a page lying at an angle on a wooden desk, drawn for the example. */
function samplePhoto(kind) {
const c = document.createElement('canvas');
c.width = 1600;
c.height = 1200;
const g = c.getContext('2d');
// The desk: warm wood with grain.
const wood = g.createLinearGradient(0, 0, 1600, 1200);
wood.addColorStop(0, '#6b4a2f');
wood.addColorStop(1, '#4a3220');
g.fillStyle = wood;
g.fillRect(0, 0, 1600, 1200);
for (let i = 0; i < 90; i++) {
g.strokeStyle = `rgba(30, 18, 8, ${0.08 + (i % 5) * 0.03})`;
g.lineWidth = 1 + (i % 3);
g.beginPath();
const y = i * 14 + (i % 7) * 3;
g.moveTo(0, y);
g.bezierCurveTo(500, y + 25, 1100, y - 25, 1600, y + 10);
g.stroke();
}
// The page, turned a little, with a soft shadow under it.
const receipt = kind === 'receipt';
const pw = receipt ? 420 : 760;
const ph = receipt ? 900 : 1000;
g.save();
g.translate(800, 610);
g.rotate(receipt ? 0.12 : -0.07);
g.shadowColor = 'rgba(0, 0, 0, 0.45)';
g.shadowBlur = 30;
g.shadowOffsetX = 12;
g.shadowOffsetY = 16;
const paper = g.createLinearGradient(-pw / 2, 0, pw / 2, 0);
paper.addColorStop(0, '#fbfaf6');
paper.addColorStop(1, '#d9d4c7');
g.fillStyle = paper;
g.fillRect(-pw / 2, -ph / 2, pw, ph);
g.shadowColor = 'transparent';
g.fillStyle = '#23262b';
const left = -pw / 2 + 40;
let y = -ph / 2 + 70;
if (receipt) {
g.font = 'bold 30px monospace';
g.fillText('HARBOUR CAFE', left + 60, y);
g.font = '20px monospace';
y += 50;
for (const [item, price] of [['Flat white', '3.40'], ['Oat latte', '3.80'], ['Croissant', '2.90'], ['Granola pot', '4.25'], ['Sparkling water', '1.95'], ['Banana bread', '3.10']]) {
g.fillText(item, left, y);
g.fillText(price, left + 270, y);
y += 38;
}
y += 20;
g.font = 'bold 24px monospace';
g.fillText('TOTAL GBP 19.40', left, y);
g.font = '18px monospace';
g.fillText('Card ****4471 17/10/2026', left, y + 50);
g.fillText('Thank you!', left + 90, y + 110);
} else {
g.font = 'bold 44px sans-serif';
g.fillText('INVOICE', left, y);
g.font = '20px sans-serif';
g.fillText('Fernhill Consulting Ltd', left + 380, y - 20);
g.fillText('No. 2026-1043', left + 380, y + 10);
y += 80;
g.fillText('Bill to: Northwind Rail', left, y);
g.fillText('Date: 17 October 2026', left + 380, y);
y += 60;
g.fillStyle = '#d7dce4';
g.fillRect(left, y - 26, pw - 80, 36);
g.fillStyle = '#23262b';
g.font = 'bold 20px sans-serif';
g.fillText('Description', left + 10, y);
g.fillText('Amount', left + 540, y);
g.font = '20px sans-serif';
for (const [d, a] of [['Discovery workshops (6 days)', '£9,600'], ['Timetable data model', '£14,400'], ['Feed mapping', '£7,200'], ['Travel', '£640']]) {
y += 46;
g.fillText(d, left + 10, y);
g.fillText(a, left + 540, y);
}
y += 70;
g.font = 'bold 24px sans-serif';
g.fillText('Total due: £31,840', left + 330, y);
g.font = '18px sans-serif';
for (let i = 0; i < 6; i++) g.fillText('Payment within 30 days to account 12-34-56 01234567. Thank you for your business.'.slice(0, 60 + i), left, y + 90 + i * 30);
}
g.restore();
return c.toDataURL('image/jpeg', 0.9);
}
document.getElementById('ex-ds-sample').addEventListener('click', () => el.addImages([samplePhoto('invoice')]));
document.getElementById('ex-ds-receipt').addEventListener('click', () => el.addImages([samplePhoto('receipt')]));
el.addEventListener('bmxCapture', e => {
const { index, page } = e.detail;
out.textContent = `Page ${index + 1} added ${page.cornersFrom === 'detected' ? 'with its edges found' : '- drag its corners to the page'}.`;
});
el.addEventListener('bmxExport', e => {
out.textContent = `Saved ${e.detail.fileName}: ${e.detail.pages} pages, ${Math.round(e.detail.bytes.length / 1024)} KB.`;
});
el.addEventListener('bmxError', e => {
out.textContent = e.detail.message;
});
</script>
WHAT IT DOES
- The camera's view shows the page it finds, outlined, as you hold the phone over it; held still, the picture is taken by itself (or with the button, Space or Enter). A light for dark rooms and a switch between cameras where the device has them.
- Pictures can also be added from files, dropped, or pasted.
- Each page's edges are found, the page straightened as if photographed from straight above, and cleaned up: colour with the shadows and lamplight taken out, greyscale, crisp black and white for text, or as photographed. Corners can be dragged (or moved with the keys) where the detection was not right; pages rotate, reorder and delete.
- Saves every page as one PDF (A4, Letter, Legal or each picture's own
size), the pages as JPEGs in a .zip, or one page as a JPEG; or hands the
bytes to the page (
bmxExport) to upload where it likes.
Everything happens in the browser: the camera's pictures and the files added never leave the page unless the page sends them itself. The camera is only asked for when the user chooses to use it.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
autoCapture |
auto-capture |
boolean |
true |
Take the picture by itself once a page has been held still in the camera's view. |
dpi |
dpi |
number |
200 |
For page-size="fit": the resolution the PDF's sheets assume, in dots an inch. |
facing |
facing |
'environment' | 'user' |
'environment' |
Which camera to start with: environment (the back one) or user. |
fileName |
file-name |
string |
'scan' |
The name saved files get, without the extension. |
filter |
filter |
BmxScanFilter |
'color' |
How new pages look: color, grayscale, bw or original. |
label |
label |
string |
— | The element's accessible name. |
locale |
locale |
string |
— | The locale for numbers. Unset: the page's lang. |
maxPages |
max-pages |
number |
0 |
The most pages a scan may have. 0: no limit. |
pageSize |
page-size |
BmxScanPageSize |
'a4' |
The paper the PDF is laid out on: a4, letter, legal, or fit (each picture's own size). |
quality |
quality |
number |
0.85 |
JPEG quality of saved pages, 0.3 to 1. |
resolution |
resolution |
number |
2200 |
The longer side of a saved page in pixels (2200 is about 190 dots an inch on A4). |
strings |
strings |
Partial<BmxDocumentScannerStrings> | string |
— | Replacements for any of the wording (an object, or its JSON). |
Events
| Event | Detail | Description |
|---|---|---|
bmxCapture |
BmxDocumentScannerCaptureDetail |
Fired when a page is added from the camera or a file. |
bmxChange |
BmxDocumentScannerChangeDetail |
Fired when pages are added, removed, moved, cropped, rotated or given another look. |
bmxError |
BmxDocumentScannerErrorDetail |
Fired when the camera cannot be used or a file cannot be read. |
bmxExport |
BmxDocumentScannerExportDetail |
Fired with the bytes before a PDF, .zip or picture is downloaded. Cancelable. |
Methods
| Method | Signature | Description |
|---|---|---|
addImages |
addImages(images: (Blob | string)[] | FileList) => Promise<BmxDocumentScannerPage[]> |
Adds pictures (files, blobs or data URLs) as pages; each page's edges are found. Resolves with the pages added. |
capture |
capture() => Promise<BmxDocumentScannerPage | null> |
Takes a picture with the camera now. |
clear |
clear() => Promise<void> |
Removes every page. |
detect |
detect(page?: number | string) => Promise<BmxScanDetection | null> |
Finds a page's edges again; resolves with the detection (null when none was found, and the corners are left). |
getPageImage |
getPageImage(page?: number | string, type?: "image/jpeg" | "image/png") => Promise<Blob | null> |
A finished page as a picture (JPEG unless type says image/png), at resolution. |
getPages |
getPages() => Promise<BmxDocumentScannerPage[]> |
The pages (without their pictures). |
movePage |
movePage(from: number, to: number) => Promise<void> |
Moves a page to another place. |
removePage |
removePage(page: number | string) => Promise<void> |
Removes a page (by index or id). |
rotatePage |
rotatePage(page: number | string, degrees: number) => Promise<void> |
Rotates a page by a quarter turn (90 clockwise, -90 the other way) or to an angle. |
saveImages |
saveImages() => Promise<void> |
Saves every page as a JPEG, together in a .zip. |
savePdf |
savePdf() => Promise<void> |
Saves every page as one PDF: bmxExport first, then a download unless the event is cancelled. |
setCorners |
setCorners(page: number | string, corners: BmxScanQuad) => Promise<boolean> |
Sets a page's corners (fractions of the photograph, clockwise from the top left). |
setFilter |
setFilter(page: number | string, filter: BmxScanFilter) => Promise<void> |
Gives a page another look: color, grayscale, bw or original. |
startCamera |
startCamera() => Promise<boolean> |
Starts the camera (the browser asks the user first). False when it cannot be used. |
stopCamera |
stopCamera() => Promise<void> |
Stops the camera and goes back to the pages. |
toPdf |
toPdf() => Promise<Uint8Array> |
Every page as one PDF. |
CSS shadow parts
| Part | Description |
|---|---|
camera |
The camera's controls. |
pages |
The list of pages. |
stage |
The page shown large, the camera's view or the corner editor. |
status |
The page count. |
toolbar |
The scanning tools. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-document-scanner-height |
Height of the scanner. |
--bmx-document-scanner-outline |
Colour of the page's outline and corners. |
--bmx-document-scanner-pages-width |
Width of the list of pages. |
--bmx-document-scanner-radius |
Corner radius of the frame. |
--bmx-document-scanner-stage |
Background behind the page and the camera's view. |