Upload Field

An upload field for react-hook-form, TanStack Form and server actions.

"use client"

import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"

import { Button } from "@/components/ui/button"
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { UploadField } from "@/components/upload-field"

const schema = z.object({
  title: z.string().min(1, "Give your submission a title"),
  attachments: z
    .array(z.string())
    .min(1, "Attach at least one file")
    .max(3, "Attach at most 3 files"),
})

type Values = z.infer<typeof schema>

export default function FormUploadExample() {
  const [isUploading, setIsUploading] = React.useState(false)
  const form = useForm<Values>({
    resolver: zodResolver(schema),
    defaultValues: { title: "", attachments: [] },
  })

  function onSubmit(values: Values) {
    toast("Submitted", {
      description: (
        <pre className="text-xs break-all whitespace-pre-wrap">
          {JSON.stringify(values, null, 2)}
        </pre>
      ),
    })
  }

  return (
    <form
      onSubmit={form.handleSubmit(onSubmit)}
      className="flex flex-col gap-6"
    >
      <FieldGroup>
        <Controller
          name="title"
          control={form.control}
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid || undefined}>
              <FieldLabel htmlFor="submission-title">Title</FieldLabel>
              <Input
                {...field}
                id="submission-title"
                aria-invalid={fieldState.invalid}
              />
              <FieldError errors={[fieldState.error]} />
            </Field>
          )}
        />
        <Controller
          name="attachments"
          control={form.control}
          render={({ field, fieldState }) => (
            <UploadField
              label="Attachments"
              description="Up to 3 files. They upload while you fill in the form."
              required
              maxFiles={3}
              error={fieldState.error?.message}
              onChange={field.onChange}
              onBlur={field.onBlur}
              onUploadingChange={setIsUploading}
            />
          )}
        />
      </FieldGroup>
      <Button type="submit" disabled={isUploading} className="self-start">
        {isUploading ? "Waiting for uploads…" : "Submit"}
      </Button>
    </form>
  )
}

Installation

npx shadcn@latest add @uploadcn/upload-field

Usage

import { UploadField } from "@/components/upload-field"
<Controller
  name="attachments"
  control={form.control}
  render={({ field, fieldState }) => (
    <UploadField
      label="Attachments"
      required
      maxFiles={3}
      error={fieldState.error?.message}
      onChange={field.onChange}
      onBlur={field.onBlur}
      onUploadingChange={setIsUploading}
    />
  )}
/>

The value is a list of object keys, not files: uploads happen while the user fills in the form, so submitting is instant.

Props

Prop

Type

Examples

Required upload (native form)

"use client"

import * as React from "react"
import { s3Adapter, validateFileCount } from "@uploadcn/core"
import { useUploadValue, useUploader } from "@uploadcn/react"

import { demoTransport } from "@/examples/_demo"
import { Button } from "@/components/ui/button"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field"
import {
  Upload,
  UploadDropzone,
  UploadDropzoneDescription,
  UploadDropzoneTitle,
  UploadQueue,
} from "@/components/ui/upload"

const adapter = s3Adapter({
  endpoint: "/api/upload",
  transport: demoTransport, // docs only, see examples/_demo.ts
})
const getKey = (item: { result?: { key: string } }) => item.result?.key ?? ""

/**
 * A plain <form>: successful uploads are submitted as hidden inputs named
 * "documents", so this works with server actions too.
 */
export default function RequiredUploadExample() {
  const uploader = useUploader({ adapter, maxFiles: 2 })
  const keys = useUploadValue(uploader, getKey)
  const [error, setError] = React.useState<string | null>(null)
  const [submitted, setSubmitted] = React.useState<string[] | null>(null)
  const labelId = React.useId()

  function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const issue = validateFileCount(keys.length, { minFiles: 1, maxFiles: 2 })
    setError(issue?.message ?? null)
    if (issue) return
    setSubmitted(
      new FormData(event.currentTarget).getAll("documents").map(String)
    )
  }

  return (
    <form onSubmit={onSubmit} className="flex flex-col gap-4">
      <Field data-invalid={error ? true : undefined}>
        <FieldLabel id={labelId}>
          Proof of identity{" "}
          <span aria-hidden className="text-muted-foreground">
            *
          </span>
        </FieldLabel>
        <Upload uploader={uploader} name="documents">
          <UploadDropzone
            aria-labelledby={labelId}
            aria-required
            aria-invalid={error ? true : undefined}
            className="py-6 aria-invalid:border-destructive"
          >
            <UploadDropzoneTitle>Add 1–2 documents</UploadDropzoneTitle>
            <UploadDropzoneDescription>
              Passport, ID card or driver’s license
            </UploadDropzoneDescription>
          </UploadDropzone>
          <UploadQueue />
        </Upload>
        <FieldDescription>
          Required. Files are encrypted at rest.
        </FieldDescription>
        {error ? <FieldError>{error}</FieldError> : null}
      </Field>
      <Button type="submit" className="self-start">
        Continue
      </Button>
      {submitted ? (
        <p className="text-sm text-muted-foreground">
          Submitted keys:{" "}
          <code className="text-xs">{submitted.join(", ")}</code>
        </p>
      ) : null}
    </form>
  )
}

File restrictions

"use client"

import { type FileValidator, s3Adapter } from "@uploadcn/core"

import { demoTransport } from "@/examples/_demo"
import {
  Upload,
  UploadDropzone,
  UploadDropzoneDescription,
  UploadDropzoneHeader,
  UploadDropzoneMedia,
  UploadDropzoneTitle,
  UploadQueue,
} from "@/components/ui/upload"

const adapter = s3Adapter({
  endpoint: "/api/upload",
  transport: demoTransport, // docs only, see examples/_demo.ts
})

/** Custom rules can be sync or async (e.g. ask your API if the name is taken). */
const noSpacesInName: FileValidator = (file) =>
  file.name.includes(" ") ? "File names can't contain spaces" : null

export default function FileRestrictionsExample() {
  return (
    <Upload
      adapter={adapter}
      accept={["image/png", "image/jpeg"]}
      maxSize={2 * 1000 * 1000}
      maxFiles={3}
      image={{ minWidth: 400, minHeight: 400 }}
      validate={noSpacesInName}
      duplicates="reject"
    >
      <UploadDropzone>
        <UploadDropzoneHeader>
          <UploadDropzoneMedia variant="icon" />
          <UploadDropzoneTitle>
            PNG or JPEG, at least 400×400px
          </UploadDropzoneTitle>
          <UploadDropzoneDescription>
            Max 2 MB each · up to 3 files · no spaces in names · no duplicates
          </UploadDropzoneDescription>
        </UploadDropzoneHeader>
      </UploadDropzone>
      <UploadQueue />
    </Upload>
  )
}

On this page