@spark-web/dropzone
v6.2.1
Published
--- title: Drop Zone isExperimentalPackage: true ---
Readme
title: Drop Zone isExperimentalPackage: true
The Dropzone component lets users upload files by clicking on, or dragging and
dropping onto the "drop zone". It can also be be triggered by clicking on the
label.
Usage
Field
Each Dropzone must be accompanied by a Field with a label.
Effective form labeling helps inform users which selection to make.
Examples
Accept
By providing an accept prop, we can make the Dropzone only accept certain
file types.
The value must be an object with a common MIME type as keys and an array of file extensions as values (similar to showOpenFilePicker's types accept option).
<Stack gap="large">
<Field label="Upload image" description="Accepts common image file formats">
<Dropzone accept={{ 'image/*': ['.png', '.jpg'] }} />
</Field>
<Field label="Upload PDF" description="Only accepts PDF files">
<Dropzone accept={{ 'application/pdf': ['.pdf'] }} />
</Field>
</Stack>Show Image Thumbnails
By default, once a file as been added to the Dropzone, a document icon will
displayed next to the file name in the list below. If you have uploaded an
image, you can use the showImageThumbnails to show an image preview instead.
<Field label="Upload image" description="Drop an image here to see a preview">
<Dropzone accept={{ 'image/*': ['.png', '.jpg'] }} showImageThumbnails />
</Field>Detailed view
Set isDetailedView to render each file as a richer row: a status icon, a
status message, and — for failures — a recovery action. The status itself is
supplied by you through fileDetails, keyed by the file id handed to
onChange — local:1, local:2, … for dropped files, so a key can never
collide with a backend id in existingFiles. Dropzone renders that state; it
never uploads anything or derives a status.
const [details, setDetails] = React.useState({
'local:1': { status: 'valid', uploadedFileStatusMessage: 'Looks good!' },
'local:2': { isProcessing: true, processingMessage: 'Checking…' },
'local:3': {
status: 'warning',
uploadedFileStatusMessage: 'Upload failed. Check your connection.',
},
'local:5': {
status: 'warning',
action: 'none',
uploadedFileStatusMessage: 'This file failed our security scan.',
},
'local:4': {
status: 'invalid',
uploadedFileStatusMessage: 'This looks like a bank statement.',
},
});
return (
<Field label="Payslip" description="Drop a file to add a row of your own">
<Dropzone
isDetailedView
maxFiles={6}
fileDetails={details}
retryUpload={id => console.log('retry', id)}
deleteUpload={id => console.log('delete', id)}
/>
</Field>
);A row is styled by the state you give it:
| fileDetails entry | Row |
| --------------------------- | ---------------------------------------------------------------------- |
| isProcessing: true | Spinner and processingMessage; no dismiss button; overrides status |
| processingTone | Tones a processing row's border, background and spinner |
| status: 'valid' | Positive tone |
| status: 'warning' | Caution tone; "Try again" when retryUpload is given |
| status: 'invalid' | Critical tone; "Replace file" action (see below) |
| any settled status | Keeps its dismiss button, beside any recovery action |
| showRemoveButton | false locks that one row — no dismiss (×); read in both views |
| action | Overrides the recovery action the status implies |
| uploadedFileErrorMessages | Bulleted list under the message once settled; wraps, never truncates |
| no entry | Renders bare — identical to the default row |
That last case is worth remembering: isDetailedView renders the detailed row,
but fileDetails is what makes it look detailed.
Choosing the recovery action
status says what happened; action says what the user can do about it. By
default warning offers "Try again" and invalid offers "Replace file", which
is usually the reading you want — set action only where it isn't.
Reach for it when one status covers both a transient failure and a permanent
one. An expired upload URL and a rejected virus scan are both warning, but
only the first is worth another attempt; action: 'none' spares the second a
"Try again" that sends the user in a circle.
fileDetails={{
doc_1: { status: 'warning', uploadedFileStatusMessage: 'Upload failed.' },
doc_2: {
status: 'warning',
action: 'none',
uploadedFileStatusMessage: 'This file failed our security scan.',
},
}}Each row reads its own entry, so retryUpload — one handler shared by every row
— is always called with the id of the row that was pressed. One row's action
never affects its neighbours'.
A processing row's message ends in an animated ellipsis that cycles . → .. →
..., so a slow round trip reads as still working rather than stalled. Write
processingMessage however you like — a trailing … or ... is trimmed rather
than doubled up, and the animation is dropped for a reduced-motion preference.
A processing row carries no dismiss (×) button: the work it is doing is already under way, so there is nothing left to dismiss. The button comes back once the row settles.
A processing row is colourless by default. Give it a processingTone —
critical, caution or positive — to tone its border, background and
spinner, so a destructive action in flight reads as one:
<Field label="Payslip" description="A row being removed, not added">
<Dropzone
isDetailedView
existingFiles={[{ id: 'doc_a1b2', name: 'payslip_march.pdf' }]}
fileDetails={{
doc_a1b2: {
isProcessing: true,
processingMessage: 'Deleting…',
processingTone: 'critical',
},
}}
deleteUpload={id => console.log('delete', id)}
/>
</Field>The message itself stays muted whatever the tone, because it narrates work rather than reporting an outcome — a settled row is where a message takes the colour of its result.
processingTone changes colour and nothing else. The recovery actions stay
gated on the settled status, so a processing row never grows a "Try again" or
"Replace file" button however it is toned, and the tone is ignored once the row
settles.
"Replace file" reopens the file picker and swaps the row out only once a replacement is accepted, so cancelling the dialog — or picking a file that fails validation — leaves the original in place.
Dropzone wires the action up itself for a file dropped into it: it owns that
row, so it swaps it out and tells you via deleteUpload. An existingFiles row
is rendered straight from your prop, so only you can remove it — that row offers
"Replace file" only when deleteUpload is supplied, and no dismiss button
either, since nothing could act on one. Without that gate the replacement would
be added while the original stayed put: two rows for one slot, permanently over
maxFiles.
Dismissal
A recovery action sits beside the dismiss button, never in place of it.
Recovering from a failure and giving up on the file are different intentions,
and a row shouldn't force one of them: a failed upload is exactly the case where
someone may want to try again or may have picked the wrong file and want it
gone. So action says what recovery is on offer, never whether the row can be
cleared — action: 'none' included.
A settled row carries its ✕ unless something makes it impossible, or you take it away deliberately:
| Row | Dismiss (×) |
| ----------------------------------------------------- | --------------------------------- |
| Settled, dropped into the dropzone | Yes |
| Settled, from existingFiles, with deleteUpload | Yes |
| Settled, from existingFiles, without deleteUpload | No — nothing could act on it |
| showRemoveButton: false | No — you took it away |
| isProcessing | No — the work is already underway |
The first row is the one that matters most: a file dropped into the Dropzone
lives in its internal state, and the row's own ✕ is the only thing that can
remove it.
Locking a row
Where a row genuinely shouldn't be removable — a mandatory document, or a list
frozen while it's under review — showRemoveButton: false takes the ✕ off that
row alone. It is a separate decision from action: a locked row still offers
whatever recovery its status implies.
fileDetails={{
doc_mandatory: {
status: 'valid',
uploadedFileStatusMessage: 'Required — contact support to change.',
showRemoveButton: false,
},
doc_optional: { status: 'valid' },
}}Take care on a file dropped into this session. It lives in Dropzone's own
state and the row's dismiss button is the only thing that can remove it, so
hiding that button strands the row until you clear the flag. An existingFiles
row is yours already — drop it from the array whenever you like.
Existing files
deleteUploadfires the moment the ✕ is pressed —Dropzonehas no confirmation step of its own, so if you want one it goes in your handler.Supply
deleteUploadwhenever you supplyexistingFiles.Dropzonerenders these rows from your prop and cannot remove them itself, so without that handler nothing can: such a row renders neither a dismiss button nor "Replace file", rather than offering a control that does nothing when pressed.Ids beginning with
local:are reserved for files dropped into the dropzone. One supplied here shares a React key and afileDetailsentry with a dropped row.Dropzonesteps over ids already on screen when minting its own, so the two can only meet if you introduce a reserved id after a file has been dropped.
existingFiles renders files uploaded in an earlier session alongside newly
dropped ones. They count towards maxFiles, and are keyed by whatever id your
backend gave them.
const [documents, setDocuments] = React.useState([
{ id: 'doc_a1b2', name: 'payslip_march.pdf' },
{ id: 'doc_c3d4', name: 'payslip_april.pdf' },
]);
return (
<Field label="Payslip" description="Two already uploaded, room for one more">
<Dropzone
isDetailedView
maxFiles={3}
existingFiles={documents}
fileDetails={Object.fromEntries(
documents.map(doc => [
doc.id,
{ status: 'valid', uploadedFileStatusMessage: 'Uploaded' },
])
)}
deleteUpload={id =>
setDocuments(docs => docs.filter(doc => doc.id !== id))
}
/>
</Field>
);Dropzone renders these straight from the prop rather than copying them into
its own state, so removing one only fires deleteUpload — drop it from your
own array to make the row disappear. It touches no local state, so onChange
and onBlur stay silent: an upload-on-change handler is not re-entered for
files it has already uploaded. onChange continues to report only dropped
File objects.
Download links
Give a row a downloadUrl and its file name renders as an underlined link. This
works for an existing file and for one uploaded in the current session alike —
anywhere your API can hand back a URL.
<Field label="Payslip" description="Click a file name to download it">
<Dropzone
isDetailedView
existingFiles={[
{ id: 'doc_a1b2', name: 'payslip_march.pdf' },
{ id: 'doc_c3d4', name: 'payslip_april.pdf' },
]}
fileDetails={{
doc_a1b2: {
downloadUrl: 'https://example.com/payslip_march.pdf',
status: 'valid',
uploadedFileStatusMessage: 'Uploaded',
},
doc_c3d4: {
status: 'valid',
uploadedFileStatusMessage: 'Uploaded — no link for this one',
},
}}
deleteUpload={id => console.log('delete', id)}
/>
</Field>The link renders as
<a href download target="_blank" rel="noopener noreferrer">. Same-origin URLs
save directly; cross-origin ones (S3, a CDN) ignore download per spec and open
in a new tab, so the user never loses the form they are on.
The file name is split across two elements so it can middle-truncate, which
would otherwise make the link announce as "payslip_mar ch.pdf". The whole name
is set as an explicit aria-label.
