Skip to content

File Upload

.bp-file-upload is a custom element (<bp-file-upload>) that wraps a native <label> + <input type="file"> dropzone and a queue of upload items. CSS handles drag feedback via [data-dragover] and per-item state via [data-state]. JS handles drag events, file selection, and an artificial “trickle” progress animation so a spinner never has to hide how far along an upload really is.

npm

@import '@be-partner-labs/ds/components/file-upload';

CDN

<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@be-partner-labs/ds/dist/components/file-upload.min.css"
/>

For the full bundle: https://cdn.jsdelivr.net/npm/@be-partner-labs/ds/dist/bpl-ds.min.css

Dropzone

    <bp-file-upload class="bp-file-upload">
    <label class="bp-file-upload__dropzone" for="fu-demo">
    <input class="bp-file-upload__input" type="file" id="fu-demo" multiple />
    <span class="bp-file-upload__icon" aria-hidden="true"></span>
    <span class="bp-file-upload__copy">Drag files here or <span class="bp-file-upload__browse">browse</span></span>
    </label>
    <ul class="bp-file-upload__list"></ul>
    </bp-file-upload>

    Queue states

    • quarterly-report.pdf 2.4 MB 62%
    • brand-assets.zip 18.1 MB 100%
    • video-master.mov 340 MB Upload failed
    <ul class="bp-file-upload__list">
    <li class="bp-file-upload__item" data-state="uploading">
    <span class="bp-file-upload__name">quarterly-report.pdf</span>
    <span class="bp-file-upload__size">2.4 MB</span>
    <progress class="bp-file-upload__progress" value="62" max="100"></progress>
    <span class="bp-file-upload__percent">62%</span>
    <button class="bp-file-upload__retry" type="button" hidden>Retry</button>
    </li>
    <li class="bp-file-upload__item" data-state="success">
    <span class="bp-file-upload__name">brand-assets.zip</span>
    <span class="bp-file-upload__size">18.1 MB</span>
    <progress class="bp-file-upload__progress" value="100" max="100"></progress>
    <span class="bp-file-upload__percent">100%</span>
    <button class="bp-file-upload__retry" type="button" hidden>Retry</button>
    </li>
    <li class="bp-file-upload__item" data-state="error">
    <span class="bp-file-upload__name">video-master.mov</span>
    <span class="bp-file-upload__size">340 MB</span>
    <progress class="bp-file-upload__progress" value="90" max="100"></progress>
    <span class="bp-file-upload__percent">Upload failed</span>
    <button class="bp-file-upload__retry" type="button">Retry</button>
    </li>
    </ul>

    The <bp-file-upload> custom element is required for drag state, file selection, and progress. Load it once:

    <script
    type="module"
    src="https://unpkg.com/@be-partner-labs/ds/js/bp-file-upload.esm.min.js"
    ></script>

    Or via npm:

    import '@be-partner-labs/ds/js/bp-file-upload'

    What JS handles:

    • Sets [data-dragover] on drag enter/over, clears it on drag leave/drop — three signals (border, glow, copy) before the drop
    • Builds a queue item (name, formatted size, <progress>, retry button) for each selected or dropped file
    • Runs a decelerating “trickle” simulation up to 90% while the real upload is in flight, so users see honest-feeling progress instead of a spinner
    • Exposes complete(item) to snap to 100% and mark success, and fail(item, message) to mark an error and reveal inline retry — the file stays loaded, so retry fires in one tap instead of forcing re-selection (see Retry vs. resume for what “resume” does and doesn’t cover)
    • Dispatches bp-file-upload:add, bp-file-upload:retry, bp-file-upload:success, bp-file-upload:error custom events so the host app can wire real upload requests
    const uploader = document.querySelector('bp-file-upload')
    uploader.addEventListener('bp-file-upload:add', async ({ detail: { file, item } }) => {
    try {
    await uploadFile(file) // your own upload call
    uploader.complete(item)
    } catch {
    uploader.fail(item, 'Upload failed')
    }
    })

    What CSS handles: All visual appearance via [data-dragover] on the wrapper and [data-state] on each item.

    The component’s built-in retry is a flow-level resume, not a byte-level one:

    • The File object is kept alive in the closure passed through bp-file-upload:add / bp-file-upload:retry — the user never re-picks the file.
    • trickle(item) reads progress.value before restarting, so the bar continues from wherever it stopped (e.g. 90%) instead of visually resetting to 0.

    That’s as far as a CSS/markup design system can go on its own — it has no opinion on your transport. Whether the retry actually skips bytes already received depends entirely on your upload protocol on the backend. To get true resume (not just “don’t lose the file”), pair the component with a chunked/resumable upload strategy:

    • Chunked PUT with Content-Range — split the file with Blob.slice(), upload sequentially, and track the last acknowledged offset per file.
    • A resumable protocoltus or S3 multipart upload, which hand you an upload/session ID your server can resume against.

    Track offsets outside the component (e.g. a Map<File, number>) and resume from there on retry. Since your transport now gives you real byte progress, drive the bar directly instead of trickling — trickle() is only for requests where you can’t get real progress events:

    function setProgress(item, percent) {
    item.querySelector('.bp-file-upload__progress').value = percent
    item.querySelector('.bp-file-upload__percent').textContent = `${percent}%`
    }
    const offsets = new Map() // File -> bytes already confirmed by the server
    async function uploadChunked(file, item) {
    const CHUNK = 5 * 1024 * 1024
    let offset = offsets.get(file) ?? 0
    while (offset < file.size) {
    const chunk = file.slice(offset, offset + CHUNK)
    await fetch(`/uploads/${file.name}`, {
    method: 'PUT',
    headers: { 'Content-Range': `bytes ${offset}-${offset + chunk.size - 1}/${file.size}` },
    body: chunk,
    })
    offset += chunk.size
    offsets.set(file, offset)
    setProgress(item, Math.round((offset / file.size) * 100))
    }
    offsets.delete(file)
    }
    const onUpload = ({ detail: { file, item } }) =>
    uploadChunked(file, item)
    .then(() => uploader.complete(item))
    .catch(() => uploader.fail(item, 'Upload failed'))
    uploader.addEventListener('bp-file-upload:add', onUpload)
    uploader.addEventListener('bp-file-upload:retry', onUpload)

    There’s no dedicated progress-setting method on the element — setProgress() above (two lines, no magic) is the whole surface. trickle(), complete(), and fail() remain the two moments the component itself owns: “we don’t know exactly how far along this is” and “it’s done or it isn’t.” Real, driven progress is your call once you have real numbers.

    VariableDefaultDescription
    --file-upload-border2px dashed var(--bp-color-border)Dropzone border
    --file-upload-border-radiusvar(--bp-radius-lg)Dropzone border radius
    --file-upload-backgroundvar(--bp-color-bg-subtle)Dropzone background
    --file-upload-paddingvar(--bp-space-8)Dropzone inner padding
    --file-upload-gapvar(--bp-space-2)Gap between dropzone children
    --file-upload-accentvar(--bp-primary)Drag-over / focus accent color
    No axe violations tested 2026-07-19
    • The dropzone is a native <label for="..."> wrapping a real <input type="file"> — clickable, keyboard-focusable, and announced correctly without any ARIA.
    • Progress uses the native <progress> element, not a styled <div>, so assistive tech gets the role and value for free.
    • Error state pairs a text message (“Upload failed”) with a visible Retry button — never color alone.
    • The file input keeps native focus styles via :focus-visible; don’t remove the outline without the box-shadow replacement already applied by .bp-file-upload__dropzone.
    APIAvailabilityUsed forWithout itPolyfill
    DataTransfer / drag events Widely available Baseline 2015 Reading dropped filesFalls back to click-to-browse via the native file inputNone needed
    <progress> Widely available Baseline 2015 Per-file upload progressNone — always supportedNone needed
    • --_border, --_border-radius, --_background, --_padding, --_gap, --_accent — component-private, do not set directly.
    • The trickle timer is stored on the item element (item._bpTrickleTimer) and cleared by complete() and fail().