Rxova
Skip to content

File input — About

This is the part that is usually left to the caller, and the reason the package exists.

URL.createObjectURL(file) hands back a string that keeps the entire file resident in memory until URL.revokeObjectURL is called or the document is discarded. A gallery where the user adds and removes ten 5 MB photos while deciding which to keep has leaked 50 MB by the time they submit.

previews owns that lifecycle:

  • URLs are minted lazily, only for files whose type starts with image/.
  • They are revoked the instant a file leaves the list, not deferred to unmount — which is what makes add-then-remove cycles safe.
  • They are revoked again on unmount, for whatever is still selected.
  • They are never minted on the server: there is no unmount during server rendering, so such a URL could never be revoked.

The URL map is mutated, never replaced. Swapping in a fresh map was a real bug caught by the adversarial suite — the unmount cleanup captures the map once, so reassigning it left that cleanup holding an empty one and every URL alive for the lifetime of the document, the exact leak the code exists to prevent. A test now asserts revokeObjectURL was called exactly as many times as createObjectURL, and an end-to-end test proves a revoked URL no longer resolves.

Previews are off by default for the same reason: opting into a lifecycle should be deliberate.

The drop zone is a real <button type="button">. Dragging has no keyboard equivalent, so the click path is the only accessible path — and a button supplies Enter, Space, focus and a role for free. It also carries an explicit tabIndex={0}, because WebKit omits buttons from sequential navigation unless Full Keyboard Access is enabled.

The real <input type="file"> stays in the accessibility tree. It is hidden by clipping, never display: none or the hidden attribute, both of which remove it from the tree entirely and, in some browsers, stop programmatic .click() from opening the picker.

Focus never lands on <body>. The button you just clicked is about to leave the DOM. After a removal, focus goes to the next file’s button, or the previous one if that was the last, or back to the drop zone if the list is now empty.

Remove buttons are named after their fileRemove invoice.pdf, not Remove. A screen reader’s element list shows buttons stripped of their surrounding row, so five identical “Remove” entries are unusable. Override with removeLabel.

The list is a <ul>, so a screen reader announces “list, 3 items” before reading them.

Preview images are alt="". The filename sits right beside them; alt text would be read twice.

One polite announcement per batch. Attaching four files says so once, not four times, and aria-live="polite" never interrupts what the user is already hearing — attaching a file is not an emergency.

Verified with axe-core at the component level (empty, populated, with previews, invalid, disabled, read-only, right-to-left) and with @axe-core/playwright over the whole demo page on Chromium, Firefox and WebKit.

Two files are the same when their name, size and last-modified timestamp match — the same test the native control applies. Content hashing would be strictly more correct and would also mean reading every byte of every file the user hovers over.

That definition has one consequence worth knowing: a file the user edits between two picks is a different file, which is almost always what you want.

Every refusal produces one onReject call with the file and a reason:

ReasonCause
typeFailed the accept string
too-largeAbove maxSize
too-smallBelow minSize
duplicateAlready in the list, under the identity rule above
max-filesThe count cap is reached
invalidvalidate returned false or a string

A batch is evaluated file by file, so five picked with two refused adds the other three. A throwing validate refuses that one file and warns — it never unmounts the form.

Unusable bounds are dropped rather than enforced: maxFiles: 0, a negative minSize, a minSize above maxSize. A field that can never accept anything is a bug in the calling code, not a state worth rendering, and onWarn says so.

dragleave fires every time the pointer crosses into a child of the drop zone, so the naive onDragLeave → setDragging(false) flickers the highlight off and on as the user moves over the hint text or an existing file row. A depth counter is the only thing that survives nesting.

Only dragenter and dragleave move that counter. dragover repeats for as long as the pointer hovers — every few hundred milliseconds and on every pointer move — so counting it too makes the depth climb without bound, and the one matching dragleave can never bring it back to zero: the zone stays lit for the life of the page after a drag that leaves without dropping. dragover still has to be prevented on every tick, because that is what makes the browser fire drop at all, and it still lights the zone, since dragging in from outside the viewport does not always produce a dragenter the zone sees.

Binding your own zone therefore means binding onDragEnter={field.handleDragEnter} and onDragOver={field.handleDragOver} — they are different handlers on purpose.

A drag carrying only text never lights the zone up at all — the handlers check that Files is among the transfer’s types before reacting, and a drop with no files is left to the browser rather than consumed.

The underlying input keeps its value, so a plain <form> submit posts the file with no JavaScript involved. The value is cleared on removal and on clear() — not after every pick, which is the usual trick for “let the user re-pick the same file” but also empties the control a submit would post, leaving the field rendering a file the server never receives.

In multiple mode the native control can only carry the last selection. Read value/onChange if you accumulate across several picks.

  • 138 unit and browser tests, coverage enforced per file at 95% (99.7% statements, 99.3% branches, 100% functions and lines).
  • An adversarial suite that drives the failure modes on purpose: throwing callbacks, unmounting mid-drag, duplicate picks, the URL leak, two fields on one page, a dragleave from a child.
  • 28 end-to-end specs on Chromium, Firefox and WebKit against a production demo build — one of which asserts the diagnostics path is absent from that bundle.
  • Where an engine cannot express something (synthesising a file DataTransfer), the test skips visibly rather than passing quietly.

onWarn reports a prop the component could not use as given — a maxFiles below one, a minSize above maxSize, an accept string that will match nothing. The coerced result is what renders, so this is never an error, and the whole path is stripped from production builds.

<FileInput
label="Documents"
accept="png"
onWarn={(warning) => {
// { code: 'accept-suspicious', prop: 'accept', received: 'png', message: '…' }
console.warn(warning.code, warning.received)
}}
/>

Codes: max-files-invalid, size-range-invalid, negative-size, accept-suspicious, single-with-max. The accept one is worth knowing about — png without its dot, or a wildcard like image/*.png, silently matches no file at all, which reads as a broken field rather than a typo in a prop.

Warnings are deduplicated per instance on code:received, so a re-rendering parent reports once per distinct mistake rather than once per render.

There is no stylesheet to import.

Set them on [data-rx-file-root] or any ancestor:

[data-rx-file-root] {
--rx-file-gap: 0.5rem;
--rx-file-zone-gap: 0.5rem;
--rx-file-zone-padding: 1rem;
--rx-file-zone-border: 1px dashed currentColor;
--rx-file-zone-radius: 0.375rem;
--rx-file-zone-background: transparent;
--rx-file-list-gap: 0.25rem;
--rx-file-row-gap: 0.5rem;
--rx-file-preview-size: 2.5rem;
--rx-file-preview-radius: 0.25rem;
--rx-file-remove-size: 1.5rem;
}

--rx-file-list-gap is the space between files and --rx-file-row-gap the space inside one row. Keep --rx-file-remove-size at 1.5rem or above for WCAG 2.5.8 Target Size (Minimum).

These are public API, covered by semver.

AttributeOnMeaning
data-rx-file-rootwrapperAlways present
data-draggingwrapperA drag is over the zone
data-countwrapperNumber of files
data-fullwrappermaxFiles reached
data-invalid / data-disabled / data-readonlywrapperMirrors the props
data-rx-file-input<input type="file">The real control, visually hidden
data-rx-file-zone<button>The drop zone
data-rx-file-list<ul>The selection
data-rx-file-file<li>One file row
data-rx-file-name / data-rx-file-sizerowName and human-readable size
data-rx-file-preview<img>Only when previews is on
data-rx-file-remove<button>The remove button
data-rx-file-announcementlive regionOff-screen, aria-live="polite"

Every recipe below is transcribed from src/__tests__/form.browser.test.tsx, so it is code that runs on every commit. The value is a File[]. Nothing here uploads: you get the File objects and the transport stays yours.

With a name, the real <input type="file"> posts itself — which is why it is visually hidden rather than absent:

<form action="/upload" method="post" encType="multipart/form-data">
<FileInput name="docs" label="Documents" multiple />
<button type="submit">Upload</button>
</form>

formData.getAll('docs') returns the real File objects.

onChange emits the whole array, so field.onChange binds directly:

<Controller
name="docs"
control={control}
render={({ field }) => (
<FileInput
label="File"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
function Field() {
const [field, , helpers] = useField<File[]>('docs')
return (
<FileInput
label="File"
name="docs"
value={field.value}
onChange={(files) => helpers.setValue(files)}
onBlur={() => helpers.setTouched(true)}
/>
)
}

onBlur fires when focus leaves the whole field, not when it moves to the drop zone or a remove button — so curating the selection does not mark the field touched.

<Field name="docs">
{({ input }) => (
<FileInput
label="File"
name={input.name}
value={Array.isArray(input.value) ? input.value : []}
onChange={input.onChange}
onBlur={input.onBlur}
/>
)}
</Field>
<form.Field name="docs">
{(field) => (
<FileInput
label="File"
name="docs"
value={field.state.value}
onChange={(value) => field.handleChange(value)}
onBlur={field.handleBlur}
/>
)}
</form.Field>

React Final Form starts a field as '', which is not an array. The guard above is the binding’s job, not the component’s.

Keep the real file input, drop-zone button, selection list, and live announcements together. The design system supplies field chrome; upload transport remains outside this package.

shadcn/ui

        'use client'

import { FileField } from '@/components/rxova/file-field'

export function ShadcnFile() {
  return (
    <FileField
      label="Attachments"
      description="Choose one or more files."
      name="attachments"
      multiple
    />
  )
}
      

Radix Themes

        'use client'

import { Box, Text } from '@radix-ui/themes'
import { FileInput } from '@rxova/react-file-input'

export function RadixFile() {
  return (
    <Box>
      <Text as="div" size="2" weight="bold" mb="1">
        Attachments
      </Text>
      <FileInput label="Attachments" name="attachments" multiple />
      <Text as="div" size="1" color="gray" mt="1">
        Choose one or more files.
      </Text>
    </Box>
  )
}
      

Material UI

        'use client'

import FormControl from '@mui/material/FormControl'
import FormHelperText from '@mui/material/FormHelperText'
import FormLabel from '@mui/material/FormLabel'
import { FileInput } from '@rxova/react-file-input'

export function MuiFile() {
  return (
    <FormControl>
      <FormLabel>Attachments</FormLabel>
      <FileInput label="Attachments" name="attachments" multiple />
      <FormHelperText>Choose one or more files.</FormHelperText>
    </FormControl>
  )
}
      

Chakra UI

        'use client'

import { Field } from '@chakra-ui/react'
import { FileInput } from '@rxova/react-file-input'

export function ChakraFile() {
  return (
    <Field.Root>
      <Field.Label>Attachments</Field.Label>
      <FileInput label="Attachments" name="attachments" multiple />
      <Field.HelperText>Choose one or more files.</Field.HelperText>
    </Field.Root>
  )
}
      

Mantine

        'use client'

import { Input } from '@mantine/core'
import { FileInput } from '@rxova/react-file-input'

export function MantineFile() {
  return (
    <Input.Wrapper label="Attachments" description="Choose one or more files.">
      <FileInput label="Attachments" name="attachments" multiple />
    </Input.Wrapper>
  )
}
      

Ant Design

        'use client'

import { Form } from 'antd'
import { FileInput } from '@rxova/react-file-input'

export function AntFile() {
  return (
    <Form.Item label="Attachments" extra="Choose one or more files.">
      <FileInput label="Attachments" name="attachments" multiple />
    </Form.Item>
  )
}