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

@rxova/react-file-input

v1.1.1

Published

A file picker and drop zone that validates, deduplicates and revokes its own preview URLs — and never uploads anything. Headless, zero-dependency.

Readme

A file picker and drop zone that validates, deduplicates and revokes its own preview URLs — and never uploads anything. Headless, zero-dependency.

npm install @rxova/react-file-input

📖 Documentation & live examples → — guides, form recipes, theming, the object-URL lifecycle, and migration from another upload library.

  • 3.6 kB brotli for the whole component, 2.5 kB for the headless hook. No runtime dependencies.
  • Keyboard-first. The drop zone is a real <button>, so Enter and Space open the picker. Dragging has no keyboard equivalent, so the click path is the accessible path.
  • Object URLs are managed for you — created lazily, revoked the moment a file is removed and again on unmount. This is the one thing every alternative leaves to the caller.
  • Per-file rejection reasons: type, too-large, too-small, duplicate, max-files, invalid — with a sentence you can render as-is.
  • No network. The library never issues a request; uploading is yours to do, with your auth, your retries and your progress.
  • Adversarial suite included, and E2E on Chromium, Firefox and WebKit.
  • onWarn for a logger of your choice, stripped from production builds.

Basic use

import { useState } from "react";
import { FileInput, describeRejection } from "@rxova/react-file-input";

function Attachments() {
  const [files, setFiles] = useState<File[]>([]);
  return (
    <FileInput
      label="Attachments"
      name="attachments"
      multiple
      accept=".pdf,image/*"
      maxSize={5_000_000}
      maxFiles={5}
      previews
      value={files}
      onChange={setFiles}
      onReject={(attempt) => {
        toast(describeRejection(attempt, { maxSize: 5_000_000 }));
      }}
    />
  );
}

Uncontrolled works too — omit value/onChange and pass defaultValue.

Rules

| Prop | What it does | | --------------------- | ------------------------------------------------------------------------------------------------------------------------ | | accept | Exactly the native accept grammar: .ext, type/sub, type/*. Lenient when the browser reports no MIME type at all. | | maxSize / minSize | Bytes. An inverted range is ignored rather than enforced (and warned about). | | maxFiles | Ignored unless multiple. A single-file field is always capped at one. | | dedupe | On by default. Identity is name + size + last-modified, like the native control. | | validate | Final say. Return true, false, or a string that becomes the rejection message. |

Every refusal reaches onReject with the file and a machine-readable reason, one call per file, so a selection of five where two fail still adds the other three. The argument is a FileRejected, so reason is always set; attempt() and attemptAll() return FileAccepted | FileRejected, which narrows on accepted.

Previews

previews opts into the object-URL lifecycle:

<FileInput label="Photos" multiple previews accept="image/*" />

URLs are minted only for images, only on the client, and revoked as soon as the file leaves the list — add and remove ten 5 MB photos and nothing is retained. It is off by default because a URL nobody revokes is a memory leak, and the caller should opt into the lifecycle deliberately.

Forms

The underlying input keeps its value, so a plain <form> submit posts the file with no JavaScript involved. It holds only files the field is showing: a refused file, a removed one, or one a controlled parent never accepted is dropped from it, so picking that same file again works. In multiple mode the native control can only carry the last selection — read value/onChange if you accumulate across several picks.

Styling

There is no stylesheet to import. Only layout-critical declarations are inlined; everything visual is a CSS custom property or a data-* hook.

| Property | Default | Applies to | | --------------------------- | ------------------------- | ----------------------------- | | --rx-file-gap | 0.5rem | Between the zone and the list | | --rx-file-zone-gap | 0.5rem | Inside the drop zone | | --rx-file-zone-padding | 1rem | Drop zone padding | | --rx-file-zone-border | 1px dashed currentColor | Drop zone border | | --rx-file-zone-radius | 0.375rem | Drop zone corners | | --rx-file-zone-background | transparent | Drop zone background | | --rx-file-list-gap | 0.25rem | Space between files | | --rx-file-row-gap | 0.5rem | Space inside one row | | --rx-file-preview-size | 2.5rem | Thumbnail size | | --rx-file-preview-radius | 0.25rem | Thumbnail corners | | --rx-file-remove-size | 1.5rem | Remove button hit area |

Do not shrink --rx-file-remove-size below 1.5rem: at the default font size that is the 24×24 CSS pixels WCAG 2.5.8 Target Size (Minimum) requires.

data-* attributes

These are public API, covered by semver.

| Attribute | On | Meaning | | -------------------------------------------------- | --------------------- | --------------------------------- | | data-rx-file-root | wrapper | Always present | | data-dragging | wrapper | A drag is over the zone | | data-count / data-full | wrapper | File count, and maxFiles hit | | data-invalid / data-disabled / data-readonly | wrapper | Mirrors 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-size | row | Name 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-announcement | live region | Off-screen, aria-live="polite" |

renderFile replaces a whole row when the default one is not what you want; the remove button stays.

Headless

useFileInput gives you the state and the handlers with no markup at all:

import { useFileInput } from "@rxova/react-file-input";

function CustomDropZone() {
  const field = useFileInput({ multiple: true, previews: true, maxSize: 1_000_000 });

  return (
    <div
      ref={field.zoneRef}
      onDragEnter={field.handleDragEnter}
      onDragOver={field.handleDragOver}
      onDragLeave={field.handleDragLeave}
      onDrop={field.handleDrop}
      data-dragging={field.dragging || undefined}
    >
      <input type="file" ref={field.inputRef} multiple onChange={field.handleInputChange} />
      <button type="button" onClick={field.open}>
        Choose files
      </button>
      {field.entries.map((entry, index) => (
        <button
          key={entry.key}
          type="button"
          aria-label={`Remove ${entry.file.name}`}
          onClick={() => {
            field.removeAt(index);
          }}
        >
          <img src={entry.preview} alt="" />
          {field.sizeOf(entry.file)}
        </button>
      ))}
    </div>
  );
}

The fiddly parts — the drag-depth counter, the URL revocation, the focus handoff after a removal — live in the hook, not the component.

Diagnostics

<FileInput label="Files" onWarn={(w) => Sentry.captureMessage(w.message, { extra: w })} />

Fires when a prop is coerced rather than honoured — max-files-invalid, size-range-invalid, negative-size, accept-suspicious, single-with-max. Deduped per instance, console.warn when no handler is given, and the whole path is removed from production builds (there is an E2E test asserting exactly that against the real production bundle).

Accessibility

  • The real <input type="file"> stays in the accessibility tree — visually hidden by clipping, never display: none, which would remove it from the tree and break .click() in some browsers.
  • The hidden input is tabIndex={-1}, so the field has one tab stop: the drop zone. A Tab never lands somewhere nobody can see.
  • The drop zone is a <button type="button"> with an explicit tabIndex={0}, because WebKit leaves buttons out of the tab order without Full Keyboard Access.
  • The drop zone is named after the field, then its hint — "Attachments Choose a file or drop it here" — and shares aria-describedby. Pass label even beside a visible <label htmlFor>, which names only the input.
  • Each remove button is named after its own file, not just "Remove" — a screen reader's element list shows them stripped of their row.
  • After a removal, focus moves to the next file's button, or the previous one, or back to the drop zone. It never falls to <body>.
  • Additions, removals and refusals are announced once per batch in a polite live region.
  • Preview images are alt="": the filename is right beside them.

Verified with axe-core at component level and @axe-core/playwright over the whole demo page, on all three engines.

UI-library recipes

The docs contain maintained examples for shadcn/ui, Radix Themes, Material UI, Chakra UI, Mantine and Ant Design. Copy a ready-labelled wrapper with:

npx shadcn@latest add https://rxova.dev/packages/react-inputs/r/file-field.json

Open the recipes.

Part of rxova

Part of the rxova headless React inputs suite — install the whole set from @rxova/react-inputs, or read the full generated API reference for this package.

Cross-cutting guidance lives on this component's About page: styling and form libraries. Coming from another library? The migration guide maps the props across.

License

MIT © rxova