registry:ui · Data Entry
file-input — a picker that reads like one
2026-08-23 redesign: the old trigger was indistinguishable from a button and long filenames broke layouts. The anatomy is borrowed from ant-design Upload with LOCAL-picker semantics — no network, no fake upload progress: a dedicated drop zone (or compact button trigger) plus a selected-file list below, exactly where ant puts it. The zone is a real <button> (Enter/Space open the platform picker) and a real drop target (enter-depth counting, file-drag detection); accept violations are gate-rejected — reported, never entering the value.
$npx jixoai-ui add file-input
https://ui.jixoai.com/r/file-input.json
overview
Overview
Ant's Upload anatomy over LOCAL-picker semantics: an acquisition trigger, an accept gate, and an honest selected-file list — no network, no status theater.
Two trigger postures over one visually hidden native input: the default drop zone (dashed 1px — the dash is reserved for
drop targets and invalid shells, so the drag-over swap to --primary can never read as an error) and the compact button trigger. The zone's hint line is composed honestly from the
field's own contract (accept: image/* · max: 3 files · single file) and a hint prop overrides it. The gate is the honesty kernel: the
platform picker filters by accept itself, but DROPS bypass it — so drops
are re-checked, and violating files never enter the bound File[]; they surface through
the error line and the optional onreject callback. maxFiles reports overflow without truncating the array.
ONE accessible control: the visible trigger IS the picker for tabs and assistive tech —
the native input is clipped, tabindex="-1", aria-hidden (measured: Enter on the trigger reaches the platform
filechooser; the native input is absent from the tab order). Both postures are real
drop targets — an enter-depth counter defeats child-element flicker and dataTransfer.types is checked so only file drags arm the pose
(measured: an image and a .txt dropped together on an accept="image/*" zone bind the png and reject the txt).
Selected files render as ant-style rows in one bordered box — square thumb (a live
object-URL preview for images, revoked on remove, on unmount, AND on external splices of
the bound array; zero-dependency kind glyphs for everything else) | ellipsized name with
a title tooltip | size | remove × — with a remove-all tail on multi-file lists.
The paint: density is this family's deepest axis — the root aliases the closed control
contract (--jx-file-* seams over the hit/icon/text/inset ladders) and the
zone, rows, thumbs and voices all step with the rung (measured in the axis table).
Theme splits by emission form: the zone's typed border and ground freeze at the :root
pole while the well shadow, the drag ink and the row hairlines flip. The InputGroup
hardening law applies (min-width 0 on every flex child, max-width 100% shells,
ellipsized names) — 390px hosts survive unbroken filenames. Geometry is logical-property-only, so dir="rtl" mirrors the rows with zero branches. Kinship: input (the shell/label/error law), input-group (the hardening law), press-button (the press poses).
usage
Usage
Bind the File[] value; accept, maxFiles, and hint shape the zone's composed contract line. capture rides the rest spread to the platform picker.
<script lang="ts">
import FileInput from '@ui/file-input.svelte';
</script>
<!-- drop (default): dashed zone + rows below; drops that violate accept
never enter the value — the error line reports, onreject callbacks -->
<FileInput label="avatar" accept="image/*" bind:files={avatar} />
<!-- compact trigger posture; multiple appends, maxFiles only reports -->
<FileInput label="logs" variant="button" multiple maxFiles={5} bind:files={logs} />
<!-- hint overrides the composed "accept: … · max: N" line -->
<FileInput label="evidence" multiple hint="pdf only — 10 MB each" bind:files={evidence} />
<!-- capture rides the rest spread to the native input — the mobile
platform picker opens its camera face-to-face -->
<FileInput label="photo" accept="image/*" capture="environment" />file-input
The full control, live: pick through the zone, drag files onto it, remove rows. The playground swaps the trigger posture (drop / button), multiple, and disabled — the usage file in the drawer tracks every toggle.
<FileInput
label="demo"
variant="drop"
multiple
bind:files
/>trigger
The drop zone — dashed by intent
Dashed borders on this site mean two things: invalid shells and drop targets. The zone is the second: 1px dashed var(--border) at rest with the upload glyph, a font-nav CLICK OR DRAG FILES title, and a hint line composed honestly from the field's own contract (accept: image/* · max: 3 files · single file). Under a file drag the dash swaps to var(--primary) and the surface lifts — press physics from the trigger law, magnetism from ant's Dragger; it can never be mistaken for an error, because errors keep the monochrome dash plus the “! message” line.
file-input · drop zone
0 files bound<script lang="ts">
import FileInput from '@ui/file-input.svelte';
</script>
<div class="grid gap-5 min-[760px]:grid-cols-2">
<FileInput label="drop zone" multiple bind:files={dropFiles} />
<FileInput
label="gate — accept: image/*"
accept="image/*"
multiple
onreject={(rejected) => (lastRejected = rejected.map((f) => f.name).join(', '))}
bind:files={gateFiles}
/>
</div>The platform picker filters by accept on its own; the gate exists for DROPS, which
bypass it. Rejected files never enter the bound File[] — the honest contract — they
surface through the family error line (“N dropped files rejected — accept: …”) and the
optional onreject callback. The zone content itself is
a snippet ({#snippet zone()}) when a field needs
its own illustration.
presentation
The file list — ant rows, local truth
Ant puts the selected files in a list under the trigger; so does this. One bordered box of hairline rows, each row [square thumb | name | size | remove ×]: the thumb is a live object-URL preview for images (revoked the instant the row is removed, the component unmounts, or the bound array is spliced externally) or a zero-dependency kind glyph for everything else — inline SVG for image/video/audio/pdf/doc, a font-nav </> for code. A “remove all” tail closes multi-file lists. There is no uploading/done/status theater: a local picker's rows are the truth, and the only statuses that exist are real (the error line's overflow/rejection reports).
0file-input · rows
<script lang="ts">
import FileInput from '@ui/file-input.svelte';
</script>
<div class="grid gap-5 min-[760px]:grid-cols-3">
<FileInput label="xs rows" density="xs" multiple bind:files={multiFiles} />
<FileInput label="default rows" density="default" multiple bind:files={multiFiles} />
<FileInput label="lg rows" density="lg" multiple bind:files={multiFiles} />
</div>Every row is keyboard-operable: the × carries aria-label="remove NAME" and the family's inset focus
law; removal presses back into the page. The whole control is logical-property-only,
so dir="rtl" mirrors thumbs, names and buttons with
zero branches.
postures
button variant · disabled
variant='button' is the compact posture for inline forms and space-tight rows: the same upload glyph and the same list below it, drop support included — the dashed drag-over state lands on the button itself. disabled freezes the whole control honestly: the trigger stops pressing, drops are ignored at the gate, every × goes inert, and the native input carries the disabled attribute for form semantics.
file-input · postures
<script lang="ts">
import FileInput from '@ui/file-input.svelte';
</script>
<div class="grid gap-5 min-[760px]:grid-cols-2">
<FileInput label="inline (button)" variant="button" multiple bind:files={buttonFiles} />
<FileInput label="frozen" multiple disabled bind:files={disabledFiles} />
</div>hardening
Narrow hosts — the InputGroup law
The 2026-08-23 width-overflow fix is law, not tuning: min-width: 0 on the root and every flex child, max-width: 100% on the shells, ellipsized names with native title tooltips. An unbroken 100+-character filename in a 390px host ellipsizes inside its row; the size column and the × keep their flex-none footing; the shell never pushes past its host row.
file-input · 390px host
<script lang="ts">
import FileInput from '@ui/file-input.svelte';
</script>
<!-- 390px: the iPhone-class viewport the hardening is tested against -->
<div class="w-[390px] max-w-full border-border border p-4">
<FileInput label="390px host" multiple bind:files={narrowFiles} />
</div>types
FileInput variants
The default drop zone, the compact button trigger, the accept-gated field, and the error state.
file-input · variants
a screenshot is required
<script lang="ts">
import FileInput from '@ui/file-input.svelte';
</script>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-3 border border-border p-4">
<FileInput label="drop zone (default)" multiple />
<span class="text-muted-foreground text-[12px]">dashed zone · hint composed from the props</span>
</div>
<div class="flex flex-col gap-3 border border-border p-4">
<FileInput label="button trigger" variant="button" multiple />
<span class="text-muted-foreground text-[12px]">compact inline posture · drop support included</span>
</div>
<div class="flex flex-col gap-3 border border-border p-4">
<FileInput label="accept gate" accept="image/*" multiple />
<span class="text-muted-foreground text-[12px]">dropped non-images are rejected, never bound</span>
</div>
<div class="flex flex-col gap-3 border border-border p-4">
<FileInput label="error" multiple error="a screenshot is required" />
<span class="text-muted-foreground text-[12px]">dashed destructive surfaces + the “! message” line</span>
</div>
</div>a11y
Accessibility
ONE accessible control: the visible trigger is the picker for tabs and assistive tech — the clipped native input drops out of the tab order and the a11y tree (measured: the trigger click reaches the platform filechooser; the native input is tabindex -1 + aria-hidden).
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus to the visible trigger button (the native input is aria-hidden, tabindex -1) |
| Enter / Space | Opens the platform file picker (measured — the trigger click requests the filechooser) |
| drag files | Both variants are real drop targets; accept violations are gate-rejected and never enter the value |
| × | Removes one selected file; “remove all” clears a multi-file list |
ARIA
| Attribute | Value | Description |
|---|---|---|
| aria-label | label / "choose file(s)" | On the trigger button — label[for] points at it too |
| aria-describedby | {id}-error | The error line: prop error, maxFiles overflow, or drop rejection (measured wired after a rejected drop) |
| aria-label (×) | "remove NAME" | On every list row remove button (type="button") |
| aria-label (list) | "selected files" | On the selected-file list box |
| input | type="file", aria-hidden, tabindex -1 | The native element: accepts/multiple/disabled ride it; it owns no accessible role |
theming
Density and tokens
Family-local size knobs alias the closed density contract; resize the scope and the zone, rows, thumbs and voices follow (measured 63 / 72 / 90 / 108px zone ladder). One fixed exception: the zone title stays the typed 11px label at every rung.
| Token | Default | Source |
|---|---|---|
| --jx-file-h | var(--jx-hit) — 28 / 32 / 40 / 48px (measured) | density |
| --jx-file-thumb / --jx-file-icon | var(--jx-icon) — 16 / 18 / 20 / 24px (measured) | density |
| --jx-file-text | var(--jx-text) — 11 / 12 / 13 / 15px (measured) | density |
| --jx-file-zone-pad / --jx-file-zone-glyph | var(--jx-inset) / var(--jx-icon) | density |
| label voice | 10 / 11 / 12 / 14px (measured) | density |
| zone title | 11px FIXED at every rung (measured) | structural |
| --jx-press-shadow / -hover / -active | --shadow-2xs/xs/sm + press pair | component |
| well sweep | resting --shadow-well · hover -hover | structural |
axes
The eight axes
The eight-axis surface (explicit-props): size · shape · radius · density · color · theme · elevation · motion — each axis takes named steps, auto (inherit the ambient context; stamps nothing), an exact number (px · coefficient · dp · hue per axis), or query() for responsive values. MEASURED, not claimed: density is the fleet's deepest adoption (the --jx-file-* alias seams step every knob); size is the §11 echo with nothing following; the theme split freezes the zone's typed canvas while the well/drag machines flip; shape/radius/color/elevation are supply-only.
| Property | Type | Default | Description |
|---|---|---|---|
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'auto' | LIVE THROUGH THE ALIAS SEAMS — the fleet's deepest adoption. The root aliases the closed control contract (--jx-file-h ← --jx-hit, --jx-file-thumb/--jx-file-icon ← --jx-icon, --jx-file-text ← --jx-text, --jx-file-zone-pad/--jx-file-zone-glyph ← --jx-inset/--jx-icon) and every knob follows. Measured across xs/sm/default/lg: the drop zone's min-height 63 / 72 / 90 / 108px (exactly hit × 2.25); the list rows 28 / 40 / 48px min-height; the thumb box 18 / 22 / 26px (icon + the 2px hairline pair); the file-name voice 11 / 13 / 15px; the scaffold label 10 / 11 / 12 / 14px. THE FIXED EXCEPTION, measured: the zone's uppercase title rides the typed --text-label step and stays 11px at every rung. Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | THE §11 ECHO, NOTHING FOLLOWS — the stamp lands verbatim (measured root inline: --jx-size-effective: 14px; font-size: var(--jx-size-effective, 1rem); computed 14px / 18px at the two demo lanes), and every family voice is a var() channel or a typed px step: the knob aliases are px ladders, the zone title the typed 11px label, the hint the 10.5px micro seam. Nothing is em-of-parent. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — stamps the carriers; zero readers in the family (grep receipt). The zone, the trigger and the list are squared by the family box law: borderRadius 0. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-radius-effective; zero readers (grep receipt). borderRadius 0 on the zone, the trigger and the list — the square picker look the redesign kept. Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY — stamps --jx-color-effective; zero readers in the family (grep receipt). The ink is the typed token set (--jx-foreground/--jx-muted-foreground) plus the raw machines: the drag-over dash turns var(--primary), invalid surfaces var(--destructive). Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | THE BRIDGE + THE SPLIT — the .dark class lands on the field root and the answer depends on the emission form. TYPED POLE (frozen at :root): the zone's border AND ground — measured inside a .dark scope the border stays oklch(0 0 0) and the ground keeps its light pole, so the zone still reads light while the island is dark. RAW MACHINES (flip): the well sweep's resting/hover shadows (measured flipping to the white-inset dark well), the row hairline var(--border), the drag ink var(--primary), the focus ring, the remove/clear hover ink. A dropped zone in a dark scope keeps its light canvas — the honest drop-target look. light/system/auto ride tree inheritance. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | SUPPLY-ONLY — zero carrier reads (grep receipt). The shadows the family carries are two trigger laws, not the lane: the press seams (--jx-press-shadow / -hover / -active riding --shadow-2xs/xs/sm + the press pair, typed atoms) and the F-1 well sweep (raw var(--shadow-well) resting, -hover deepening — with the drag-over lift keeping its transient shadow). Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | AXIS SUPPLY-ONLY, KERNELS LIVE — the carrier --jx-motion-effective is unread (grep receipt). The family’s motion rides the sheet kernels: var(--motion-150) ease-out transitions (the zone glyph/title ink crossfade to the primary drag ink, the remove × color+transform, the clear link), the press translate physics, and the prefers-reduced-motion kill over the whole set (its own selector repeated to match the :is() specificity). Number unit: coefficient. |
Receipts: the density ladder, the size echo, the frozen-zone contrast and the gate behavior were measured on this page's served DOM (probe, task 25); the supply-only rows carry grep receipts over src/lib/ui/file-input/. The universal demo folds in below, with a query() seat: the density lane rides the md viewport key (48rem) — base small below, the default rung at 48rem and wider.
file-input · universal props
<FileInput label="px number" size={14} density="small" />
<FileInput label="named steps" size="large" radius="medium" />
<FileInput label="query()" density={query({ md: 'default' }, 'small')} multiple />api
API
22 meta rows − 8 ambient axes = 14 family rows; the table below serves the 12 contract rows (class forwards through cn to the root; the synthesized rest spread rides the native input — accept/multiple/disabled/capture are re-declared as props, everything else passes through). The File[] value is the $bindable contract; FileItem adds component-managed identity + previews.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
files bind | File[] | [] | Selected files; bound ⇒ controlled — removal and drops write back. |
| variant | 'drop' | 'button' | 'drop' · Own default, not ambient | Dashed drop zone or compact inline trigger; both are buttons AND drop targets. Defaults: literal slot — own ’drop’, not ambient (a presentation mode, no axis). |
| accept | string | — | Native accept attribute; dropped files violating it are gate-rejected. |
| multiple | boolean | false | Allow several files; the collection appends instead of replacing. |
| maxFiles | number | — | Overflow limit — renders an error, never truncates the array. |
| hint | string | composed | Secondary zone hint; defaults to a composed "accept: … · max: N" line. |
| label | string | — | Field label rendered as label[for] above the trigger. |
| id | string | auto | The wiring knob: label[for] and the error line’s id (and the trigger’s aria-describedby) hang off it — auto-generated when omitted. |
| error | string | — | Error text: dashed destructive surfaces + the describedby line. |
| disabled | boolean | false | Freezes the trigger, drops, and per-row removal. |
| onreject | (rejected: File[]) => void | — | Fires with files a DROP brought in that violated accept. |
| zone | Snippet | — | Replaces the drop zone’s glyph + title + hint content. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
FileItem
| Property | Type | Default | Description |
|---|---|---|---|
| file * | File | — | The native File object, exactly as the platform handed it over. |
| id * | string | — | Internal management id — stable across re-renders per File identity. |
| previewUrl | string | — | Image preview object URL; revoked on remove / unmount / external splice. |