Files dropped on a field or on the whole page, with a button for the keyboard and for touch, checked against your types and sizes.

A field

FileDropZone is a field that takes files: a dashed box to drop them on, a button that opens the file picker, your rules under it, and a line for the files it refused. Drag a file over it and the edge turns solid ink with "Drop to upload"; drop it, choose it, click anywhere on the box, or focus the button and paste it. On a phone, which cannot drag a file into a page, the hint is not drawn and the button stays. For the camera, put a second FileButton with capture beside the zone, as the "Take a photo" button below does on a touch screen: capture on the zone itself makes its only button the camera's on iOS and Android, so a phone could no longer choose a PDF.

Example

Receipt
Drag a file here or
PDF, JPG, PNG or HEIC, up to 10 MB
Follows the page theme and corners
'use client';

import { FileButton, FileDropZone, Text } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

const TEN_MB = 10 * 1024 * 1024;

/** Each type by its MIME type and its extensions, since systems disagree on types; HEIC for a phone's photos. */
const TYPES = [
  'application/pdf',
  '.pdf',
  'image/jpeg',
  '.jpg',
  '.jpeg',
  'image/png',
  '.png',
  'image/heic',
  '.heic',
];

export default function FileDropZoneReceipt(): ReactElement {
  const [added, setAdded] = useState<string | null>(null);
  const take = (accepted: File[]): void => {
    if (accepted[0] !== undefined) setAdded(accepted[0].name);
  };
  return (
    <div className="stack" style={{ width: '100%', maxWidth: '28rem' }}>
      <FileDropZone
        label="Receipt"
        description="PDF, JPG, PNG or HEIC, up to 10 MB"
        acceptedFileTypes={TYPES}
        maxFileSize={TEN_MB}
        onFiles={take}
      />
      {/* On a phone only, beside the zone rather than on its button, which would then take photos only: a desktop
          browser ignores capture and opens the file picker. */}
      <span className="touch-only">
        <FileButton
          variant="secondary"
          capture="environment"
          acceptedFileTypes={['image/*', '.heic']}
          maxFileSize={TEN_MB}
          onFiles={take}
        >
          Take a photo
        </FileButton>
      </span>
      {added === null ? null : (
        <Text size="small" tone="muted" className="receipt-added">
          Added {added}
        </Text>
      )}
    </div>
  );
}

Errors in your words

Every drop, paste and choice runs one check, and onFiles gets the files that passed and the ones that did not, each with its reason: 'type', 'size', 'empty' or 'count'. A drop of five with one too large adds four. By default the zone says how many were not added and, when they share one, why: "1 file was not added: it is too large". labels.rejected gets the count, the count as formatCount writes it and the refusals, (count, formatted, rejected), to say it in your words, the shape of UploadTray's cancelAllTitle, while its title, started and settled take every count and formatCount itself; errorMessage replaces that line with your own, such as one that names each file in the GOV.UK Design System's words: "The selected file must be smaller than 10 MB".

Example

Statements
Drag files here or
PDF, JPG or PNG, up to 10 MB each, 5 at a time
Follows the page theme and corners
'use client';

import { FileDropZone, type FileRejection, type FileRejectionReason } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

const TEN_MB = 10 * 1024 * 1024;

/** Each reason in GOV.UK's words for a file upload's errors. */
const WORDS: Record<FileRejectionReason, string> = {
  type: 'must be a PDF, JPG or PNG',
  size: 'must be smaller than 10 MB',
  empty: 'is empty',
  count: 'was one too many: add up to 5 files at a time',
};

export default function FileDropZoneErrors(): ReactElement {
  const [refused, setRefused] = useState<FileRejection[]>([]);
  return (
    <div style={{ width: '100%', maxWidth: '32rem' }}>
      <FileDropZone
        label="Statements"
        description="PDF, JPG or PNG, up to 10 MB each, 5 at a time"
        acceptedFileTypes={['application/pdf', '.pdf', 'image/jpeg', '.jpg', '.jpeg', 'image/png', '.png']}
        maxFileSize={TEN_MB}
        allowsMultiple
        maxFiles={5}
        errorMessage={refused.map(({ file, reason }, index) => (
          <span key={`${file.name}-${String(index)}`} style={{ display: 'block' }}>
            {`The selected file ${file.name} ${WORDS[reason]}`}
          </span>
        ))}
        onFiles={(_accepted, rejected) => {
          setRefused(rejected);
        }}
      />
    </div>
  );
}

The whole page

PageDropZone takes a drop anywhere on the page. It draws nothing until files are dragged over the window; then an overlay covers the workspace, clear of the rail, and says what a drop does. It also takes a paste of files anywhere on the page while focus is outside a text field, as GitHub and Slack do. The overlay is for the pointer and hidden from assistive technology, so every page zone has a button: put a FileButton beside the page's title. Mount the zone at the page's root: it draws its overlay in the AppShell, or the body, so a header's blur or a transform never confines it. Turn the switch on and drag a file from your desktop onto this page, or paste one.

Example

Changed

Nothing added yet.

Follows the page theme and corners
'use client';

import { FileButton, PageDropZone, PageHeader, Switch, Text, type FileRejection } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

const TYPES = ['application/pdf', '.pdf', 'image/*', '.heic'];

export default function FileDropZonePage(): ReactElement {
  const [isOn, setOn] = useState(false);
  const [added, setAdded] = useState<string[]>([]);
  const [refused, setRefused] = useState<string[]>([]);
  const take = (accepted: File[], rejected: FileRejection[]): void => {
    setAdded((names) => [...accepted.map((file) => file.name), ...names].slice(0, 5));
    setRefused(rejected.map(({ file }) => file.name));
  };
  return (
    <div className="stack" style={{ width: '100%', maxWidth: '32rem' }}>
      {/* The page zone's keyboard twin: every page zone has a button in the page header's actions. */}
      <PageHeader
        // An example on a docs page, which has its own h1.
        headingLevel={2}
        title="Documents"
        actions={
          <FileButton acceptedFileTypes={TYPES} allowsMultiple onFiles={take}>
            Upload
          </FileButton>
        }
      />
      <Switch isSelected={isOn} onChange={setOn}>
        Drop anywhere on this page
      </Switch>
      <Text size="small" tone="muted" className="page-zone-added">
        {added.length === 0 ? 'Nothing added yet.' : `Added ${added.join(', ')}`}
      </Text>
      {refused.length === 0 ? null : (
        <Text size="small" tone="muted" className="page-zone-refused">
          {`Not added: ${refused.join(', ')}`}
        </Text>
      )}
      {/* In an app, mount the page zone at the page's root, beside its header: it draws its overlay in the shell. */}
      {isOn ? (
        <PageDropZone
          title="Drop files to upload"
          description="To Documents, 2026"
          acceptedFileTypes={TYPES}
          allowsMultiple
          onFiles={take}
        />
      ) : null}
    </div>
  );
}

A button alone

FileButton is React Aria's FileTrigger around the library's Button, with the same check and the same onFiles. React Aria clears its input as it is pressed, so choosing the same file again after a failure fires again.

Example

No file chosen.

Follows the page theme and corners
'use client';

import { FileButton, Text } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

export default function FileButtonBasic(): ReactElement {
  const [chosen, setChosen] = useState<string[]>([]);
  const take = (accepted: File[]): void => {
    setChosen(accepted.map((file) => file.name));
  };
  return (
    <div className="stack">
      <div className="row">
        <FileButton acceptedFileTypes={['application/pdf']} allowsMultiple onFiles={take}>
          Upload
        </FileButton>
        <FileButton variant="secondary" acceptedFileTypes={['image/*', '.heic']} onFiles={take}>
          Choose a photo
        </FileButton>
      </div>
      <Text size="small" tone="muted">
        {chosen.length === 0 ? 'No file chosen.' : `Chosen: ${chosen.join(', ')}`}
      </Text>
    </div>
  );
}

Usage

The library never reads or sends a file: onFiles gives you File objects and the refusals with their reasons, and your app uploads them and tells the upload tray how each is going. The check is a courtesy to the reader. Check every file again on your server, since a renamed file passes any check by its name (OWASP's File Upload Cheat Sheet).

  • acceptedFileTypes takes what an <input accept> takes: MIME types ('application/pdf'), wildcards ('image/*') and extensions ('.heic'). List an extension beside each MIME type, since systems disagree on types: Windows gives a .csv file application/vnd.ms-excel where a Mac gives text/csv. A browser that does not know a file's type gives it none, as some do for a phone's HEIC photos, and such a file is checked by its extension alone, so list .heic where you take photos.
  • allowsMultiple takes several files at once, and maxFiles caps how many one drop, paste or choice may add; without allowsMultiple it is one. Files past the cap are refused as 'count'.
  • A dropped folder is refused as 'type', carried as an empty File with the folder's name; its contents are never read.
  • A page has a page zone or inline zones, never both, and one page zone at a time: a second warns in development and stays idle.
  • While a zone is mounted, a file dropped beside it does nothing, rather than open in the tab and leave the page, and the pointer shows that it cannot drop there. That holds on a form's text field too, unless the drag also holds text or a link for the field to insert; an editor that takes files itself is left alone.
  • capture is 'environment' for the back camera and 'user' for the front. A desktop browser ignores it and opens the file picker. On iOS and Android it makes the button the camera's only, so give it its own FileButton beside the one that chooses files, and list an image type, such as 'image/*' and '.heic'.
  • The zone has no name and does not submit with a form: the files reach your app through onFiles only.
import { FileButton, FileDropZone, PageDropZone } from '@snapshot-ui/react';

<FileDropZone
  label="Receipts"
  description="PDF, JPG, PNG or HEIC, up to 10 MB"
  acceptedFileTypes={['application/pdf', '.pdf', 'image/jpeg', '.jpg', '.jpeg', 'image/png', '.png', '.heic']}
  maxFileSize={10 * 1024 * 1024}
  allowsMultiple
  onFiles={(accepted, rejected) => upload(accepted)}
/>;

// A whole page: the zone at the page's root, its keyboard twin beside the title.
function DocumentsPage() {
  return (
    <>
      <header>
        <h1>Documents</h1>
        <FileButton allowsMultiple onFiles={(accepted) => upload(accepted)}>
          Upload
        </FileButton>
      </header>
      <DocumentList />
      <PageDropZone title="Drop files to upload" allowsMultiple onFiles={(accepted) => upload(accepted)} />
    </>
  );
}

Props

FileButton accepts every Button prop but onPress.

acceptedFileTypes
readonly string[]

MIME types, wildcards and extensions, as an <input accept> takes them: 'application/pdf', 'image/*', '.heic'. Absent or empty takes any file.

allowsMultiple
boolean

Several files at once.

Default: false

aria-label
string

The zone's name where no label is shown, such as in a grid row. A name for assistive technology. Give it, or aria-labelledby, where no label or text is shown.

capture
"environment" | "user"

On a phone, opens the camera instead of the file picker: environment the back camera, user the front. A desktop browser ignores it. Handed to the zone's button, which is then the camera's only, so a phone can no longer choose a PDF: prefer a second FileButton with capture beside the zone, and list an image type.

className
string

description
ReactNode

The rules, in the app's words, under the zone: "PDF, JPG or PNG, up to 10 MB".

errorMessage
ReactNode

The error line. Replaces the library's line about refused files.

formatCount
((count: number) => string)

How counts are written, such as in Persian digits.

Default: String

isDisabled
boolean

isInvalid
boolean

Invalid by the app's word, showing errorMessage.

label
ReactNode

The field's name, above the zone.

labels
Partial<FileDropZoneLabels>

maxFiles
number

The most files one drop, paste or choice may add; the rest are refused as 'count'.

Default: 1, or no limit with `allowsMultiple`

maxFileSize
number

The largest file, in bytes.

onFiles
(accepted: File[], rejected: FileRejection[]) => void

Called once per drop, paste or choice, with the files that passed and those that did not, each in the order given. Never called with both empty.

acceptedFileTypes
readonly string[]

MIME types, wildcards and extensions, as an <input accept> takes them: 'application/pdf', 'image/*', '.heic'. Absent or empty takes any file.

allowsMultiple
boolean

Several files at once.

Default: false

capture
"environment" | "user"

On a phone, opens the camera instead of the file picker: environment the back camera, user the front. A desktop browser ignores it. A phone's button then takes photos only, so list an image type in acceptedFileTypes and keep a button without it for other files, as "Take a photo" beside "Choose a file".

children
ReactNode

The button's text: "Upload", "Choose files".

isDisabled
boolean

Whether the button is disabled.

maxFiles
number

The most files one drop, paste or choice may add; the rest are refused as 'count'.

Default: 1, or no limit with `allowsMultiple`

maxFileSize
number

The largest file, in bytes.

onFiles
(accepted: File[], rejected: FileRejection[]) => void

Called once per drop, paste or choice, with the files that passed and those that did not, each in the order given. Never called with both empty.

variant
ButtonVariant

Visual emphasis. Signal orange is deliberately not a variant: it is reserved for consent and legal actions.

Default: 'primary'

acceptedFileTypes
readonly string[]

MIME types, wildcards and extensions, as an <input accept> takes them: 'application/pdf', 'image/*', '.heic'. Absent or empty takes any file.

allowsMultiple
boolean

Several files at once.

Default: false

description
string

Where the files go, in slate: "To Documents, 2026".

isDisabled
boolean

maxFiles
number

The most files one drop, paste or choice may add; the rest are refused as 'count'.

Default: 1, or no limit with `allowsMultiple`

maxFileSize
number

The largest file, in bytes.

onFiles
(accepted: File[], rejected: FileRejection[]) => void

Called once per drop, paste or choice, with the files that passed and those that did not, each in the order given. Never called with both empty.

title
string

The overlay's line. Required: it says what a drop does. "Drop files to upload"

choose
string

The button.

Default: 'Choose files' ('Choose a file')

dragHint
string

The line beside the button where a pointer can drag.

Default: 'Drag files here or' ('Drag a file here or' without allowsMultiple)

drop
string

The line while files are over the zone.

Default: 'Drop to upload'

rejected
(count: number, formatted: string, rejected: readonly FileRejection[]) => string

The error line after a drop, paste or choice with refusals, given how many were refused, that count as formatCount writes it, and the refusals with their reasons.

Default: "1 file was not added: it is too large", or "3 files were not added" where the reasons differ

Accessibility

The zone is a group named by its label, with one tab stop: its button, "Choose files", described by your rules. The button also takes a paste, so a keyboard reader adds files from the clipboard where a pointer drops them. A click anywhere on the box opens the picker too, a larger target for the pointer that adds no tab stop.

  • Every drag has a single-pointer twin (WCAG 2.5.7): the zone's button, and a FileButton beside every page zone.
  • Refusals are said once, politely, by a status mounted with the zone, and the button is described by them while they show.
  • The page overlay is for the pointer and hidden from assistive technology.
  • A disabled zone says so to assistive technology, and its button opens nothing.
  • In forced colors the dashed edges keep the reader's text color, a disabled zone takes the reader's disabled color, and a drop target's edge turns the highlight color, solid, even on an invalid zone.
TabTo the zone's button: one stop.
EnterSpaceOn the button, opens the file picker.
Ctrl+VCmd+VWith focus on the button, adds the clipboard's files, as a drop would (the library's handler). On a page with a page zone, the same anywhere outside a text field.
EscapeDuring a drag, the browser cancels it and the overlay leaves with the drag, or at the latest when the pointer next moves (the library's handler).

Design rules

What keeps drop zones in the system.

Say the rules in the description: types, size and count.

Pair every page zone with a button beside the title.

Name each refused file and its reason in your error line.

Never hide the button on a desktop.

Never draw the drop look in signal orange or link blue.

Never put an inline zone under a page zone.

esc

↑ ↓ to move↵ to open