Live demonstration of <web-dropzone> as a headless store with bound <web-dropzone-picker>, <web-dropzone-list>, and <web-dropzone-indicator> satellite renderers — see ARCHITECTURE.md.
Every renderer binds to exactly one store via for="<store-id>". The store dispatches CustomEvents with bubbles + composed, so satellites in their own shadow roots receive them without polling.
Nothing new — the existing <web-dropzone> with renderer attributes mounts its own internal picker + list. This is the one-element form that still works unchanged after stage 2 + 3 landed.
<web-dropzone selector-appearance="card" list-appearance="detailed" multiple></web-dropzone>
The picker sits in a form; the file list lives somewhere else on the page. Both bind to the same headless store. Drop files on the left — the list on the right syncs via the store's bubbling events.
Headless. No renderer attributes → renders nothing itself.
Card appearance, no list — pure file selector.
Detailed appearance, free-standing element.
Attachments:
<web-dropzone id="uploads"></web-dropzone> <form> <web-dropzone-picker for="uploads" selector-appearance="card"></web-dropzone-picker> </form> <aside> <web-dropzone-list for="uploads" list-appearance="detailed"></web-dropzone-list> </aside>
An avatar uploader and a documents uploader on the same page. Each is its own store: own queue, own list, own validation, own (potential) upload endpoint. Nothing flows between them.
One store, one global list, one global indicator — but each picker stamps its own uploadCallback + uploadMetadata onto every file it contributes. The store's upload loop picks file.uploadCallback ?? config.uploadFileCallback per file, so two pickers feeding the same queue can still route to different endpoints / buckets / tenants.
file-added / file-progress / file-status-changed dispatched by the store and consumed by every satellite.<web-dropzone id="uploads"></web-dropzone>
<web-dropzone-picker id="picker-public" for="uploads" label="Public"></web-dropzone-picker>
<web-dropzone-picker id="picker-private" for="uploads" label="Private"></web-dropzone-picker>
<web-dropzone-list for="uploads"></web-dropzone-list>
<script>
document.getElementById('picker-public').uploadCallback = (file, onProgress, signal, ctx) => uploadTo('/public', file, onProgress, signal);
document.getElementById('picker-private').uploadCallback = (file, onProgress, signal, ctx) => uploadTo('/private', file, onProgress, signal);
// Each file remembers which picker contributed it — the store stamps
// uploadCallback onto FileState at addFiles time. Mode A files inherit
// the store-level handler; Mode B files use their own. No store-side
// routing logic required.
</script>
The flagship UX: just a button in the form (button-appearance picker), plus a floating chip pinned to the viewport edge that summarizes the queue and opens a slide-out drawer with the full list. Drop files via the button to see the chip update in real time.
↘ A floating chip is pinned to the right edge of the page. Click it to slide out the queue.
The same <web-dropzone-indicator> covers a spectrum of UX when you mix position="inline", drawer="off", and a JS renderCallback. All four below bind to one store so a single drop fans out into every variant.
Inline placement, default drawer (click to expand). The chip's wrapper is stripped via ::part(chip) so the callback's output reads as inline body text — the line below is one continuous sentence.
Status:
[Uploading xyz.zip @ 12.6 MB/s]Inline + drawer="off". Callback reads currentUploadingFile and tracks per-tick deltas for the speed.
Now:
Inline + drawer="off", callback returns just a spinning glyph while anything is uploading and nothing otherwise.
Sync:
Inline + default drawer + no callback. Same element, no JS configuration — built-in icon + label + percent.
Stock:
import { createDropzoneSpinner } from '@keenmate/web-dropzone'
// A · whole-body callback. `false` → hide the surface.
counter.renderBodyCallback = ({ aggregate }) =>
aggregate.total === 0
? false
: aggregate.uploading > 0
? `<strong>Uploading ${aggregate.complete + 1} / ${aggregate.total}</strong>`
: `${aggregate.complete} / ${aggregate.total} done`
// B · split across the three slots. body decides visibility,
// fileInfo names the file, progress computes the rate.
speed.renderBodyCallback = ({ aggregate }) => aggregate.uploading > 0 ? null : false
speed.renderFileInfoCallback = ({ currentFile: f }) => f ? `<strong>${esc(f.name)}</strong>` : ''
speed.renderProgressCallback = ({ currentFile: f }) => f ? `@ ${formatRate(rateOf(f))}` : ''
// C · library's polished spinner, cached so the WAAPI rotation
// never restarts. Identity-checked memoization no-ops every tick.
let cached
spinner.renderBodyCallback = ({ aggregate }) => {
if (aggregate.uploading === 0) return false
return (cached ??= createDropzoneSpinner({ ariaLabel: 'Syncing' }))
}
The list satellite renders the same polished appearances as the convenience form. Here the picker accepts images and the grid list satellite generates thumbnails on the fly — same DOM, same CSS, same hover-action overlay as list-appearance="grid" on a classic <web-dropzone>.
<web-dropzone id="gallery" multiple accept="image/*"></web-dropzone> <web-dropzone-picker for="gallery" selector-appearance="card" accept="image/*"></web-dropzone-picker> <web-dropzone-list for="gallery" list-appearance="grid"></web-dropzone-list>
mode="headless" + files-changedThe most decoupled story: skip satellites entirely and react to the coalesced files-changed CustomEvent. The store fires it once per animation frame with the current snapshot — your renderer (vanilla DOM, Lit, Svelte, anything) diffs and updates whatever UI it owns. Granular events (file-progress / file-status-changed) still fire if you want per-tick fidelity instead.
const store = document.getElementById('dz-byo')
const list = document.getElementById('byo-list')
store.addEventListener('files-changed', (e) => {
const { files } = e.detail
list.replaceChildren(...files.map(f => {
const li = document.createElement('li')
li.textContent = `${f.name} — ${f.status} ${Math.round(f.progress)}%`
return li
}))
})