FileUpload
FileUpload turns one native file picker into calm application state. It chooses or drops one file by default, opts into the platform's multiple selection when the product needs it, keeps accepted values when another candidate is rejected, and supplies previews that disappear when their files are removed.
The application writes every visible element: the real choose button, drop surface, filename, preview, remove action, error, and Tailwind classes. It also owns the eventual upload request. There is no visual variant, upload runtime, anatomy package, or hidden storage decision.
Installation
One command detects Vue, React, or Svelte and writes the matching source into the conventional component directory:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add file-upload- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
There is no provider, initializer, klean-ui.json, upload SDK, class helper, barrel file, or Klean runtime dependency.
Usage
The framework-native binding contains a File or null. Add the native multiple prop and the binding becomes File[]. The content slot or render function receives the same small API in each framework.
Vue
<script setup>
import { ref, shallowRef } from 'vue'
import FileUpload from '@/components/ui/file-upload/FileUpload.vue'
const file = shallowRef(null)
const error = ref('')
function validate(candidate) {
return candidate.size <= 2 * 1024 * 1024 ? true : 'Choose a file under 2 MB.'
}
</script>
<template>
<FileUpload
v-model="file"
accept="image/png,image/jpeg,.pdf"
:validate="validate"
@change="error = ''"
@reject="error = $event.message"
v-slot="upload"
>
<div
v-bind="upload.dropzone"
:class="[
'rounded-xl border border-dashed p-6',
upload.dragging ? 'border-gray-950 bg-gray-50' : 'border-gray-300'
]"
>
<p>{{ upload.file?.name || 'Drop one file here' }}</p>
<button type="button" @click="upload.choose">
{{ upload.file ? 'Replace file' : 'Choose file' }}
</button>
<button v-if="upload.file" type="button" @click="upload.clear">
Remove
</button>
</div>
<p v-if="error" role="alert">{{ error }}</p>
</FileUpload>
</template>
React
import { useState } from 'react'
import FileUpload from '@/components/ui/file-upload/FileUpload.jsx'
export default function ReceiptField() {
const [file, setFile] = useState(null)
const [error, setError] = useState('')
return (
<FileUpload
value={file}
onChange={(candidate) => {
setFile(candidate)
setError('')
}}
onReject={(detail) => setError(detail.message)}
accept="image/png,image/jpeg,.pdf"
validate={(candidate) =>
candidate.size <= 2 * 1024 * 1024 || 'Choose a file under 2 MB.'
}
>
{(upload) => (
<>
<div
{...upload.dropzone}
className={`rounded-xl border border-dashed p-6 ${
upload.dragging ? 'border-gray-950 bg-gray-50' : 'border-gray-300'
}`}
>
<p>{upload.file?.name || 'Drop one file here'}</p>
<button type="button" onClick={upload.choose}>
{upload.file ? 'Replace file' : 'Choose file'}
</button>
{upload.file ? (
<button type="button" onClick={upload.clear}>
Remove
</button>
) : null}
</div>
{error ? <p role="alert">{error}</p> : null}
</>
)}
</FileUpload>
)
}
Svelte
<script>
import FileUpload from '$lib/components/ui/file-upload/FileUpload.svelte'
let file = $state(null)
let error = $state('')
</script>
<FileUpload
bind:file
accept="image/png,image/jpeg,.pdf"
validate={(candidate) =>
candidate.size <= 2 * 1024 * 1024 || 'Choose a file under 2 MB.'}
onchange={() => (error = '')}
onreject={(detail) => (error = detail.message)}
>
{#snippet children(upload)}
<div
{...upload.dropzone}
class={`rounded-xl border border-dashed p-6 ${
upload.dragging ? 'border-gray-950 bg-gray-50' : 'border-gray-300'
}`}
>
<p>{upload.file?.name || 'Drop one file here'}</p>
<button type="button" onclick={upload.choose}>
{upload.file ? 'Replace file' : 'Choose file'}
</button>
{#if upload.file}
<button type="button" onclick={upload.clear}>Remove</button>
{/if}
</div>
{#if error}<p role="alert">{error}</p>{/if}
{/snippet}
</FileUpload>
API
Inputs and events
| Input or event | Default | Purpose |
|---|---|---|
| bound value | null | A File or null; with multiple, a File[]. Vue uses v-model, React uses value/onChange, and Svelte uses bind:file. |
accept | — | Native accept expression, also checked for dropped files: MIME types, wildcards such as image/*, or .ext. |
capture | — | Native mobile capture hint such as environment. |
multiple | false | Uses the native multiple-file picker and appends accepted candidates to the bound file array. |
disabled | false | Prevents browse, drop, replace, and clear. |
validate(file, context) | accept | Returns true/undefined to accept, a message to reject, or { reason, message }. context.files contains files already accepted before this candidate. |
change / onChange | — | Receives the next File, null, or File[] after an accepted selection, removal, or clear. |
reject / onReject | — | Receives { file, reason, message }; a multi-file mistake in single mode also includes the attempted files. |
class / className | — | Ordinary classes on the neutral FileUpload root. |
| native/global attributes | — | IDs, titles, data hooks, and accessible relationships for the root. |
Content API
| Value | Purpose |
|---|---|
file | The current accepted File in single mode, otherwise null. |
files | A normalized array of accepted files in both single and multiple modes. |
previewUrl | The current single-file preview address. Render it only in an element appropriate for the file type. |
previews | Ordered { file, previewUrl } entries for rendering multiple previews without matching arrays by hand. |
dragging | Whether a file drag is currently over the bound drop surface. |
choose() | Opens the platform file picker. Call it from a real visible button. |
remove(file) | Removes one accepted file. In single mode it clears the current value. |
clear() | Returns the bound value to null or [] and releases its previews. |
dropzone | Additive drag/drop event and data bindings for an ordinary caller-owned element. It does not invent button semantics. |
There is deliberately no variant, maxSize, maxFiles, upload, progress, retry, endpoint, existingUrl, or preview-kind prop. The native multiple switch changes selection cardinality; validation and visible presentation remain clearer where the product rule is written.
Browse and drop are one path
accept affects the platform picker and is also checked when files are dropped. Single mode rejects a several-file drop instead of silently choosing one. Multiple mode considers each candidate in order, appends the accepted files, and reports rejected files without discarding the successful part of the batch or an earlier selection.
Drop remains additive. The drop surface is not given a button role or tab stop because a real visible button already invokes choose(). Users who cannot or do not drag receive the same capability with native keyboard activation and an honest accessible name.
Client checks are convenience, not trust. MIME metadata and filenames can be wrong. Repeat file type, size, authorization, and content checks on the server before storing or serving an upload.
Native form boundary
FileUpload resets its internal picker after selection so choosing the same file again is observable. The bound File or File[] is therefore the source of truth and the application builds the multipart request explicitly.
That is why FileUpload does not accept name, form, or required: those props would falsely imply that the browser submits the hidden picker for you. Keep required validation next to the rest of the form state. Use Input with type="file" when ordinary native form serialization—not a custom preview or drop experience—is the actual requirement.
Upload progress, cancellation, retry, scanning, storage, and server errors also belong to the request layer. Compose Spinner, Alert, and a real status region around FileUpload when those states exist.
Multiple attachments
Use multiple when the product evidence is genuinely plural: feedback screenshots, supporting documents, or a small attachment set. Keep count, duplicate, and size policy in validate(file, { files }); do not turn those product decisions into Klean props.
files is useful for count and submission. previews pairs each accepted file with its temporary preview, so reordering or removing files does not require matching parallel arrays. remove(file) removes one; clear() removes all.
The component appends an accepted browse or drop selection. If the product supports paste, read the clipboard files in the application and update the same bound array. If the product supports drag-to-reorder, write that ordering UI around the array; FileUpload is selection state, not a gallery manager.
Hagfish business logo
The persisted logo remains server-owned. FileUpload owns only the new local candidate; the wrapper decides whether “Remove” clears that candidate or requests deletion of the current server asset.
The square tile, initials, remote fallback, border, and actions are ordinary markup. Avatar is not used here because an invoice logo is editable content, not compact person-or-team identity.
Hagfish receipt
The receipt flow accepts camera-friendly images and PDF documents, validates the product size limit, previews only images, and leaves multipart submission to the expense form.
capture="environment" is a hint to capable mobile browsers, not a requirement to open the camera. The ordinary picker remains available when the browser or device ignores it.
Durable behavior
An unsubmitted local File cannot be reconstructed after refresh, Back/Forward restoration, SSR, or on another device. FileUpload does not pretend otherwise. On refresh, show the server-owned current asset or ask the user to select the file again.
Form drafts may safely remember application metadata such as “a receipt still needs to be reselected,” but must not persist a temporary preview address or fake a file handle. When a candidate is replaced, cleared, externally changed, or its owner unmounts, its temporary preview is released.
Persisted assets become durable only after the server accepts them and returns authoritative record data or a URL. Navigation and rollback should then use that server truth.
Accessibility
- Always provide a real visible
button type="button"forchoose(); drag and drop is never the only path. - Name the button for the action and context: “Choose receipt,” “Replace business logo,” or equivalent visible text.
- Keep rejection text visible and use
role="alert"when it appears as the immediate result of the user's choice. In multiple mode, announce both accepted and rejected counts when that distinction matters. - Give image previews useful alt text such as “Selected receipt preview.” Do not use an image element for PDFs or unknown file types.
- Include filename and size as text. Do not rely on a thumbnail, color, or icon alone.
- Keep disabled styling and behavior aligned on the visible controls and FileUpload.
- Preserve visible focus and at least a 44-pixel target on choose, replace, and remove buttons.
- Motion for drag feedback is optional and must respect reduced-motion preferences.
Styling with Tailwind
FileUpload renders no opinionated visible surface. Style the application markup directly: a quiet rounded dropzone, a wrapping attachment grid, a compact logo tile, a Hagfish border and shadow, or a dense receipt row are all Tailwind recipes.
When one product repeats the same treatment, keep a small product wrapper such as ReceiptField.vue. That wrapper may own copy and policy without turning them into global Klean variants.
When not to use
- Use Input with
type="file"for an ordinary native file field submitted by its form. - Use Avatar to render resilient identity, not to select or upload its source.
- Keep queue progress, drag-to-reorder galleries, image cropping, and resumable upload workflows in application composition;
multipleonly owns selection state and preview lifecycle. - Keep upload transport, storage SDKs, antivirus scanning, and server validation outside FileUpload.
- Do not persist
File, blob URLs, or client MIME metadata as durable truth.
Complete framework source
Copy, inspect, and change the complete one-file source for your framework.
Vue source
<script setup>
import {
computed,
onBeforeUnmount,
ref,
shallowRef,
useAttrs,
watch
} from 'vue'
defineOptions({ inheritAttrs: false })
const props = defineProps({
accept: { type: String, default: undefined },
capture: { type: [String, Boolean], default: undefined },
multiple: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
validate: { type: Function, default: () => true }
})
const emit = defineEmits(['change', 'reject'])
const file = defineModel({ default: null })
const attrs = useAttrs()
const root = ref()
const input = ref()
const previewEntries = shallowRef([])
const dragging = ref(false)
let dragDepth = 0
const files = computed(() => {
const current = file.value
if (props.multiple) {
return Array.isArray(current)
? current.filter(Boolean)
: current
? [current]
: []
}
return current ? [current] : []
})
const singleFile = computed(() =>
props.multiple ? null : (files.value[0] ?? null)
)
const previews = computed(() =>
previewEntries.value.map(({ file: candidate, url }) => ({
file: candidate,
previewUrl: url
}))
)
const previewUrl = computed(() => previews.value[0]?.previewUrl ?? '')
const rootAttrs = computed(() => {
const {
class: _class,
'data-slot': _dataSlot,
'data-state': _dataState,
'data-dragging': _dataDragging,
'data-disabled': _dataDisabled,
...rest
} = attrs
return rest
})
function resetInput() {
if (input.value) input.value.value = ''
}
function revokeEntry(entry) {
if (entry?.url && typeof URL.revokeObjectURL === 'function') {
URL.revokeObjectURL(entry.url)
}
}
function createPreviewEntry(candidate) {
const canPreview =
candidate &&
typeof Blob !== 'undefined' &&
candidate instanceof Blob &&
typeof URL.createObjectURL === 'function'
return {
file: candidate,
url: canPreview ? URL.createObjectURL(candidate) : ''
}
}
function syncPreviews(candidates) {
const remaining = [...previewEntries.value]
const next = candidates.map((candidate) => {
const index = remaining.findIndex((entry) =>
Object.is(entry.file, candidate)
)
if (index === -1) return createPreviewEntry(candidate)
return remaining.splice(index, 1)[0]
})
for (const entry of remaining) revokeEntry(entry)
previewEntries.value = next
}
function revokePreviews() {
for (const entry of previewEntries.value) revokeEntry(entry)
previewEntries.value = []
}
function validationResult(candidate, acceptedFiles) {
if (!acceptedByAttribute(candidate)) {
reject(candidate, 'accept', 'That file type is not accepted.')
return false
}
let result
try {
result = props.validate(candidate, {
files: [...acceptedFiles],
multiple: props.multiple
})
} catch {
reject(candidate, 'validate', 'That file could not be validated.')
return false
}
if (result === true || result === undefined) return true
reject(
candidate,
typeof result === 'object' && result?.reason ? result.reason : 'validate',
typeof result === 'string' && result
? result
: typeof result === 'object' && result?.message
? result.message
: 'That file is not valid.'
)
return false
}
function acceptedByAttribute(candidate) {
const rules = props.accept
?.split(',')
.map((rule) => rule.trim().toLowerCase())
.filter(Boolean)
if (!rules?.length) return true
const type = candidate.type?.toLowerCase() ?? ''
const name = candidate.name?.toLowerCase() ?? ''
return rules.some((rule) => {
if (rule.startsWith('.')) return name.endsWith(rule)
if (rule.endsWith('/*')) return type.startsWith(rule.slice(0, -1))
return type === rule
})
}
function reject(candidate, reason, message, files = undefined) {
const detail = { file: candidate ?? null, reason, message }
if (files) detail.files = files
emit('reject', detail)
resetInput()
return false
}
function setFile(candidate) {
file.value = candidate
emit('change', candidate)
resetInput()
return true
}
function select(selection) {
if (props.disabled) return false
const candidates = Array.from(selection ?? [])
if (!candidates.length) return false
if (!props.multiple && candidates.length > 1) {
reject(candidates[0], 'multiple', 'Choose one file at a time.', candidates)
resetInput()
return false
}
const accepted = []
const current = props.multiple ? [...files.value] : []
for (const candidate of candidates) {
if (validationResult(candidate, [...current, ...accepted])) {
accepted.push(candidate)
}
}
if (!accepted.length) {
resetInput()
return false
}
return setFile(props.multiple ? [...current, ...accepted] : accepted[0])
}
function choose() {
if (props.disabled || !input.value) return
resetInput()
if (typeof input.value.showPicker === 'function') {
try {
input.value.showPicker()
return
} catch {
// The native click path covers browsers that restrict showPicker().
}
}
input.value.click()
}
function clear() {
if (props.disabled) return
setFile(props.multiple ? [] : null)
}
function remove(candidate) {
if (props.disabled) return false
if (!props.multiple) return setFile(null)
const index = files.value.findIndex((entry) => Object.is(entry, candidate))
if (index === -1) return false
const next = [...files.value]
next.splice(index, 1)
return setFile(next)
}
function hasFiles(event) {
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
}
function handleDragEnter(event) {
if (props.disabled || !hasFiles(event)) return
event.preventDefault()
dragDepth += 1
dragging.value = true
}
function handleDragOver(event) {
if (props.disabled || !hasFiles(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
dragging.value = true
}
function handleDragLeave(event) {
if (props.disabled || !hasFiles(event)) return
event.preventDefault()
dragDepth = Math.max(0, dragDepth - 1)
if (dragDepth === 0) dragging.value = false
}
function handleDrop(event) {
if (!hasFiles(event)) return
event.preventDefault()
dragDepth = 0
dragging.value = false
if (!props.disabled) select(event.dataTransfer?.files)
}
const dropzone = computed(() => ({
'data-dragging': dragging.value ? '' : undefined,
'data-disabled': props.disabled ? '' : undefined,
onDragenter: handleDragEnter,
onDragover: handleDragOver,
onDragleave: handleDragLeave,
onDrop: handleDrop
}))
watch(
files,
(candidates) => {
syncPreviews(candidates)
},
{ immediate: true, flush: 'sync' }
)
onBeforeUnmount(() => {
revokePreviews()
})
defineExpose({ root, choose, clear, remove })
</script>
<template>
<div
ref="root"
v-bind="rootAttrs"
data-slot="file-upload"
:data-state="files.length ? 'ready' : 'empty'"
:data-dragging="dragging ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:class="attrs.class"
>
<input
ref="input"
type="file"
hidden
data-part="input"
:accept="accept"
:capture="capture"
:multiple="multiple"
:disabled="disabled"
@change="select($event.currentTarget.files)"
/>
<slot
:file="singleFile"
:files="files"
:preview-url="previewUrl"
:previews="previews"
:dragging="dragging"
:choose="choose"
:clear="clear"
:remove="remove"
:dropzone="dropzone"
/>
</div>
</template>
React source
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
function acceptedByAttribute(file, accept) {
const rules = accept
?.split(',')
.map((rule) => rule.trim().toLowerCase())
.filter(Boolean)
if (!rules?.length) return true
const type = file.type?.toLowerCase() ?? ''
const name = file.name?.toLowerCase() ?? ''
return rules.some((rule) => {
if (rule.startsWith('.')) return name.endsWith(rule)
if (rule.endsWith('/*')) return type.startsWith(rule.slice(0, -1))
return type === rule
})
}
const FileUpload = forwardRef(function FileUpload(
{
value,
defaultValue = null,
onChange,
onReject,
accept,
capture,
multiple = false,
disabled = false,
validate = () => true,
className,
children,
'data-slot': _dataSlot,
'data-state': _dataState,
'data-dragging': _dataDragging,
'data-disabled': _dataDisabled,
...props
},
forwardedRef
) {
const rootRef = useRef(null)
const inputRef = useRef(null)
const dragDepth = useRef(0)
const previewEntriesRef = useRef([])
const controlled = value !== undefined
const [localValue, setLocalValue] = useState(defaultValue)
const [previews, setPreviews] = useState([])
const [dragging, setDragging] = useState(false)
const selectedValue = controlled ? value : localValue
const files = useMemo(() => {
if (multiple) {
return Array.isArray(selectedValue)
? selectedValue.filter(Boolean)
: selectedValue
? [selectedValue]
: []
}
return selectedValue ? [selectedValue] : []
}, [multiple, selectedValue])
const file = multiple ? null : (files[0] ?? null)
const previewUrl = previews[0]?.previewUrl ?? ''
const resetInput = useCallback(() => {
if (inputRef.current) inputRef.current.value = ''
}, [])
const setSelection = useCallback(
(selection) => {
if (!controlled) setLocalValue(selection)
onChange?.(selection)
resetInput()
return true
},
[controlled, onChange, resetInput]
)
const reject = useCallback(
(candidate, reason, message, files) => {
const detail = { file: candidate ?? null, reason, message }
if (files) detail.files = files
onReject?.(detail)
resetInput()
return false
},
[onReject, resetInput]
)
const select = useCallback(
(selection) => {
if (disabled) return false
const candidates = Array.from(selection ?? [])
if (!candidates.length) return false
if (!multiple && candidates.length > 1) {
reject(
candidates[0],
'multiple',
'Choose one file at a time.',
candidates
)
resetInput()
return false
}
const accepted = []
const current = multiple ? [...files] : []
for (const candidate of candidates) {
if (!acceptedByAttribute(candidate, accept)) {
reject(candidate, 'accept', 'That file type is not accepted.')
continue
}
let result
try {
result = validate(candidate, {
files: [...current, ...accepted],
multiple
})
} catch {
reject(candidate, 'validate', 'That file could not be validated.')
continue
}
if (result !== true && result !== undefined) {
reject(
candidate,
typeof result === 'object' && result?.reason
? result.reason
: 'validate',
typeof result === 'string' && result
? result
: typeof result === 'object' && result?.message
? result.message
: 'That file is not valid.'
)
continue
}
accepted.push(candidate)
}
if (!accepted.length) {
resetInput()
return false
}
return setSelection(multiple ? [...current, ...accepted] : accepted[0])
},
[
accept,
disabled,
files,
multiple,
reject,
resetInput,
setSelection,
validate
]
)
const choose = useCallback(() => {
const input = inputRef.current
if (disabled || !input) return
resetInput()
if (typeof input.showPicker === 'function') {
try {
input.showPicker()
return
} catch {
// The native click path covers browsers that restrict showPicker().
}
}
input.click()
}, [disabled, resetInput])
const clear = useCallback(() => {
if (!disabled) setSelection(multiple ? [] : null)
}, [disabled, multiple, setSelection])
const remove = useCallback(
(candidate) => {
if (disabled) return false
if (!multiple) return setSelection(null)
const index = files.findIndex((entry) => Object.is(entry, candidate))
if (index === -1) return false
const next = [...files]
next.splice(index, 1)
return setSelection(next)
},
[disabled, files, multiple, setSelection]
)
function hasFiles(event) {
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
}
function handleDragEnter(event) {
if (disabled || !hasFiles(event)) return
event.preventDefault()
dragDepth.current += 1
setDragging(true)
}
function handleDragOver(event) {
if (disabled || !hasFiles(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
setDragging(true)
}
function handleDragLeave(event) {
if (disabled || !hasFiles(event)) return
event.preventDefault()
dragDepth.current = Math.max(0, dragDepth.current - 1)
if (dragDepth.current === 0) setDragging(false)
}
function handleDrop(event) {
if (!hasFiles(event)) return
event.preventDefault()
dragDepth.current = 0
setDragging(false)
if (!disabled) select(event.dataTransfer?.files)
}
useEffect(() => {
const remaining = [...previewEntriesRef.current]
const next = files.map((candidate) => {
const index = remaining.findIndex((entry) =>
Object.is(entry.file, candidate)
)
if (index !== -1) return remaining.splice(index, 1)[0]
const canPreview =
typeof Blob !== 'undefined' &&
candidate instanceof Blob &&
typeof URL.createObjectURL === 'function'
return {
file: candidate,
previewUrl: canPreview ? URL.createObjectURL(candidate) : ''
}
})
for (const entry of remaining) {
if (entry.previewUrl) URL.revokeObjectURL?.(entry.previewUrl)
}
previewEntriesRef.current = next
setPreviews(next)
}, [files])
useEffect(
() => () => {
for (const entry of previewEntriesRef.current) {
if (entry.previewUrl) URL.revokeObjectURL?.(entry.previewUrl)
}
previewEntriesRef.current = []
},
[]
)
const dropzone = {
'data-dragging': dragging ? '' : undefined,
'data-disabled': disabled ? '' : undefined,
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop
}
const api = {
get file() {
return file
},
get previewUrl() {
return previewUrl
},
get files() {
return files
},
get previews() {
return previews
},
get dragging() {
return dragging
},
choose,
clear,
remove,
get dropzone() {
return dropzone
}
}
useImperativeHandle(forwardedRef, () => ({
root: rootRef.current,
choose,
clear,
remove
}))
return (
<div
{...props}
ref={rootRef}
data-slot="file-upload"
data-state={files.length ? 'ready' : 'empty'}
data-dragging={dragging ? '' : undefined}
data-disabled={disabled ? '' : undefined}
className={className}
>
<input
ref={inputRef}
type="file"
hidden
data-part="input"
accept={accept}
capture={capture}
multiple={multiple}
disabled={disabled}
onChange={(event) => select(event.currentTarget.files)}
/>
{typeof children === 'function' ? children(api) : children}
</div>
)
})
export default FileUpload
Svelte source
<script>
import { untrack } from "svelte";
let {
file = $bindable(null),
onchange,
onreject,
accept,
capture,
multiple = false,
disabled = false,
validate = () => true,
class: className,
children,
"data-slot": _dataSlot,
"data-state": _dataState,
"data-dragging": _dataDragging,
"data-disabled": _dataDisabled,
...props
} = $props();
let root = $state();
let input = $state();
let previewEntries = $state([]);
let dragging = $state(false);
let dragDepth = 0;
let files = $derived(
multiple
? Array.isArray(file)
? file.filter(Boolean)
: file
? [file]
: []
: file
? [file]
: [],
);
let singleFile = $derived(multiple ? null : (files[0] ?? null));
let previews = $derived(
previewEntries.map((entry) => ({
file: entry.file,
previewUrl: entry.url,
})),
);
let previewUrl = $derived(previews[0]?.previewUrl ?? "");
function resetInput() {
if (input) input.value = "";
}
function revokeEntry(entry) {
if (entry?.url) URL.revokeObjectURL?.(entry.url);
}
function createPreviewEntry(candidate) {
const canPreview =
candidate &&
typeof Blob !== "undefined" &&
candidate instanceof Blob &&
typeof URL.createObjectURL === "function";
return {
file: candidate,
url: canPreview ? URL.createObjectURL(candidate) : "",
};
}
function syncPreviews(candidates) {
const remaining = [...previewEntries];
const next = candidates.map((candidate) => {
const index = remaining.findIndex((entry) =>
Object.is(entry.file, candidate),
);
if (index === -1) return createPreviewEntry(candidate);
return remaining.splice(index, 1)[0];
});
for (const entry of remaining) revokeEntry(entry);
previewEntries = next;
}
function revokePreviews() {
for (const entry of previewEntries) revokeEntry(entry);
previewEntries = [];
}
function acceptedByAttribute(candidate) {
const rules = accept
?.split(",")
.map((rule) => rule.trim().toLowerCase())
.filter(Boolean);
if (!rules?.length) return true;
const type = candidate.type?.toLowerCase() ?? "";
const filename = candidate.name?.toLowerCase() ?? "";
return rules.some((rule) => {
if (rule.startsWith(".")) return filename.endsWith(rule);
if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
return type === rule;
});
}
function reject(candidate, reason, message, files = undefined) {
const detail = { file: candidate ?? null, reason, message };
if (files) detail.files = files;
onreject?.(detail);
resetInput();
return false;
}
function setSelection(selection) {
file = selection;
onchange?.(selection);
resetInput();
return true;
}
function select(selection) {
if (disabled) return false;
const candidates = Array.from(selection ?? []);
if (!candidates.length) return false;
if (!multiple && candidates.length > 1) {
reject(
candidates[0],
"multiple",
"Choose one file at a time.",
candidates,
);
resetInput();
return false;
}
const accepted = [];
const current = multiple ? [...files] : [];
for (const candidate of candidates) {
if (!acceptedByAttribute(candidate)) {
reject(candidate, "accept", "That file type is not accepted.");
continue;
}
let result;
try {
result = validate(candidate, {
files: [...current, ...accepted],
multiple,
});
} catch {
reject(candidate, "validate", "That file could not be validated.");
continue;
}
if (result !== true && result !== undefined) {
reject(
candidate,
typeof result === "object" && result?.reason
? result.reason
: "validate",
typeof result === "string" && result
? result
: typeof result === "object" && result?.message
? result.message
: "That file is not valid.",
);
continue;
}
accepted.push(candidate);
}
if (!accepted.length) {
resetInput();
return false;
}
return setSelection(multiple ? [...current, ...accepted] : accepted[0]);
}
export function choose() {
if (disabled || !input) return;
resetInput();
if (typeof input.showPicker === "function") {
try {
input.showPicker();
return;
} catch {
// The native click path covers browsers that restrict showPicker().
}
}
input.click();
}
export function clear() {
if (!disabled) setSelection(multiple ? [] : null);
}
export function remove(candidate) {
if (disabled) return false;
if (!multiple) return setSelection(null);
const index = files.findIndex((entry) => Object.is(entry, candidate));
if (index === -1) return false;
const next = [...files];
next.splice(index, 1);
return setSelection(next);
}
export function getRoot() {
return root;
}
function hasFiles(event) {
return Array.from(event.dataTransfer?.types ?? []).includes("Files");
}
function handleDragEnter(event) {
if (disabled || !hasFiles(event)) return;
event.preventDefault();
dragDepth += 1;
dragging = true;
}
function handleDragOver(event) {
if (disabled || !hasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
dragging = true;
}
function handleDragLeave(event) {
if (disabled || !hasFiles(event)) return;
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) dragging = false;
}
function handleDrop(event) {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth = 0;
dragging = false;
if (!disabled) select(event.dataTransfer?.files);
}
let dropzone = $derived({
"data-dragging": dragging ? "" : undefined,
"data-disabled": disabled ? "" : undefined,
ondragenter: handleDragEnter,
ondragover: handleDragOver,
ondragleave: handleDragLeave,
ondrop: handleDrop,
});
let api = {
get file() {
return singleFile;
},
get previewUrl() {
return previewUrl;
},
get files() {
return files;
},
get previews() {
return previews;
},
get dragging() {
return dragging;
},
get dropzone() {
return dropzone;
},
choose,
clear,
remove,
};
$effect(() => {
const candidates = files;
untrack(() => syncPreviews(candidates));
});
$effect(() => {
return () => untrack(revokePreviews);
});
</script>
<div
{...props}
bind:this={root}
data-slot="file-upload"
data-state={files.length ? "ready" : "empty"}
data-dragging={dragging ? "" : undefined}
data-disabled={disabled ? "" : undefined}
class={className}
>
<input
bind:this={input}
type="file"
hidden
data-part="input"
{accept}
{capture}
{multiple}
{disabled}
onchange={(event) => select(event.currentTarget.files)}
/>
{@render children?.(api)}
</div>
Related components
- Button — provides the real visible browse, replace, and remove actions.
- Input — handles an ordinary native
type="file"field when custom orchestration is unnecessary. - Avatar — renders identity after the application has a persisted image source.
- Alert — presents recoverable upload or server-validation failures.
- Spinner — supplements visible pending upload status.