File input
Pick files by click or drop, validate them, show what was picked. An input, not an uploader.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-file-input.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-file-input.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-file-input.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-file-input.jsonAlmost every app grows one, and it is almost always the same hand-rolled shape: a <div> with an onclick over a hidden <input type="file">, a MIME check that turns away valid files, and a list
that grows a second row when the same file is dropped twice. This is that input with those fixed.
<script lang="ts">
import FileInput from '#lib/components/coral/kit/file-input/file-input.svelte';
let files = $state<File[]>([]);
</script>
<div class="w-full max-w-md">
<FileInput bind:value={files} />
</div>What Coral adds
- The keyboard can open it. Putting
onclickon a<div>and hiding the input withhiddenremoves it from the accessibility tree, and the result cannot be operated without a mouse. Here the input is real and the zone is its<label>. - Drag and drop that actually works, and stays highlighted while the pointer moves over the contents of the zone.
acceptthat does not reject valid files. The bug below.- One selection, one source of truth:
valueis the state, not a mirror of some private copy. - Sizes go through
Intl, so the decimal separator follows the locale:1,5 MBwhere that is how the number is written, rather than a hardcoded1.5 MB. - The same file twice is once. A hand-rolled input appends blindly, so dropping the same file again puts two identical rows on screen and posts it twice.
- Images show what they are. An image file is previewed from an object URL, and that URL is released when the file leaves the selection - otherwise the bytes behind it stay in memory for as long as the page is open.
The rows
Each held file is an attachment, in the idle state: picked, not uploaded. Images get a thumbnail
and everything else a file icon, with the name, the size and a remove button that is named after the
file rather than just "Remove".
idle is the honest state for a picker, and the remaining ones - uploading, processing, error, done - are what an uploader drives through the file snippet below.
An input, not an uploader
Coral stops at File[]. There is no progress, no retry, no remote URL, no queue. All of that needs
to know where the bytes are going, and Coral does not.
The boundary is easy to cross by accident. A file picker that wants to look like an uploader ends up rendering a progress bar driven by this:
// Don't. This measures nothing.
$effect(() => {
if (files.length > 0 && uploadProgress < 100) {
const timer = setTimeout(() => (uploadProgress = Math.min(uploadProgress + 10, 100)), 80);
return () => clearTimeout(timer);
}
});A bar that fills at a fixed rate the moment a file is chosen, with no request behind it: the appearance of an uploader, which is all a component without an API can offer.
The real seam is the file snippet: Coral holds the selection and renders the zone, the project
renders each row with whatever its own uploader knows - including the attachment states the default
row deliberately leaves alone.
<script lang="ts">
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import XIcon from '@lucide/svelte/icons/x';
import FileInput from '#lib/components/coral/kit/file-input/file-input.svelte';
import { formatBytes } from '#lib/components/coral/kit/file-input/format-bytes.js';
import * as Attachment from '#lib/components/ui/attachment/index.js';
import { Spinner } from '#lib/components/ui/spinner/index.js';
let files = $state<File[]>([]);
// Stands in for whatever the project's uploader reports. Coral holds the files and knows
// nothing about where they go, so this state lives here rather than in the component.
const progressFor = (file: File) => Math.min(100, Math.round((file.size % 90) + 10));
const stateFor = (file: File) =>
progressFor(file) === 100 ? 'done' : file.name.endsWith('.log') ? 'error' : 'uploading';
</script>
<div class="w-full max-w-md">
<FileInput bind:value={files} multiple label="Attach screenshots or log files">
{#snippet file({ file, remove })}
{@const state = stateFor(file)}
<Attachment.Root {state} class="w-full">
<Attachment.Media>
{#if state === 'uploading'}
<Spinner />
{:else}
<XIcon />
{/if}
</Attachment.Media>
<Attachment.Content>
<Attachment.Title>{file.name}</Attachment.Title>
<Attachment.Description>
{#if state === 'error'}
Upload failed. Try again.
{:else if state === 'done'}
Uploaded · {formatBytes(file.size)}
{:else}
Uploading · {progressFor(file)}%
{/if}
</Attachment.Description>
</Attachment.Content>
<Attachment.Actions>
{#if state === 'error'}
<Attachment.Action aria-label="Retry {file.name}">
<RefreshCwIcon />
</Attachment.Action>
{/if}
<Attachment.Action aria-label="Remove {file.name}" onclick={remove}>
<XIcon />
</Attachment.Action>
</Attachment.Actions>
</Attachment.Root>
{/snippet}
</FileInput>
</div>The accept bug
A pure MIME check looks obviously right and is wrong:
// Rejects clip.mov, which the user can plainly see is a video.
accept.split(',').some((type) => type === file.type || file.type.startsWith(category));Browsers report an empty file.type for plenty of ordinary files: .mov, .avi, .m4v, .mkv, notably on Windows, in installed PWAs, and on iOS. The usual patch is a hardcoded table of
video and image extensions, which is wrong again the first time a format is missing from it.
Coral needs no table. A file the browser refuses to type can only be judged by its extension:
accept | file | file.type | Result |
|---|---|---|---|
video/* | clip.mov | '' | accepted - nothing to check the MIME entry with |
.pdf,image/* | clip.mov | '' | rejected - extensions were listed, none matched |
.mov,video/mp4 | clip.mov | '' | accepted - the extension matches |
image/* | photo.png | image/png | accepted |
.pdf | report.pdf | anything | accepted - extension entries are read at all, which a MIME check alone skips |
The server is the real gate either way; the point is not to turn away a file the person is looking at.
Constraints and rejections
<script lang="ts">
import FileInput from '#lib/components/coral/kit/file-input/file-input.svelte';
import type { FileRejection } from '#lib/components/coral/kit/file-input/types.js';
let files = $state<File[]>([]);
let problems = $state<string[]>([]);
// The message is the project's, in the project's language. Coral only says what happened.
const reasons: Record<FileRejection['reason'], string> = {
type: 'is not an allowed type',
size: 'is over 1 MB',
count: 'is extra: already have 3'
};
function onreject(rejections: FileRejection[]) {
problems = rejections.map(({ file, reason }) => `${file.name} ${reasons[reason]}`);
}
</script>
<div class="flex w-full max-w-md flex-col gap-3">
<FileInput
bind:value={files}
multiple
maxFiles={3}
maxSize={1024 * 1024}
accept="image/*,.pdf"
{onreject}
onchange={() => (problems = [])}
/>
{#each problems as problem (problem)}
<p class="text-sm text-destructive">{problem}</p>
{/each}
</div>accept, maxSize and maxFiles filter what gets in. What gets turned away goes to onreject, as
structured data:
type FileRejection = {
file: File;
reason: 'type' | 'size' | 'count';
};Coral renders no error text. The message is copy, copy belongs to the project, and so does where it goes: inline under the field, or a toast. Coral says what happened and stays out of it.
The consequence to respect: set a constraint, wire onreject, or files are dropped in silence.
Every rejection is reported, not just the first. Keeping only errors[0] and discarding the rest
means dropping five oversized files explains one of them.
multiple and maxFiles
One number, not two. multiple opens the door, maxFiles says how many fit, and without multiple the limit is 1 whatever maxFiles says. A single-file input replaces what it holds rather than
refusing the new file.
The shape to avoid is carrying both a variant: 'single' | 'multiple' and a maxFiles, defaulted
to 'single' and 5: two props that contradict each other before anyone touches them.
Accessibility
The zone is a <label> wrapping a real <input type="file"> that is sr-only rather than hidden.
That single change is the difference between a picker the keyboard can open and one it cannot:
- The click is native. No
onclick, no.click()call, noa11y_click_events_have_key_eventssuppression, which a clickable<div>needs just to compile. - Tab reaches the input; the zone shows the focus ring through
has-[input:focus-visible]. - Space and Enter open the picker, because that is what a focused file input does.
Every prop a native file input takes is forwarded to it: id, required, capture, aria-*, so
a surrounding Field labels it the usual way.
The held files are a real list, one <li> per file, so their number is announced before they are
read. An image preview carries an empty alt: the file's name is already the row's title, and
repeating it makes a screen reader say it twice.
Import
<script lang="ts">
import FileInput from '#lib/components/coral/kit/file-input/file-input.svelte';
import type { FileRejection } from '#lib/components/coral/kit/file-input/types.js';
</script>Props
Everything a native file input accepts is forwarded to it. On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
value | File[] | [] | Bindable. The selection. |
onchange | (files: File[]) => void | - | Pick, drop or removal. Never on mount. |
onreject | (rejections: FileRejection[]) => void | - | What was turned away, and why. |
accept | string | '' | image/*,.pdf. Empty takes anything. |
multiple | boolean | false | Allows more than one file. |
maxFiles | number | unbounded | Only read when multiple. |
maxSize | number | unbounded | Bytes, per file. |
disabled | boolean | false | Blocks the zone and the remove buttons. |
label | string | Drop files here, or click to browse | The line inside the zone. |
hint | string | from accept + maxSize | The line under it. '' renders none. |
removeLabel | (file: File) => string | Remove <name> | Accessible label for a remove button. |
class | string | - | Merged onto the zone - height, padding. |
listClass | string | - | Merged onto the file list. |
zone | Snippet<[{ dragging, disabled, hint }]> | - | Replaces the contents of the zone. |
file | Snippet<[{ file, index, remove }]> | - | Replaces each row. The uploader seam. |
hint defaults to a summary built from the constraints: PDF, IMAGE · 1 MB. It is deliberately
wordless: extensions and categories reduce to bare tokens, so nothing in it needs translating.
Forms
The field underneath holds what has been picked, so it takes part in a form like any other: name submits the files, required makes the browser hold the form up until there is one, and a reset
empties it.
<form method="POST" enctype="multipart/form-data">
<FileInput name="attachments" multiple required bind:value={files} />
</form>value and the field are kept in step in both directions. The field's own files is written from value - so a removal, a rejected pick and a drop all leave it holding exactly the selection -
and writing it fires no event, so it never reaches onchange. It is also why a file can be picked
again after it was removed: the field is emptied with the selection, and a dialog that returns
exactly what the field already holds fires no change.
Resetting the form is the reader emptying it, so value is emptied and onchange is called with [].
Code that uploads through fetch can still build its own request from value:
const body = new FormData();
for (const file of files) body.append('attachments', file);