Skip to content

File upload

A drop zone that is fully operable from the keyboard, with local file listing, size validation and removal.

Formsintermediateformuploaddrag-and-dropattachment

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Field } from '@/components/ui/field'
import { FileUpload } from '@/components/ui/file-upload'
import { DemoColumn, DemoStage } from './_kit'

export default function FileUploadDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Field
          name="assets"
          label="Brand assets"
          hint="Nothing is uploaded — files are listed locally in this demo."
        >
          {(field) => (
            <FileUpload
              id={field.id}
              name={field.name}
              aria-describedby={field['aria-describedby']}
              multiple
              accept="image/png,image/svg+xml,application/pdf"
              hint="PNG, SVG or PDF, up to 2 MB each"
              maxSizeBytes={2 * 1024 * 1024}
            />
          )}
        </Field>

        <Field name="contract" label="Signed contract">
          {(field) => (
            <FileUpload
              id={field.id}
              name={field.name}
              aria-describedby={field['aria-describedby']}
              accept="application/pdf"
              hint="A single PDF"
            />
          )}
        </Field>

        <Field name="locked" label="Locked upload">
          {(field) => (
            <FileUpload id={field.id} name={field.name} disabled hint="Read-only on this plan" />
          )}
        </Field>
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/file-upload.tsx

import { Field } from '@/components/ui/field'
import { FileUpload } from '@/components/ui/file-upload'
import { DemoColumn, DemoStage } from './_kit'

export default function FileUploadDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Field
          name="assets"
          label="Brand assets"
          hint="Nothing is uploaded — files are listed locally in this demo."
        >
          {(field) => (
            <FileUpload
              id={field.id}
              name={field.name}
              aria-describedby={field['aria-describedby']}
              multiple
              accept="image/png,image/svg+xml,application/pdf"
              hint="PNG, SVG or PDF, up to 2 MB each"
              maxSizeBytes={2 * 1024 * 1024}
            />
          )}
        </Field>

        <Field name="contract" label="Signed contract">
          {(field) => (
            <FileUpload
              id={field.id}
              name={field.name}
              aria-describedby={field['aria-describedby']}
              accept="application/pdf"
              hint="A single PDF"
            />
          )}
        </Field>

        <Field name="locked" label="Locked upload">
          {(field) => (
            <FileUpload id={field.id} name={field.name} disabled hint="Read-only on this plan" />
          )}
        </Field>
      </DemoColumn>
    </DemoStage>
  )
}

components/ui/file-upload.tsx

'use client'

import { useRef, useState, type DragEvent } from 'react'
import { UploadCloud, File as FileIcon, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { Button } from './button'

/**
 * FileUpload
 *
 * A drop zone that is genuinely operable from the keyboard: the visible
 * surface is a `<button>` that opens the native picker, so Space and Enter
 * work, and the file input itself stays in the DOM for form submission.
 *
 * Nothing is uploaded — this is a template demo. Selected files are listed
 * locally and can be removed.
 */
export interface FileUploadProps {
  name?: string
  id?: string
  accept?: string
  multiple?: boolean
  disabled?: boolean
  /** Human-readable constraint, e.g. "PNG or PDF, up to 10 MB". */
  hint?: string
  maxSizeBytes?: number
  className?: string
  'aria-describedby'?: string
  onFilesChange?: (files: File[]) => void
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}

export function FileUpload({
  name = 'files',
  id = 'file-upload',
  accept,
  multiple = false,
  disabled = false,
  hint,
  maxSizeBytes,
  className,
  onFilesChange,
  ...aria
}: FileUploadProps) {
  const inputRef = useRef<HTMLInputElement>(null)
  const [files, setFiles] = useState<File[]>([])
  const [dragging, setDragging] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const accept_ = (incoming: FileList | null) => {
    if (!incoming) return
    const next = Array.from(incoming)
    const oversize = maxSizeBytes ? next.find((file) => file.size > maxSizeBytes) : undefined
    if (oversize) {
      setError(`${oversize.name} is larger than ${formatBytes(maxSizeBytes ?? 0)}.`)
      return
    }
    setError(null)
    const merged = multiple ? [...files, ...next] : next.slice(0, 1)
    setFiles(merged)
    onFilesChange?.(merged)
  }

  const onDrop = (event: DragEvent<HTMLDivElement>) => {
    event.preventDefault()
    setDragging(false)
    if (disabled) return
    accept_(event.dataTransfer.files)
  }

  const remove = (index: number) => {
    const next = files.filter((_, i) => i !== index)
    setFiles(next)
    onFilesChange?.(next)
    if (inputRef.current) inputRef.current.value = ''
  }

  return (
    <div className={cn('flex flex-col gap-3', className)}>
      <div
        onDragOver={(event) => {
          event.preventDefault()
          if (!disabled) setDragging(true)
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={onDrop}
        className={cn(
          'rounded-md border border-dashed transition-colors duration-150 ease-standard',
          dragging ? 'border-accent bg-accent-soft' : 'border-line-strong bg-surface-sunken',
          disabled && 'cursor-not-allowed opacity-60',
        )}
      >
        <button
          type="button"
          disabled={disabled}
          onClick={() => inputRef.current?.click()}
          aria-describedby={aria['aria-describedby']}
          className="flex w-full flex-col items-center gap-2 px-4 py-8 text-center disabled:cursor-not-allowed"
        >
          <UploadCloud className="size-6 text-ink-subtle" aria-hidden="true" />
          <span className="text-sm font-medium text-ink">
            Drop {multiple ? 'files' : 'a file'} here, or{' '}
            <span className="text-accent underline underline-offset-4">browse</span>
          </span>
          {hint ? <span className="text-xs text-ink-muted">{hint}</span> : null}
        </button>
        <input
          ref={inputRef}
          id={id}
          name={name}
          type="file"
          accept={accept}
          multiple={multiple}
          disabled={disabled}
          className="sr-only"
          onChange={(event) => accept_(event.target.files)}
        />
      </div>

      {error ? (
        <p role="alert" className="text-xs font-medium text-danger">
          {error}
        </p>
      ) : null}

      {files.length > 0 ? (
        <ul className="flex flex-col gap-1.5" aria-label="Selected files">
          {files.map((file, index) => (
            <li
              key={`${file.name}-${index}`}
              className="flex items-center gap-2.5 rounded-md border border-line bg-surface px-3 py-2 text-sm"
            >
              <FileIcon className="size-4 shrink-0 text-ink-subtle" aria-hidden="true" />
              <span className="min-w-0 flex-1 truncate text-ink">{file.name}</span>
              <span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
                {formatBytes(file.size)}
              </span>
              <Button
                variant="ghost"
                size="icon-sm"
                onClick={() => remove(index)}
                aria-label={`Remove ${file.name}`}
              >
                <X className="size-3.5" aria-hidden="true" />
              </Button>
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

The visible surface is a button, not a div with a drop handler, so Space and Enter open the picker. Drag-and-drop is an enhancement on top of that, never the only way in.

  • State the accepted formats and size limit in the hint before a user picks the wrong file.
  • Nothing is uploaded in this demo — selected files are listed locally and can be removed.
  • Removing a file resets the native input value so the same file can be chosen again.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Single file
  • Multiple files
  • Size-limited
  • Disabled
  • Error

Accessibility

Keyboard
The drop zone is a `<button>`; the file input is visually hidden but present for form submission.
Errors
Validation failures are announced through `role="alert"`.
File list
Selected files are a labelled list, and each remove button names its file.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.