Skip to content

FilePicker

A dashed box you drop files on, or press to open the file dialog.

tsx
import { FilePicker } from 'neba';

<FilePicker
  multiple
  label="Attachments"
  accept="image/*,.pdf"
  maxSize={5_000_000}
  maxFiles={4}
  onFilesChange={setFiles}
  onReject={setRejected}
/>;

Props

PropTypeDefaultDescription
variantshared'solid' | 'outline' | 'text''outline'Weight of the surface. All three share the dashed edge — the one place the library draws a line that is not solid, because a dashed rectangle is the established sign for a drop target
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'The box's padding and type scale. A dropzone is sized by the gesture it has to catch, not by what is written in it
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. Arbitrary colour values are not accepted
densityshared'default' | 'compact''default'Padding only — never the height, never the type scale
elevationshared0 | 1 | 2 | 30Drop shadow depth. 0 means no shadow at all
acceptstringWhich files the browser's own picker offers — 'image/*,.pdf'. Dropped files are checked against it too, which the attribute alone does not do
multiplebooleanfalseWhether more than one file may be chosen
maxSizenumberThe largest a single file may be, in bytes
maxFilesnumberHow many files may be held at once — counted against what is already chosen, not against one drop
valuereadonly File[]The chosen files, for a controlled picker
defaultValuereadonly File[]The initially chosen files
onFilesChange(files: File[]) => voidCalled when the list of files changes
onReject(rejections: FileRejection[]) => voidCalled with everything turned away, and why. Without it a rejected file disappears silently, which is the worst thing a dropzone does
titleReactNodeThe line inside the box
hintReactNodeThe line under it — what is accepted, how big, how many
iconReactNodeThe glyph above the title. Pass null for a box with no picture in it
showListbooleantrueLists the chosen files under the box, each with a way to remove it
removeLabel(name: string) => stringAccessible name of a file's remove button
labelReactNodeThe label, wired to the control by Base UI's Field
descriptionReactNodeHelper text
errorReactNodeError message. Its presence turns the control invalid and re-points the colour family at danger
invalidboolean!!errorForces the invalid state without a message, for when a form library owns validity
readOnlybooleanfalseShown but not changeable. Keeps its colour and edge, drains the saturation
disabledbooleanfalseUnavailable. Drops the colour family for neutral grey
fullWidthbooleantrueStretches to the width of the container
requiredbooleanfalseWhether a file must be chosen before the form is submitted
namestringIdentifies the field when a form is submitted

Examples

Variants

All three share the dashed edge. It is the one place in the library that draws a line which is not solid, and it is not decoration — a dashed rectangle is the established sign for "this area accepts a drop", and a dropzone that looks like a card is a card nobody tries to drop on.

What it turns away

States

The browser does not apply accept to a dropped file

The accept attribute governs the browser's own file dialog and nothing else. A file that arrived by drag has never been checked against that string. A dropzone that only sets the attribute accepts anything the moment a file comes in by drop.

This component runs the check itself, against the same string, in the three forms the attribute takes: .ext, type/subtype, and type/*.

A drag state that does not flicker

dragenter and dragleave fire for every child of the zone the pointer crosses. A dropzone that toggles a boolean therefore flickers the entire time a file is being dragged over its own contents. Counting the events is the fix, and the only thing that survives a zone with content in it.

maxFiles is not "you may drop this many"

It is "you may end up with this many". It is checked against what is already held, so dropping three files on a maxFiles={3} picker that holds two accepts exactly one of them.

Why there is no Base UI primitive

A dropzone is a <div> listening for four drag events and an <input type="file"> it clicks for you. There is no popup to position, no focus to trap and no roving anything. The fallback the design language allows — plain React and DOM — is the right one here.

What is left is the part hand-rolled dropzones usually get wrong, which is the three sections above.

Accessibility

The shell is a <div> and the pressable area inside it is a real <button>. The file list sits outside that button, because the remove buttons cannot be nested inside the browse button — the same shape Chip and ListItem use.

The real <input type="file"> is moved off-screen rather than hidden. display: none and visibility: hidden both make an input unfocusable in some browsers, and this one still has to be reachable to a form and to a required validation message.

Pass onReject. Without it a rejected file disappears without a word.

Released under the MIT License