Skip to main content

FileUpload

Select and validate local files through a dropzone or trigger.

Use FileUpload to collect local files before the application sends them. Selection and validation happen in the component; upload transport belongs to the caller.

Basic usage#

import { FileUpload } from 'moraine'

export function Example() {
  return <FileUpload label="Upload files" />
}

Playground#

Upload documentsClick anywhere or drop PDF, PNG, or JPG up to 10MB
Props
Slots

Anatomy#

FileUpload [component; slot=root]
├── control [slot]
│   └── wrapper [slot]
│       ├── icon [slot]
│       ├── label [slot]
│       └── description [slot]
├── input [internal]
└── files [slot]
    └── file [slot]
        ├── filePreview [slot]
        ├── fileMeta [slot]
        │   ├── fileName [slot]
        │   └── fileSize [slot]
        └── fileRemove [slot]

Usage#

Selecting files#

Click anywhere in the visible dropzone, including its padding, icon, and text, or drop files onto it. File previews and removal actions are outside the picker hit area. Use the dropzone for pointer and drag-and-drop selection, or set dropzone={false} for a compact picker trigger. Use multiple when the value can contain more than one File; onValueChange receives either a single file, an array, or null.

import { FileUpload } from 'moraine'
import { For } from 'solid-js'

export function SelectingFiles() {
  return (
    <div class="flex flex-col gap-6 max-w-md w-full">
      <For each={['sm', 'md', 'lg'] as const}>
        {(size) => (
          <div class="flex flex-col gap-2">
            <span class="text-xs text-muted-foreground font-medium">{size}</span>
            <FileUpload
              size={size}
              multiple
              label="Click anywhere or drop files"
              description="Choose documents and images."
            />
          </div>
        )}
      </For>
    </div>
  )
}

Rejections and form behavior#

accept, minSize, maxSize, and maxFiles validate an attempted selection before it is added. Handle onFileReject to present rejected files and their reason codes in your application. readOnly prevents changes while preserving the selected list. It inherits an enclosing Field read-only state when omitted; an explicit readOnly={false} overrides that state. disabled also removes interaction.

import { FileUpload } from 'moraine'

export function FormBehavior() {
  return (
    <div class="max-w-md w-full space-y-4">
      <FileUpload
        disabled
        label="Disabled upload"
        description="Upload is locked during maintenance."
      />
      <FileUpload
        readOnly
        label="Read-only upload"
        description="View existing attachments without making modifications."
      />
    </div>
  )
}

Keyboard interaction#

Key Description
↵ / Space Opens the native operating system file picker dialog when the upload control is focused.
⇥ Navigates between the upload dropzone control and individual file removal buttons.

Examples#

Multiple files#

import { Badge, FileUpload } from 'moraine'
import type { FileUploadT } from 'moraine'
import { createSignal, For, Show } from 'solid-js'

export function MultipleMaxFiles() {
  type FileUploadValue = FileUploadT.Value

  const [receipts, setReceipts] = createSignal<FileUploadValue>([])
  const [rejectWarning, setRejectWarning] = createSignal<string | null>(null)

  const filesList = () => {
    const val = receipts()
    if (!val) {
      return []
    }
    return Array.isArray(val) ? val : [val]
  }

  return (
    <div class="p-4 b-1 b-border rounded-xl max-w-xl space-y-4">
      <div class="flex items-center justify-between">
        <div>
          <h4 class="text-sm font-medium">Expense receipts</h4>
          <p class="text-xs text-muted-foreground">
            Upload up to 3 receipt images or PDF documents.
          </p>
        </div>
        <Badge variant="outline">Max 3 files</Badge>
      </div>

      <FileUpload
        multiple
        maxFiles={3}
        accept="image/*,.pdf"
        label="Drop receipts here"
        description="PDF, PNG, JPG up to 10MB each"
        onValueChange={(val) => {
          setReceipts(val)
          setRejectWarning(null)
        }}
        onFileReject={(rejected) => {
          setRejectWarning(
            `Rejected ${rejected.length} file(s) exceeding the 3 file limit or invalid format.`,
          )
        }}
      />

      <Show when={rejectWarning()}>
        <p class="text-xs text-destructive">{rejectWarning()}</p>
      </Show>

      <Show when={filesList().length > 0}>
        <div class="pt-2 border-t border-border space-y-2">
          <p class="text-xs text-muted-foreground font-medium">
            Attached files ({filesList().length}/3):
          </p>
          <div class="space-y-1">
            <For each={filesList()}>
              {(file) => (
                <div class="text-xs p-2 rounded-lg bg-muted/40 flex items-center justify-between">
                  <span class="font-medium truncate">{file.name}</span>
                  <span class="text-muted-foreground font-mono">
                    {(file.size / 1024).toFixed(1)} KB
                  </span>
                </div>
              )}
            </For>
          </div>
        </div>
      </Show>
    </div>
  )
}

File rejections#

import { FileUpload } from 'moraine'
import { For, createSignal } from 'solid-js'

const REJECTION_LABELS = {
  FILE_DUPLICATE: 'already selected',
  FILE_INVALID_TYPE: 'type is not accepted',
  FILE_TOO_LARGE: 'is larger than 1 MB',
  FILE_TOO_MANY_FILES: 'exceeds the file limit',
  FILE_TOO_SMALL: 'is smaller than the minimum size',
  TOO_MANY_FILES: 'exceeds the file limit',
} as const

export function Rejections() {
  const [rejections, setRejections] = createSignal<{ name: string; reason: string }[]>([])

  return (
    <div class="max-w-lg space-y-3">
      <FileUpload
        multiple
        accept="image/png,image/jpeg"
        maxFiles={2}
        maxSize={1024 * 1024}
        label="Upload images"
        description="PNG or JPEG, up to 1 MB each (maximum two files)"
        onFileReject={(files) =>
          setRejections(
            files.map(({ file, errors }) => ({
              name: file.name,
              reason: errors.map((error) => REJECTION_LABELS[error]).join(', '),
            })),
          )
        }
      />

      <For each={rejections()}>
        {(rejection) => (
          <p class="text-sm text-destructive">
            {rejection.name}: {rejection.reason}
          </p>
        )}
      </For>
    </div>
  )
}

Trigger-only picker#

import { FileUpload } from 'moraine'
import type { FileUploadT } from 'moraine'
import { createSignal, Show } from 'solid-js'

export function TriggerModeNoDropzone() {
  const [attached, setAttached] = createSignal<FileUploadT.Value>(null)

  const fileName = () => {
    const file = attached()
    if (!file) {
      return null
    }
    return Array.isArray(file) ? file[0]?.name : file.name
  }

  return (
    <div class="max-w-md space-y-3">
      <FileUpload
        dropzone={false}
        label="Attach files"
        description="Choose receipts or invoices from your computer."
        onValueChange={setAttached}
      />

      <Show when={fileName()}>
        <div class="text-xs text-muted-foreground px-3 py-2 rounded-lg bg-muted/40 flex gap-2 items-center">
          <span class="i-lucide:paperclip text-primary" />
          <span>Selected: {fileName()}</span>
        </div>
      </Show>
    </div>
  )
}

Form integration#

import { Button, createForm, FileUpload } from 'moraine'
import type { FileUploadT } from 'moraine'
import { createSignal } from 'solid-js'
import * as v from 'valibot'

export function FormIntegration() {
  const [formState, setFormState] = createSignal({
    attachment: null as File | null,
  })

  const updateFormAttachment = (value: FileUploadValue) => {
    const next = Array.isArray(value) ? (value[0] ?? null) : value
    setFormState((prev) => ({ ...prev, attachment: next }))
  }

  type FileUploadValue = FileUploadT.Value
  const form = createForm({
    schema: v.object({ attachment: v.file('Please upload one attachment.') }),
    initialInput: { attachment: undefined },
  })

  return (
    <form.Form>
      <div class="max-w-xl space-y-4">
        <form.Field
          name="attachment"
          label="Attachment"
          description="Upload at least one file before submit."
          required
        >
          <FileUpload id="demo-attachment-upload" onValueChange={updateFormAttachment} />
        </form.Field>
        <div class="flex gap-3 items-center">
          <Button type="submit" variant="secondary" size="sm">
            Validate
          </Button>
          <p class="text-xs text-muted-foreground">
            Current attachment: {formState().attachment?.name ?? 'none'}
          </p>
        </div>
      </div>
    </form.Form>
  )
}

Attributes#

Attributes
data-disabledSlot: file-uploadDescription: Present when the component, slot, or item is disabled.
data-invalidSlot: file-upload, file-upload-controlDescription: Present when the field or form has a validation error.
data-readonlySlot: file-uploadDescription: Present when the field is in read-only mode.
data-requiredSlot: file-uploadDescription: Present when the field input is required.
data-dropzoneSlot: file-upload-control, file-upload-wrapperDescription: Present when the target is an active file dropzone.
data-draggingSlot: file-upload-controlDescription: Present while the related thumb or handle is being dragged.

Props#

Props for the FileUpload component. Renders a <div> element by default.

Prop