Skip to content

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.

ReceiptField.vue
Browse and drop feed the same one-file contract. The visible button remains the keyboard and screen-reader path.

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.

Terminal
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

ReceiptField.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

ReceiptField.jsx
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

ReceiptField.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 eventDefaultPurpose
bound valuenullA File or null; with multiple, a File[]. Vue uses v-model, React uses value/onChange, and Svelte uses bind:file.
acceptNative accept expression, also checked for dropped files: MIME types, wildcards such as image/*, or .ext.
captureNative mobile capture hint such as environment.
multiplefalseUses the native multiple-file picker and appends accepted candidates to the bound file array.
disabledfalsePrevents browse, drop, replace, and clear.
validate(file, context)acceptReturns true/undefined to accept, a message to reject, or { reason, message }. context.files contains files already accepted before this candidate.
change / onChangeReceives the next File, null, or File[] after an accepted selection, removal, or clear.
reject / onRejectReceives { file, reason, message }; a multi-file mistake in single mode also includes the attempted files.
class / classNameOrdinary classes on the neutral FileUpload root.
native/global attributesIDs, titles, data hooks, and accessible relationships for the root.

Content API

ValuePurpose
fileThe current accepted File in single mode, otherwise null.
filesA normalized array of accepted files in both single and multiple modes.
previewUrlThe current single-file preview address. Render it only in an element appropriate for the file type.
previewsOrdered { file, previewUrl } entries for rendering multiple previews without matching arrays by hand.
draggingWhether 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.
dropzoneAdditive 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.

FeedbackAttachments.vue
Native multiple selection, partial rejection, wrapping previews, and per-file removal share one file array. Paste handling and the upload request remain application composition.

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.

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.

BusinessLogoField.vue
Existing remote state and a new local File are different truths. The recipe keeps that distinction visible.

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.

ReceiptField.vue
Image preview, PDF metadata, replace, remove, drop, and rejection all come from one bound File without product props in FileUpload.

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" for choose(); 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; multiple only 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

FileUpload.vue
<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

FileUpload.jsx
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

FileUpload.svelte
<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>

  • 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.

All open source projects are released under the MIT License.