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
Also see: Validated forms.
BbDropzone captures and validates files. It does not upload them.
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
Validate receipt attachments
Set accept, max-size and max-files on the zone, and turn every rejected file into a message the user can act on.
No receipts attached yet.
<template>
<div class="flex max-w-md flex-col gap-3">
<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="(errors) => (messages = errors.map(describe))"
@update:model-value="messages = []"
>
<template #default="{ labelId, open, dragging, focused }">
<div
class="zone"
:class="{
'zone--dragging': dragging,
'zone--focused': focused,
'zone--error': messages.length,
}"
>
<BbIcon class="opacity-60" icon="lucide:cloud-upload" size="lg" />
<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>
</div>
</template>
</BbDropzone>
<!-- Only accepted files reach the model, so this list never shows a rejection. -->
<ul v-if="receipts.length" class="list">
<li v-for="file in receipts" :key="file.name + file.size" class="item">
<BbIcon icon="lucide:file-text" size="sm" />
<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="m-0 text-xs opacity-70">No receipts attached yet.</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropzone, BbIcon } from 'bitboss-ui';
import { formatFileSize } from '~/demo-data';
import type { DropZoneError } from 'bitboss-ui';
const receipts = ref<File[]>([]);
// Seeded with one rejection so the error state is visible before any drop.
const messages = ref<string[]>([
'team-dinner.heic is not a receipt (PNG, JPG or PDF only).',
]);
const remove = (target: File) => {
receipts.value = receipts.value.filter((file) => file !== target);
};
// 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 drop updates the model first, then emits one `error` with every rejection
// of that drop. So the model update clears the last drop's messages, and the
// error, when there is one, replaces them: a clean drop leaves 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.375rem;
padding: 1.25rem 1rem;
text-align: center;
}
.zone--dragging {
border-color: var(--bb-primary);
}
.zone--focused {
outline: var(--bb-ring-size) solid var(--bb-primary);
outline-offset: 2px;
}
.zone--error {
border-color: var(--bb-danger);
}
.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>
Rejected files never enter the model. After a drop updates the model, @error hands you every typed rejection of that drop in one array, and a clean drop emits none. The component never fills errors itself, so clear your messages on update:modelValue and set them on error.
Model and file constraints
multiple changes both behaviour and type: single mode replaces a File | null; multiple mode appends to a seeded File[].
<template>
<div class="flex max-w-md flex-col gap-5">
<!-- Single: the model is `File | null`, and a new pick replaces the old.
The bare tokens are shorthand: 'pdf' covers both `.pdf` and
`application/pdf`. The other demos write the pairs out. -->
<BbDropzone
id="apply-resume"
v-model="resume"
:accept="['pdf', 'docx']"
:errors="errors.resume"
@error="(rejected) => reject('resume', rejected)"
@update:model-value="clear('resume')"
>
<template #default="{ labelId, open, dragging, focused }">
<div class="zone" :class="zoneClass(dragging, focused, errors.resume)">
<BbIcon class="opacity-60" icon="lucide:file-text" size="md" />
<span class="flex min-w-0 flex-1 flex-col">
<span :id="labelId" class="text-sm font-medium">Rรฉsumรฉ</span>
<span class="truncate text-xs text-[color:var(--bb-text-muted)]">
{{ resume ? resume.name : 'PDF or DOCX, one file' }}
</span>
</span>
<BbButton size="xs" variant="outline" @click="open">
{{ resume ? 'Replace' : 'Choose' }}
</BbButton>
</div>
</template>
</BbDropzone>
<!-- Multiple: the model is `File[]`, seeded empty; picks are appended.
Explicit entries: `.pdf` still matches a drop with an empty `type`. -->
<BbDropzone
id="apply-samples"
v-model="samples"
:accept="['image/*', 'application/pdf', '.pdf']"
:errors="errors.samples"
:max-files="5"
:max-size="5 * 1024 * 1024"
multiple
@error="(rejected) => reject('samples', rejected)"
@update:model-value="clear('samples')"
>
<template #default="{ labelId, open, dragging, focused }">
<div class="zone" :class="zoneClass(dragging, focused, errors.samples)">
<BbIcon class="opacity-60" icon="lucide:images" size="md" />
<span class="flex min-w-0 flex-1 flex-col">
<span :id="labelId" class="text-sm font-medium">Work samples</span>
<span class="text-xs text-[color:var(--bb-text-muted)]">
Images or PDF, up to 5 files of 5 MB
</span>
</span>
<BbButton size="xs" variant="outline" @click="open">Add</BbButton>
</div>
</template>
</BbDropzone>
<!-- Removal is a filter over the model: there is no files API to call. -->
<ul v-if="samples.length" class="list">
<li v-for="file in samples" :key="fileKey(file)" class="item">
<span class="min-w-0 flex-1 truncate">{{ file.name }}</span>
<span class="shrink-0 tabular-nums opacity-60">
{{ formatFileSize(file.size) }}
</span>
<BbButton
:aria-label="`Remove ${file.name}`"
icon="lucide:x"
size="xs"
variant="ghost"
@click="samples = samples.filter((f) => f !== file)"
/>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { BbButton, BbDropzone, BbIcon } from 'bitboss-ui';
import { formatFileSize } from '~/demo-data';
import type { DropZoneError } from 'bitboss-ui';
const resume = ref<File | null>(null);
const samples = ref<File[]>([]);
const errors = reactive({ resume: [] as string[], samples: [] as string[] });
type Zone = keyof typeof errors;
const fileKey = (file: File) =>
`${file.name}-${file.size}-${file.lastModified}`;
const zoneClass = (dragging: boolean, focused: boolean, errors: string[]) => ({
'zone--dragging': dragging,
'zone--focused': focused,
'zone--error': errors.length > 0,
});
const describe = (error: DropZoneError, zone: Zone): string => {
switch (error.type) {
case 'accept':
return `${error.filename} is not ${zone === 'resume' ? 'a PDF or DOCX' : 'an image or PDF'}.`;
case 'maxFiles':
return `Only ${error.maxFiles} samples: ${error.filename} was not added.`;
case 'maxSize':
return `${error.filename} is ${error.formattedSize}, over ${error.formattedMaxSize}.`;
case 'uniqueness':
return `${error.filename} is already in the list.`;
}
};
// A pick updates the model, then emits one `error` with all its rejections,
// so the update clears the zone and the error refills it.
const reject = (zone: Zone, rejected: DropZoneError[]) => {
errors[zone] = rejected.map((error) => describe(error, zone));
};
const clear = (zone: Zone) => {
errors[zone] = [];
};
</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;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.zone--dragging {
border-color: var(--bb-primary);
}
.zone--focused {
outline: var(--bb-ring-size) solid var(--bb-primary);
outline-offset: 2px;
}
.zone--error {
border-color: var(--bb-danger);
}
.list {
display: flex;
flex-direction: column;
gap: 0.25rem;
list-style: none;
margin: -0.75rem 0 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>
accept takes MIME types (application/pdf), wildcards (image/*), extensions (.pdf) or bare tokens (pdf), and a bare token covers both the extension and the MIME type. If you write explicit entries, pair each MIME type with its extension, because a dropped file can arrive with an empty file.type. max-size is bytes per file, and max-files counts the files already picked plus the new ones.
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-3">
<!-- `labelId` on the headline only: the name is "Drag your invoices here
Select files". On the wrapper it would also read the hint and button. -->
<BbDropzone
id="naming-invoices"
v-model="invoices"
multiple
:accept="['application/pdf', '.pdf', 'image/png', '.png']"
:max-size="10 * 1024 * 1024"
>
<template #default="{ labelId, open, dragging, focused }">
<div
class="zone"
:class="{ 'zone--dragging': dragging, 'zone--focused': focused }"
>
<BbIcon class="opacity-60" icon="lucide:receipt" size="lg" />
<p :id="labelId" class="m-0 text-sm font-medium">
Drag your invoices here
</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]">
PDF or PNG ยท up to 10 MB
</p>
<BbButton size="xs" variant="outline" @click="open">
Browse files
</BbButton>
</div>
</template>
</BbDropzone>
<ul v-if="invoices.length" class="m-0 flex flex-col gap-1 p-0 text-xs">
<li v-for="file in invoices" :key="file.name" class="list-none truncate">
{{ file.name }}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropzone, BbIcon } from 'bitboss-ui';
const invoices = ref<File[]>([]);
</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;
}
.zone--dragging {
border-color: var(--bb-primary);
}
.zone--focused {
outline: var(--bb-ring-size) solid var(--bb-primary);
outline-offset: 2px;
}
</style>
The real input is visually hidden. Paint a focus ring from the slot's focused flag and a drop highlight from dragging, and wire open() to a visible Browse button. Without labelId the zone takes the localized default name ("Select files" in English), which only fits a zone with no visible headline. Do not nest another label; the slot already renders inside one.
Upload and submit
Inside a BbForm, give the dropzone a name and build FormData from the files @submit hands you.
<template>
<BbForm
:model="submission"
class="flex max-w-md flex-col gap-4"
@submit="onSubmit"
>
<BbTextInput id="submission-title" label="Submission title" name="title" />
<div class="flex flex-col gap-1">
<p class="m-0 text-sm font-medium">Sent with the Q2 pack</p>
<ul class="list">
<li v-for="file in attached" :key="file.id" class="item">
<BbIcon class="opacity-60" icon="lucide:file-text" size="sm" />
<span class="min-w-0 flex-1 truncate">{{ file.name }}</span>
<span class="shrink-0 tabular-nums opacity-60">
{{ formatFileSize(file.size) }}
</span>
</li>
</ul>
</div>
<!-- No label on a dropzone: inside a BbForm, `name` is its only key. -->
<BbDropzone
id="submission-documents"
:accept="['application/pdf', '.pdf']"
multiple
name="documents"
:rules="atLeastOne"
>
<template #default="{ labelId, open, dragging, focused }">
<div
class="zone"
:class="{ 'zone--dragging': dragging, 'zone--focused': focused }"
>
<p :id="labelId" class="m-0 text-sm font-medium">
Add the Q3 documents
</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]">
PDF, listed below once added
</p>
<BbButton size="xs" variant="outline" @click="open">
Browse files
</BbButton>
</div>
</template>
</BbDropzone>
<!-- The form holds the picked files: read them from the bound object. -->
<ul v-if="submission.documents.length" class="list -mt-2">
<li v-for="file in submission.documents" :key="file.name" class="item">
<BbIcon class="opacity-60" icon="lucide:file-text" size="sm" />
<span class="min-w-0 flex-1 truncate">{{ file.name }}</span>
<span class="shrink-0 tabular-nums opacity-60">
{{ formatFileSize(file.size) }}
</span>
<BbButton
:aria-label="`Remove ${file.name}`"
icon="lucide:x"
size="xs"
variant="ghost"
@click="remove(file)"
/>
</li>
</ul>
<BbButton
class="self-start"
type="submit"
variant="primary"
>
Send for review
</BbButton>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]" role="status">
{{ status }}
</p>
</BbForm>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { BbButton, BbDropzone, BbForm, BbIcon, BbTextInput } from 'bitboss-ui';
import { delay, files, formatFileSize } from '~/demo-data';
const submission = reactive({
title: 'Q3 compliance pack' as string | null,
documents: [] as File[],
});
// Metadata the server already holds: a `File` cannot exist during prerender.
const attached = files.filter((file) => file.id === 'f-2201');
const atLeastOne = (value: unknown) =>
(Array.isArray(value) && value.length > 0) || 'Add at least one Q3 PDF.';
// Removal is a filter over the bound value; the dropzone follows it.
const remove = (target: File) => {
submission.documents = submission.documents.filter((f) => f !== target);
};
const status = ref('');
/** Runs only when every field passes; builds the multipart body from the values. */
const onSubmit = async (values: Record<string, unknown>) => {
const body = new FormData();
body.append('title', String(values.title ?? ''));
for (const file of values.documents as File[])
body.append('documents[]', file);
await delay(null, 500); // stands in for fetch('/submissions', { method: 'POST', body })
const count = body.getAll('documents[]').length;
status.value = `Sent for review with ${count} new ${count === 1 ? 'PDF' : 'PDFs'}.`;
};
</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: 1rem;
text-align: center;
}
.zone--dragging {
border-color: var(--bb-primary);
}
.zone--focused {
outline: var(--bb-ring-size) solid var(--bb-primary);
outline-offset: 2px;
}
/* The root carries `bb-dropzone--errors` while a rule fails. */
.bb-dropzone--errors .zone {
border-color: var(--bb-danger);
}
.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.375rem 0.5rem;
}
</style>
Send with no new PDF and the form stops at the dropzone and moves focus to it. A dropzone has no label, so without name the form throws, and rule messages say "This field" unless you set validation-label. Outside a form, name renders the hidden input a native multipart post needs. Progress, cancellation, retry and server errors stay in your own state.
Coming from v2hidden input only with name
v2 always rendered the hidden submittable input; now it exists only when name is set.
Lock the zone during an upload
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"
:accept="['.bin']"
:disabled="uploading"
@update:model-value="done = false"
>
<template #default="{ labelId, focused }">
<div
class="zone"
:class="{ 'zone--focused': focused, 'zone--disabled': uploading }"
>
<BbIcon class="opacity-60" icon="lucide:cpu" size="md" />
<span class="flex min-w-0 flex-1 flex-col">
<span :id="labelId" class="text-sm font-medium">Firmware image</span>
<span class="truncate text-xs text-[color:var(--bb-text-muted)]">
{{
file
? `${file.name} ยท ${formatFileSize(file.size)}`
: 'Drag a .bin file here or click to choose'
}}
</span>
</span>
</div>
</template>
</BbDropzone>
<div v-if="uploading" class="flex flex-col gap-1.5">
<div class="flex justify-between text-xs">
<span>Uploading, the image is locked</span>
<span class="tabular-nums text-[color:var(--bb-text-muted)]">
{{ progress }}%
</span>
</div>
<BbProgress
:label="`Uploading ${file?.name}`"
:model-value="progress"
/>
</div>
<p v-else-if="done" class="m-0 text-xs" role="status">
Uploaded. Drop another image to replace it.
</p>
<BbButton
class="self-start"
:disabled="!file || uploading || done"
size="sm"
variant="primary"
@click="upload"
>
Upload
</BbButton>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { BbButton, BbDropzone, BbIcon, BbProgress } from 'bitboss-ui';
import { delay, formatFileSize } from '~/demo-data';
const file = ref<File | null>(null);
const uploading = ref(false);
const done = ref(false);
const progress = ref(0);
// A `File` cannot exist during prerender, so the demo picks one on mount.
onMounted(() => {
file.value = new File([new Uint8Array(1_843_200)], 'router-fw-2.4.1.bin', {
lastModified: 0,
});
});
// A fake transfer, started by the click and advanced by awaited delays, never
// by a page-load timer.
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;
done.value = true;
}
</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;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.zone--focused {
outline: var(--bb-ring-size) solid var(--bb-primary);
outline-offset: 2px;
}
.zone--disabled {
opacity: 0.6;
}
</style>
To show files nobody can change, such as a request already sent for review, set readonly. Drop, the picker and open() do nothing, the input stays focusable, and a named zone still submits its files, which disabled would not. The file list is yours, so hide its remove buttons and your Browse button with the slot's readonly flag.
There is no loading state: pair an active transfer with BbProgress. On a phone, the zone opens the system file picker.