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
| Prop | Type | Default | Description |
|---|---|---|---|
value | PickedFile | null / PickedFile[] | - | What is held. The shape follows multiple |
onChange | (value) => void | - | Called with what is held now |
multiple | boolean | false | Hold a list rather than one file |
append | boolean | false | Add to what is held instead of replacing it. multiple only |
unique | boolean | false | Turn away a file already held. multiple only |
max | number | - | How many may be held. multiple only |
preview | boolean | false | Give each file an object URL on preview |
accept | string | "*" | What may be picked, enforced on a drop too |
onReject | (rejected: FileRejection[]) => void | - | Called with everything turned away, and why |
placeholder | ReactNode | 'Choose a file' / 'Choose files' | Shown while empty |
hideClearIcon | boolean | false | Drop the × that empties the field |
disabled | boolean | false | Nothing can be picked or dropped |
readOnly | boolean | false | Shows what is held, but nothing can change |
aria-invalid | boolean | false | Draws the field in its error colours |
render | (state) => ReactNode | - | Draw the field yourself |
className / wrapperClassName | string | - |
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 matchacceptduplicate— it is already held, anduniqueis setmax— there was no room: pastmax, 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); // previewAdding 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.
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
| Key | Does |
|---|---|
| Tab | Reaches the field |
| Enter Space | Opens 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__iconIt 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.
Related Components
- RHFFileInput - The same field, wired to React Hook Form
- Input - For text