npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

deleteUpload fires the moment the ✕ is pressed — Dropzone has no confirmation step of its own, so if you want one it goes in your handler.

Supply deleteUpload whenever you supply existingFiles. Dropzone renders 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 a fileDetails entry with a dropped row. Dropzone steps 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.

Props