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-fieldUsage
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>
)
}