Gecko UIGecko UI

FileInput

A file field you can click or drop onto

FileInput

A file field. Click it to browse, or drop files onto it.

It follows the native <input type="file"> where it can: one file unless you ask for more, and a new pick replaces what was there.

Installation

import { FileInput } from '@geckoui/geckoui';

Basic Usage

nothing picked

import { useState } from 'react';
import { FileInput, type PickedFile } from '@geckoui/geckoui';

export function Attachment() {
  const [file, setFile] = useState<PickedFile | null>(null);

  return <FileInput value={file} onChange={setFile} />;
}

The value is one PickedFile, or null. The whole field is the drop target.

Props API

PropTypeDefaultDescription
valuePickedFile | null / PickedFile[]-What is held. The shape follows multiple
onChange(value) => void-Called with what is held now
multiplebooleanfalseHold a list rather than one file
appendbooleanfalseAdd to what is held instead of replacing it. multiple only
uniquebooleanfalseTurn away a file already held. multiple only
maxnumber-How many may be held. multiple only
previewbooleanfalseGive each file an object URL on preview
acceptstring"*"What may be picked, enforced on a drop too
onReject(rejected: FileRejection[]) => void-Called with everything turned away, and why
placeholderReactNode'Choose a file' / 'Choose files'Shown while empty
hideClearIconbooleanfalseDrop the × that empties the field
disabledbooleanfalseNothing can be picked or dropped
readOnlybooleanfalseShows what is held, but nothing can change
aria-invalidbooleanfalseDraws the field in its error colours
render(state) => ReactNode-Draw the field yourself
className / wrapperClassNamestring-

append, unique and max are only accepted alongside multiple — TypeScript rejects them on a single field, since none of them mean anything for one file.

The file

type PickedFile = File & { path: string };
type PreviewFile = PickedFile & { preview: string };

A real File, so it goes straight into a FormData. path says where it sat in a folder that was dropped, and is "" for a file picked on its own.

preview only exists when you asked for it — see below.

Examples

Several files

nothing picked

const [files, setFiles] = useState<PickedFile[]>([]);

<FileInput multiple value={files} onChange={setFiles} />

The field shows a count, the way a native input does. value is now a PickedFile[], and TypeScript knows it from multiple alone.

Adding rather than replacing

nothing picked

<FileInput multiple append unique value={files} onChange={setFiles} />

Without append, picking again replaces everything — that is what the native element does. With it, each pick adds to what is there.

unique then turns away a file that is already held. It compares by size first, then samples the start, middle and end, so a large file is never read end to end. It earns its keep once files can accumulate: dropping a folder and a subfolder that both contain the same file.

What was turned away

nothing picked

<FileInput
  multiple
  append
  unique
  max={3}
  accept="image/*"
  value={files}
  onChange={setFiles}
  onReject={(rejected) =>
    toast.error(rejected.map((r) => `${r.file.name} — ${r.reason}`).join('\n'))
  }
/>
type FileRejection = {
  file: File;
  reason: 'type' | 'duplicate' | 'max';
};
  • type — it did not match accept
  • duplicate — it is already held, and unique is set
  • max — there was no room: past max, or a second file dropped on a single field

Without onReject a turned-away file simply does not appear, which is the one thing a file field should never do quietly.

Previews

<FileInput multiple preview value={files} onChange={setFiles} />

{files.map((file) => (
  <img key={file.name} src={file.preview} alt={file.name} />
))}

preview is off by default. An object URL pins the file's bytes until it is revoked, so you only pay for it when you ask. The URLs are revoked for you as files leave the field and when the field unmounts.

The prop also decides the type: with preview the value is a PreviewFile and file.preview is a string; without it the value is a PickedFile and reading preview does not compile.

<FileInput render={({ file }) => file.preview} />          {/* ✗ */}
<FileInput preview render={({ file }) => file.preview} />  {/* ✓ */}

Which means the state holding the value follows preview too:

const [file, setFile] = useState<PickedFile | null>(null);   // no preview
const [file, setFile] = useState<PreviewFile | null>(null);  // preview

Adding or removing preview without changing the state is a type error, which is the point — the field would otherwise hand you a file with no preview while the state says there is one.

States

<FileInput placeholder="Attach your CV" />
<FileInput aria-invalid />
<FileInput disabled />
<FileInput hideClearIcon />

readOnly shows what is held but takes away browsing, dropping and clearing. disabled does the same and takes the field out of the tab order.

Drawing it yourself

0 picked — click or drop

<FileInput
  multiple
  preview
  accept="image/*"
  value={files}
  onChange={setFiles}
  render={({ files, dragging, loading, browse, clear, remove }) => (
    <div className={dragging ? 'ring-2' : ''}>
      <p>{files.length} picked</p>

      {files.map((file) => (
        <img key={file.name} src={file.preview} alt={file.name} />
      ))}
    </div>
  )}
/>

render draws inside the field, so clicking to browse, dropping, the drag state and the keyboard all stay with the component. You are handing over what it looks like, not how it works.

What it is given follows the props, so nothing needs a cast:

single{ file, dragging, loading, disabled, readOnly, browse, clear }
multiple{ files, remove, ... } — the same, with the list and a way to drop one

file and files are PreviewFile when preview is set and PickedFile otherwise, so file.preview only compiles when you asked for it.

An avatar picker

The field is whatever you draw, so a round photo picker is the same component with a different render.

No photoChange

Click the circle, or drop an image on it.

const [avatar, setAvatar] = useState<PickedFile | null>(null);

<FileInput
  accept="image/*"
  preview
  value={avatar}
  onChange={setAvatar}
  render={({ file, dragging }) => {
    return (
      <span
        className="group relative block size-24 cursor-pointer overflow-hidden rounded-full"
        style={{
          boxShadow: `0 0 0 2px ${dragging ? '#3b82f6' : '#e2e8f0'}`
        }}>
        {file ? (
          <img src={file.preview} alt={file.name} className="h-full w-full object-cover" />
        ) : (
          <span className="flex h-full w-full items-center justify-center text-xs">
            No photo
          </span>
        )}

        <span className="absolute inset-0 flex items-center justify-center bg-black/50 text-xs text-white opacity-0 group-hover:opacity-100">
          {dragging ? 'Drop' : 'Change'}
        </span>
      </span>
    );
  }}
/>

No multiple, so render is handed file rather than a list, and dropping a second image replaces the first. preview is what types file.preview — without it, reading that is a compile error rather than undefined at runtime.

Keyboard

KeyDoes
TabReaches the field
Enter SpaceOpens the file dialog

Styling with CSS

.GeckoUIFileInput              [data-state] [data-dragging] [data-empty] [data-custom]
.GeckoUIFileInput__trigger     [aria-invalid]
.GeckoUIFileInput__value
.GeckoUIFileInput__placeholder
.GeckoUIFileInput__icons
.GeckoUIFileInput__clear
.GeckoUIFileInput__icon

It takes the same border, radius and state colours as the other fields, so it sits beside a DateInput or an Input without looking foreign. data-dragging is on while a file is over it. A field drawn with render carries data-custom and gives up the box around it.

Accessibility

The field is a button, so it reaches the tab order and opens on Enter or Space. It carries aria-disabled and aria-readonly rather than being removed from the page, and the clear button is labelled.