jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

install — file-input

$npx jixoai-ui add file-input

https://ui.jixoai.com/r/file-input.json

drop zone · button trigger real drag-and-drop accept gate-rejection thumbnail rows + remove 390px-safe hardening zero deps · Svelte 5 runes

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.

FileInput usage
<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.

file-input-usage.svelte svelte
<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

default variant · hint composed from the props · 0 files bound
drag a non-image onto this one — the gate rejects it: nothing rejected yet
file-input-drop-demo.svelte svelte
<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).

bound File[] · length: 0

file-input · rows

file-input-rows-demo.svelte svelte
<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

compact trigger · drag-over dashes the button · same rows below
trigger inert · drops rejected · rows readable, removal locked
file-input-postures-demo.svelte svelte
<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.

hover a row's name for the full title tooltip

file-input · 390px host

file-input-host-demo.svelte svelte
<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

dashed zone · hint composed from the props
compact inline posture · drop support included
dropped non-images are rejected, never bound

a screenshot is required

dashed destructive surfaces + the “! message” line
file-input-variants-demo.svelte svelte
<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

KeyAction
TabMoves focus to the visible trigger button (the native input is aria-hidden, tabindex -1)
Enter / SpaceOpens the platform file picker (measured — the trigger click requests the filechooser)
drag filesBoth 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

AttributeValueDescription
aria-labellabel / "choose file(s)"On the trigger button — label[for] points at it too
aria-describedby{id}-errorThe 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
inputtype="file", aria-hidden, tabindex -1The 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.

xs
sm
default
lg
TokenDefaultSource
--jx-file-hvar(--jx-hit) — 28 / 32 / 40 / 48px (measured)density
--jx-file-thumb / --jx-file-iconvar(--jx-icon) — 16 / 18 / 20 / 24px (measured)density
--jx-file-textvar(--jx-text) — 11 / 12 / 13 / 15px (measured)density
--jx-file-zone-pad / --jx-file-zone-glyphvar(--jx-inset) / var(--jx-icon)density
label voice10 / 11 / 12 / 14px (measured)density
zone title11px FIXED at every rung (measured)structural
--jx-press-shadow / -hover / -active--shadow-2xs/xs/sm + press paircomponent
well sweepresting --shadow-well · hover -hoverstructural

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.

PropertyTypeDefaultDescription
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

file-input-universal.svelte svelte
<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

PropertyTypeDefaultDescription
files bindFile[][]Selected files; bound ⇒ controlled — removal and drops write back.
variant 'drop' | 'button''drop' · Own default, not ambientDashed 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 booleanfalseAllow several files; the collection appends instead of replacing.
maxFiles number—Overflow limit — renders an error, never truncates the array.
hint stringcomposedSecondary zone hint; defaults to a composed "accept: … · max: N" line.
label string—Field label rendered as label[for] above the trigger.
id stringautoThe 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 booleanfalseFreezes 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

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)

FileItem

PropertyTypeDefaultDescription
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.
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h