Use it for
Use BbDropzone when users need a visible drop target, picker access, and file validation.
Use something else when
<input type="file">— one picker with no drop surface or rendered selection is enoughBbProgress— the task is showing an upload already in progress
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
BbDropzone captures and validates files. It does not upload them.
Validate receipt attachments
Put type, size, count, and duplicate rules at the boundary, then translate each typed rejection into application copy.
<template>
<div class="max-w-md">
<BbDropzone
id="receipts"
v-model="receipts"
:accept="['image/png', '.png', 'image/jpeg', '.jpg', 'application/pdf', '.pdf']"
:errors="messages"
:max-files="3"
:max-size="512 * 1024"
multiple
@error="onError"
>
<template #default="{ labelId, open }">
<div class="zone">
<p :id="labelId" class="text-sm font-medium">Drag receipts here</p>
<p class="text-xs opacity-60">
PNG, JPG or PDF · at most 3 files · 512 KB each
</p>
<BbButton size="sm" variant="outline" @click="open">
Browse files
</BbButton>
<p v-if="receipts.length" class="text-xs opacity-70">
{{ receipts.length }} attached
</p>
</div>
</template>
</BbDropzone>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropzone } from 'bitboss-ui';
import type { DropZoneError } from 'bitboss-ui';
const receipts = ref<File[]>([]);
const messages = ref<string[]>([]);
// The union is closed and discriminated on `type`, so this switch is
// exhaustive — a new variant in a later version fails the type-check here.
// The wording is yours: the component never fills `errors` for you.
const describe = (error: DropZoneError): string => {
switch (error.type) {
case 'accept':
return `${error.filename} is not a receipt — PNG, JPG or PDF only.`;
case 'maxFiles':
return `${error.filename} would make ${error.totalFiles}; the limit is ${error.maxFiles}.`;
case 'maxSize':
return `${error.filename} is ${error.formattedSize}, over the ${error.formattedMaxSize} limit.`;
case 'uniqueness':
return `${error.filename} is already attached.`;
}
};
// A batch drop emits one error per rejected file. Accumulating them is one of
// three reasonable choices — showing only the last, or counting them, are the
// others.
const onError = (error: DropZoneError) => {
messages.value = [...messages.value, describe(error)].slice(-3);
};
</script>
<style scoped>
.zone {
align-items: center;
background: var(--bb-panel);
border: var(--bb-border-w) dashed var(--bb-border);
border-radius: var(--bb-radius);
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1.25rem 1rem;
text-align: center;
}
</style>
Rejected files never enter the model. The error event does not fill errors for you: map its discriminated payload and pass the message back so it is announced in context.
Model and file constraints
multiple changes both behavior and type: single mode replaces File | null; multiple mode appends to a seeded File[].
File | null → null
File[] → []
<template>
<div class="flex max-w-md flex-col gap-4">
<div class="flex flex-col gap-2">
<BbDropzone id="single-photo" v-model="photo" :accept="['image/*']">
<template #default="{ labelId }">
<div class="zone">
<p :id="labelId" class="text-sm font-medium">Profile photo</p>
<p class="text-xs opacity-60">
Single — a new pick replaces the old one
</p>
</div>
</template>
</BbDropzone>
<p class="text-xs opacity-70">
<code>File | null</code> → {{ photo ? photo.name : 'null' }}
</p>
</div>
<div class="flex flex-col gap-2">
<BbDropzone id="multi-attachments" v-model="attachments" multiple>
<template #default="{ labelId }">
<div class="zone">
<p :id="labelId" class="text-sm font-medium">Attachments</p>
<p class="text-xs opacity-60">
Multiple — every pick is appended to the array
</p>
</div>
</template>
</BbDropzone>
<ul v-if="attachments.length" class="list">
<li v-for="file in attachments" :key="fileKey(file)" class="item">
<span class="min-w-0 flex-1 truncate">{{ file.name }}</span>
<span class="shrink-0 opacity-60">{{
formatFileSize(file.size)
}}</span>
<BbButton
:aria-label="`Remove ${file.name}`"
icon="lucide:x"
size="xs"
variant="ghost"
@click="remove(file)"
/>
</li>
</ul>
<p v-else class="text-xs opacity-70"><code>File[]</code> → []</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropzone } from 'bitboss-ui';
import { formatFileSize } from '~/demo-data';
const photo = ref<File | null>(null);
const attachments = ref<File[]>([]);
const fileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`;
// There is no files API to call: removal is a filter over the model, and the
// model only ever holds files that passed validation.
const remove = (target: File) => {
attachments.value = attachments.value.filter((file) => file !== target);
};
</script>
<style scoped>
.zone {
align-items: center;
background: var(--bb-panel);
border: var(--bb-border-w) dashed var(--bb-border);
border-radius: var(--bb-radius);
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1.25rem 1rem;
text-align: center;
}
.list {
display: flex;
flex-direction: column;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.item {
align-items: center;
border: var(--bb-border-w) solid var(--bb-border);
border-radius: var(--bb-radius);
display: flex;
font-size: 0.75rem;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
}
</style>
Apply accept to picker and drop paths. Pair MIME types with extensions when operating systems may omit file.type.
<template>
<div class="flex max-w-md flex-col gap-4">
<!-- Explicit MIME + extension: the MIME rule matches by content type,
the `.ext` rule by filename. A drop where the OS leaves `type`
empty is caught by the second. No `mime` database is loaded. -->
<BbDropzone
id="accept-explicit"
v-model="spreadsheets"
:accept="['text/csv', '.csv']"
:max-files="3"
:max-size="2 * 1024 * 1024"
multiple
>
<template #default="{ labelId }">
<div class="zone">
<p :id="labelId" class="text-sm font-medium">Catalogue import</p>
<p class="text-xs opacity-60">
CSV only · at most 3 files · 2 MB each
</p>
</div>
</template>
</BbDropzone>
<!-- Bare tokens: `pdf` expands to `.pdf` immediately and to
`application/pdf` once the mime database resolves. -->
<BbDropzone
id="accept-tokens"
v-model="documents"
:accept="['pdf', 'docx']"
multiple
>
<template #default="{ labelId }">
<div class="zone">
<p :id="labelId" class="text-sm font-medium">Signed documents</p>
<p class="text-xs opacity-60">PDF or DOCX</p>
</div>
</template>
</BbDropzone>
<!-- A wildcard covers a whole major type. -->
<BbDropzone id="accept-wildcard" v-model="shot" :accept="['image/*']">
<template #default="{ labelId }">
<div class="zone">
<p :id="labelId" class="text-sm font-medium">Product shot</p>
<p class="text-xs opacity-60">Any image</p>
</div>
</template>
</BbDropzone>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbDropzone } from 'bitboss-ui';
const spreadsheets = ref<File[]>([]);
const documents = ref<File[]>([]);
const shot = ref<File | null>(null);
</script>
<style scoped>
.zone {
align-items: center;
background: var(--bb-panel);
border: var(--bb-border-w) dashed var(--bb-border);
border-radius: var(--bb-radius);
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1.25rem 1rem;
text-align: center;
}
</style>
max-size is bytes per file. max-files counts the existing and incoming selection. The model always contains validated files only.
Own the drop surface
The default slot is the visible surface. Put labelId on its headline only, not on a wrapper that also contains hints, buttons, and filenames.
<template>
<div class="flex max-w-md flex-col gap-4">
<!-- `labelId` on the headline: the name is
"Drag your invoices here Select files". -->
<BbDropzone id="naming-good" v-model="named" multiple>
<template #default="{ labelId, open }">
<div class="zone">
<p :id="labelId" class="text-sm font-medium">
Drag your invoices here
</p>
<p class="text-xs opacity-60">PDF or PNG · up to 10 MB</p>
<BbButton size="sm" variant="ghost" @click="open">
Browse files
</BbButton>
<ul v-if="named.length" class="text-xs opacity-70">
<li v-for="file in named" :key="file.name">{{ file.name }}</li>
</ul>
</div>
</template>
</BbDropzone>
<!-- No `labelId` at all: the name falls back to the localized
"Select files", and no dangling aria-labelledby is emitted. -->
<BbDropzone id="naming-default" v-model="unnamed">
<template #default>
<div class="zone">
<p class="text-sm font-medium">Company logo</p>
<p class="text-xs opacity-60">
Named "Select files" — fine when the copy above is not a headline.
</p>
</div>
</template>
</BbDropzone>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropzone } from 'bitboss-ui';
const named = ref<File[]>([]);
const unnamed = ref<File | null>(null);
</script>
<style scoped>
.zone {
align-items: center;
background: var(--bb-panel);
border: var(--bb-border-w) dashed var(--bb-border);
border-radius: var(--bb-radius);
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1.25rem 1rem;
text-align: center;
}
</style>
Use the slot's dragging and focused flags for state, and open() for a visible Browse button. Do not nest another label; the slot already renders inside one.
Upload and submit
Build FormData from the model and keep progress, cancellation, retry, and server errors in application state.
<template>
<form class="flex max-w-md flex-col gap-3" @submit.prevent="submit">
<BbTextInput
id="submission-title"
v-model="title"
label="Submission title"
name="title"
/>
<div class="flex flex-col gap-1">
<p class="text-sm font-medium">Already on file</p>
<ul class="list">
<li v-for="file in attached" :key="file.id" class="item">
<span class="min-w-0 flex-1 truncate">{{ file.name }}</span>
<span class="shrink-0 opacity-60">
{{ formatFileSize(file.size) }}
</span>
</li>
</ul>
</div>
<!-- `name` is what renders the hidden submittable input. Without it v3
renders none at all, and a native <form> post sends no files. -->
<BbDropzone
id="submission-documents"
v-model="documents"
:accept="['pdf']"
multiple
name="documents"
>
<template #default="{ labelId }">
<div class="zone">
<p :id="labelId" class="text-sm font-medium">Add documents</p>
<p class="text-xs opacity-60">PDF · appended to the list above</p>
</div>
</template>
</BbDropzone>
<BbButton size="sm" type="submit">Submit</BbButton>
<p v-if="payload" class="text-xs opacity-70">
FormData entries: <code>{{ payload }}</code>
</p>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropzone, BbTextInput } from 'bitboss-ui';
import { files, formatFileSize } from '~/demo-data';
const title = ref<string | null>('Q3 compliance pack');
const documents = ref<File[]>([]);
const payload = ref('');
// Files the server already holds. They are metadata, not `File` objects — a
// `File` cannot be constructed during prerender, and the model only ever holds
// files the reader actually picked.
const attached = files.filter((file) => file.mimeType === 'application/pdf');
// The idiomatic path for anything with JavaScript: read the model, build the
// multipart body yourself, send it. `name` above covers the no-JS case.
const submit = () => {
const body = new FormData();
body.append('title', title.value ?? '');
documents.value.forEach((file) => body.append('documents[]', file));
payload.value = [...body.keys()].join(', ') || '(none)';
};
</script>
<style scoped>
.zone {
align-items: center;
background: var(--bb-panel);
border: var(--bb-border-w) dashed var(--bb-border);
border-radius: var(--bb-radius);
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1.25rem 1rem;
text-align: center;
}
.list {
display: flex;
flex-direction: column;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.item {
align-items: center;
border: var(--bb-border-w) solid var(--bb-border);
border-radius: var(--bb-radius);
display: flex;
font-size: 0.75rem;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
}
</style>
Set name only for a native multipart post. In v3, the hidden submittable input exists only when name is present; v2 rendered it unconditionally.
Unsupported field states
Disable the zone while an upload is active so the selection cannot change mid-request.
<template>
<div class="flex max-w-md flex-col gap-3">
<!-- `disabled` while the transfer is in flight, so the selection cannot
change mid-upload. There is no `loading` prop: the bar is yours. -->
<BbDropzone id="states-zone" v-model="file" :disabled="uploading">
<template #default="{ labelId }">
<div class="zone" :class="{ 'zone--disabled': uploading }">
<p :id="labelId" class="text-sm font-medium">Firmware image</p>
<p class="text-xs opacity-60">
{{ uploading ? 'Uploading — locked' : 'Drag a .bin file here' }}
</p>
</div>
</template>
</BbDropzone>
<BbProgress
v-if="uploading"
label="Upload progress"
:model-value="progress"
/>
<BbButton
:disabled="!file || uploading"
size="sm"
variant="outline"
@click="upload"
>
Upload
</BbButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropzone, BbProgress } from 'bitboss-ui';
import { delay } from '~/demo-data';
const file = ref<File | null>(null);
const uploading = ref(false);
const progress = ref(0);
// A fake transfer, started by the click and advanced by awaited delays — never
// by a page-load timer. The zone locks itself with `disabled` while it runs,
// because `readonly` and `loading` do not exist on this component.
async function upload() {
uploading.value = true;
progress.value = 0;
while (progress.value < 100) {
await delay(null, 150);
progress.value = Math.min(100, progress.value + 10);
}
uploading.value = false;
}
</script>
<style scoped>
.zone {
align-items: center;
background: var(--bb-panel);
border: var(--bb-border-w) dashed var(--bb-border);
border-radius: var(--bb-radius);
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1.25rem 1rem;
text-align: center;
}
.zone--disabled {
opacity: 0.6;
}
</style>
There is no readonly, loading, compact, or adaptive overlay. Render a read-only file list yourself, pair active transfers with BbProgress, and let mobile use the native operating-system picker.