Upload

Composable, accessible parts for building any upload interface, dropzones, lists, items, progress and actions.

"use client"

import { useUploader } from "@uploadcn/react"

import { demoAdapter, useSampleFiles } from "@/examples/_demo"
import {
  Upload,
  UploadDropzone,
  UploadDropzoneDescription,
  UploadDropzoneHeader,
  UploadDropzoneMedia,
  UploadDropzoneTitle,
  UploadItem,
  UploadItemActions,
  UploadItemContent,
  UploadItemDescription,
  UploadItemMedia,
  UploadItemProgress,
  UploadItemTitle,
  UploadList,
} from "@/components/ui/upload"

export default function UploadDemo() {
  const uploader = useUploader({ adapter: demoAdapter, maxFiles: 8 })
  useSampleFiles(uploader)

  return (
    <Upload uploader={uploader}>
      <UploadDropzone>
        <UploadDropzoneHeader>
          <UploadDropzoneMedia variant="icon" />
          <UploadDropzoneTitle>Drop files here</UploadDropzoneTitle>
          <UploadDropzoneDescription>
            or click to browse · up to 8 files
          </UploadDropzoneDescription>
        </UploadDropzoneHeader>
      </UploadDropzone>
      <UploadList>
        <UploadItem>
          <UploadItemMedia />
          <UploadItemContent>
            <UploadItemTitle />
            <UploadItemDescription />
          </UploadItemContent>
          <UploadItemActions />
          <UploadItemProgress />
        </UploadItem>
      </UploadList>
    </Upload>
  )
}

Installation

npx shadcn@latest add @uploadcn/upload

Usage

import { s3Adapter } from "@uploadcn/core"

import {
  Upload,
  UploadDropzone,
  UploadDropzoneDescription,
  UploadDropzoneHeader,
  UploadDropzoneMedia,
  UploadDropzoneTitle,
  UploadItem,
  UploadItemActions,
  UploadItemContent,
  UploadItemDescription,
  UploadItemMedia,
  UploadItemProgress,
  UploadItemTitle,
  UploadList,
} from "@/components/ui/upload"
<Upload adapter={s3Adapter({ endpoint: "/api/upload" })} maxFiles={8}>
  <UploadDropzone>
    <UploadDropzoneHeader>
      <UploadDropzoneMedia variant="icon" />
      <UploadDropzoneTitle>Drop files here</UploadDropzoneTitle>
      <UploadDropzoneDescription>or click to browse</UploadDropzoneDescription>
    </UploadDropzoneHeader>
  </UploadDropzone>
  <UploadList>
    <UploadItem>
      <UploadItemMedia />
      <UploadItemContent>
        <UploadItemTitle />
        <UploadItemDescription />
      </UploadItemContent>
      <UploadItemActions />
      <UploadItemProgress />
    </UploadItem>
  </UploadList>
</Upload>

Composition

Upload holds the uploader. Everything else is a part you place where you want it.

Uploadholds the uploader, options or an `uploader` prop
  • UploadDropzonedrag, drop, click, Enter / Space
    • UploadDropzoneHeader
      • UploadDropzoneMedia
      • UploadDropzoneTitle
      • UploadDropzoneDescription
    • UploadDropzoneContentbuttons, e.g. UploadTrigger
  • UploadTriggeropens the file picker
  • UploadOverlayturns any content into a drop target
  • UploadSlota named single-file slot, e.g. “ID front”
  • UploadListrenders its children once per file
    • UploadItem
      • UploadItemMediathumbnail or file-type icon
      • UploadItemContent
        • UploadItemTitlefile name
        • UploadItemDescriptionsize · progress · speed · error
      • UploadItemActionspause, resume, retry, cancel, remove
      • UploadItemProgress
  • UploadHeader / UploadFooter
    • UploadSummary“3 files · 12.4 MB · 45% · 8s left”
  • UploadProgressall files combined
  • UploadEmpty / UploadStart / UploadClear

UploadList renders its children as a template for every file and provides that file to the parts inside. There's no render function or prop drilling. Each row re-renders only when its own file changes.

Parts have sensible defaults and accept children to override them:

<UploadItemTitle />                         {/* the file name */}
<UploadItemTitle>Invoice #1042</UploadItemTitle>

<UploadItemActions />                       {/* the applicable actions */}
<UploadItemActions>
  <UploadItemRetry />
  <UploadItemRemove />
</UploadItemActions>

Dropzone

Variant

Use variant to change the surface: default (dashed), muted or outline.

"use client"

import { FileTextIcon, ImageIcon } from "lucide-react"

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

export default function UploadDropzoneVariants() {
  return (
    <div className="grid w-full gap-4 sm:grid-cols-3">
      <Upload adapter={demoAdapter}>
        <UploadDropzone size="sm">
          <UploadDropzoneHeader>
            <UploadDropzoneMedia variant="icon" />
            <UploadDropzoneTitle>Default</UploadDropzoneTitle>
            <UploadDropzoneDescription>Dashed border</UploadDropzoneDescription>
          </UploadDropzoneHeader>
        </UploadDropzone>
      </Upload>
      <Upload adapter={demoAdapter}>
        <UploadDropzone variant="muted" size="sm">
          <UploadDropzoneHeader>
            <UploadDropzoneMedia variant="icon">
              <ImageIcon />
            </UploadDropzoneMedia>
            <UploadDropzoneTitle>Muted</UploadDropzoneTitle>
            <UploadDropzoneDescription>
              Filled surface
            </UploadDropzoneDescription>
          </UploadDropzoneHeader>
        </UploadDropzone>
      </Upload>
      <Upload adapter={demoAdapter}>
        <UploadDropzone variant="outline" size="sm">
          <UploadDropzoneHeader>
            <UploadDropzoneMedia>
              <FileTextIcon />
            </UploadDropzoneMedia>
            <UploadDropzoneTitle>Outline</UploadDropzoneTitle>
            <UploadDropzoneDescription>Card surface</UploadDropzoneDescription>
          </UploadDropzoneHeader>
        </UploadDropzone>
      </Upload>
    </div>
  )
}

Orientation

orientation="horizontal" puts the content side by side, handy for compact forms. Use clickable={false} when the dropzone contains its own UploadTrigger.

"use client"

import { PaperclipIcon } from "lucide-react"

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

export default function UploadDropzoneHorizontal() {
  return (
    <Upload adapter={demoAdapter}>
      <UploadDropzone orientation="horizontal" clickable={false}>
        <UploadDropzoneHeader>
          <UploadDropzoneMedia variant="icon">
            <PaperclipIcon />
          </UploadDropzoneMedia>
          <div className="flex flex-col gap-0.5">
            <UploadDropzoneTitle>Attachments</UploadDropzoneTitle>
            <UploadDropzoneDescription>
              Drag files here or browse
            </UploadDropzoneDescription>
          </div>
        </UploadDropzoneHeader>
        <UploadDropzoneContent>
          <UploadTrigger>Browse files</UploadTrigger>
        </UploadDropzoneContent>
      </UploadDropzone>
      <UploadQueue />
    </Upload>
  )
}

Size

size is sm, default or lg.

<UploadDropzone size="sm">…</UploadDropzone>

Item

Variant

outline (default), muted, default (no surface), and tile for grids.

"use client"

import { useUploader } from "@uploadcn/react"

import { demoAdapter, useSampleFiles } from "@/examples/_demo"
import {
  Upload,
  UploadItem,
  UploadItemActions,
  UploadItemContent,
  UploadItemDescription,
  UploadItemMedia,
  UploadItemProgress,
  UploadItemTitle,
  UploadList,
} from "@/components/ui/upload"

const VARIANTS = ["outline", "muted", "default"] as const

export default function UploadItemVariants() {
  const uploader = useUploader({ adapter: demoAdapter })
  useSampleFiles(uploader, "documents")

  return (
    <Upload uploader={uploader} className="gap-6">
      {VARIANTS.map((variant) => (
        <section key={variant} className="flex flex-col gap-2">
          <h3 className="text-xs font-medium text-muted-foreground">
            {variant}
          </h3>
          <UploadList>
            <UploadItem variant={variant}>
              <UploadItemMedia />
              <UploadItemContent>
                <UploadItemTitle />
                <UploadItemDescription />
              </UploadItemContent>
              <UploadItemActions />
              <UploadItemProgress />
            </UploadItem>
          </UploadList>
        </section>
      ))}
    </Upload>
  )
}

Size

default, sm and xs. Media, text and actions scale with the item.

"use client"

import { useUploader } from "@uploadcn/react"

import { demoAdapter, useSampleFiles } from "@/examples/_demo"
import {
  Upload,
  UploadItem,
  UploadItemActions,
  UploadItemContent,
  UploadItemDescription,
  UploadItemMedia,
  UploadItemProgress,
  UploadItemTitle,
  UploadList,
} from "@/components/ui/upload"

const SIZES = ["default", "sm", "xs"] as const

export default function UploadItemSizes() {
  const uploader = useUploader({ adapter: demoAdapter })
  useSampleFiles(uploader)

  return (
    <Upload uploader={uploader} className="gap-6">
      {SIZES.map((size) => (
        <section key={size} className="flex flex-col gap-2">
          <h3 className="text-xs font-medium text-muted-foreground">{size}</h3>
          <UploadList>
            <UploadItem size={size}>
              <UploadItemMedia />
              <UploadItemContent>
                <UploadItemTitle />
                <UploadItemDescription />
              </UploadItemContent>
              <UploadItemActions />
              <UploadItemProgress />
            </UploadItem>
          </UploadList>
        </section>
      ))}
    </Upload>
  )
}

Tiles

Combine UploadList variant="grid" with UploadItem variant="tile" and UploadItemMedia variant="cover".

"use client"

import { useUploader } from "@uploadcn/react"

import { demoAdapter, useSampleFiles } from "@/examples/_demo"
import {
  Upload,
  UploadDropzone,
  UploadDropzoneHeader,
  UploadDropzoneMedia,
  UploadDropzoneTitle,
  UploadItem,
  UploadItemActions,
  UploadItemContent,
  UploadItemMedia,
  UploadItemProgress,
  UploadItemStatus,
  UploadList,
} from "@/components/ui/upload"

export default function UploadGrid() {
  const uploader = useUploader({ adapter: demoAdapter, accept: "image/*" })
  useSampleFiles(uploader, "images")

  return (
    <Upload uploader={uploader}>
      <UploadList variant="grid">
        <UploadItem variant="tile">
          <UploadItemMedia variant="cover" />
          <UploadItemActions />
          <UploadItemContent>
            <UploadItemStatus />
            <UploadItemProgress />
          </UploadItemContent>
        </UploadItem>
      </UploadList>
      <UploadDropzone size="sm">
        <UploadDropzoneHeader>
          <UploadDropzoneMedia />
          <UploadDropzoneTitle>Add more photos</UploadDropzoneTitle>
        </UploadDropzoneHeader>
      </UploadDropzone>
    </Upload>
  )
}

Chips

UploadList variant="inline" with extra-small items makes attachment chips.

"use client"

import { useUploader } from "@uploadcn/react"
import { PlusIcon } from "lucide-react"

import { demoAdapter, useSampleFiles } from "@/examples/_demo"
import {
  Upload,
  UploadItem,
  UploadItemActions,
  UploadItemContent,
  UploadItemMedia,
  UploadItemProgress,
  UploadItemRemove,
  UploadItemTitle,
  UploadList,
  UploadTrigger,
} from "@/components/ui/upload"

export default function UploadChips() {
  const uploader = useUploader({ adapter: demoAdapter })
  useSampleFiles(uploader)

  return (
    <Upload uploader={uploader}>
      <UploadList variant="inline">
        <UploadItem
          variant="muted"
          size="xs"
          className="w-44 rounded-full pe-1"
        >
          <UploadItemMedia className="rounded-full" />
          <UploadItemContent>
            <UploadItemTitle />
          </UploadItemContent>
          <UploadItemActions>
            <UploadItemRemove className="rounded-full" />
          </UploadItemActions>
          <UploadItemProgress className="absolute inset-x-3 bottom-0 h-0.5 w-auto" />
        </UploadItem>
      </UploadList>
      <UploadTrigger variant="ghost" className="self-start rounded-full">
        <PlusIcon data-icon="inline-start" />
        Attach
      </UploadTrigger>
    </Upload>
  )
}

Trigger

UploadTrigger renders an outline Button. Pass render to use any element.

"use client"

import { UploadIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { demoAdapter } from "@/examples/_demo"
import {
  Upload,
  UploadQueue,
  UploadTrigger,
} from "@/components/ui/upload"

export default function UploadTriggerExample() {
  return (
    <Upload adapter={demoAdapter}>
      <div className="flex flex-wrap items-center gap-2">
        <UploadTrigger render={<Button />}>
          <UploadIcon data-icon="inline-start" />
          Upload
        </UploadTrigger>
        <UploadTrigger variant="outline" size="default">
          Choose files
        </UploadTrigger>
        <UploadTrigger render={<Button variant="link" />}>
          or browse your computer
        </UploadTrigger>
      </div>
      <UploadQueue variant="muted" size="sm" />
    </Upload>
  )
}

Overlay

UploadOverlay makes any content a drop target without making it a button. A frosted layer with a label covers the content only while files are dragged over it, so text stays selectable and links stay clickable.

"use client"

import { FileTextIcon } from "lucide-react"

import { demoAdapter } from "@/examples/_demo"
import {
  Upload,
  UploadOverlay,
  UploadQueue,
} from "@/components/ui/upload"

export default function UploadOverlayExample() {
  return (
    <Upload adapter={demoAdapter} className="max-w-lg">
      <UploadOverlay
        className="rounded-xl border bg-card p-5"
        label="Drop to attach"
        description="Files are added to this note"
      >
        <article className="flex flex-col gap-2">
          <div className="flex items-center gap-2 text-sm font-medium">
            <FileTextIcon className="size-4 text-muted-foreground" />
            Launch checklist
          </div>
          <p className="text-sm text-muted-foreground">
            Drag files from your desktop onto this card. The content stays
            selectable and clickable, the overlay only appears while you drag.
          </p>
        </article>
      </UploadOverlay>
      <UploadQueue size="sm" variant="muted" />
    </Upload>
  )
}
<UploadOverlay
  label="Drop to attach"
  description="Files are added to this note"
>
  <Note />
</UploadOverlay>

Slots

UploadSlot is a named, single-file slot inside a shared <Upload>, for forms that need specific documents. Each slot has its own picker and drop target, replaces its own file, and tags it with meta.slot so your backend knows which is which. Read a slot's file with useUploadSlot(name).

"use client"

import { HomeIcon, IdCardIcon } from "lucide-react"

import { Upload, UploadSlot } from "@/components/ui/upload"

export default function UploadSlotsExample() {
  return (
    <Upload className="max-w-md">
      <UploadSlot
        name="id"
        label="Photo ID"
        description="Passport or driver's license"
        accept="image/*,application/pdf"
        icon={<IdCardIcon />}
        required
      />
      <UploadSlot
        name="address"
        label="Proof of address"
        description="Utility bill or bank statement"
        accept="application/pdf,image/*"
        icon={<HomeIcon />}
      />
    </Upload>
  )
}
<Upload>
  <UploadSlot
    name="id"
    label="Photo ID"
    accept="image/*,application/pdf"
    required
  />
  <UploadSlot name="address" label="Proof of address" />
</Upload>

UploadHeader and UploadFooter lay out titles and queue actions. UploadSummary describes the queue in one line and renders nothing while it's empty.

"use client"

import { useUploader } from "@uploadcn/react"

import { demoAdapter, useSampleFiles } from "@/examples/_demo"
import {
  Upload,
  UploadClear,
  UploadDropzone,
  UploadDropzoneDescription,
  UploadDropzoneTitle,
  UploadFooter,
  UploadHeader,
  UploadProgress,
  UploadQueue,
  UploadSummary,
  UploadTrigger,
} from "@/components/ui/upload"

export default function UploadCardExample() {
  const uploader = useUploader({ adapter: demoAdapter, maxFiles: 10 })
  useSampleFiles(uploader)
  return (
    <Upload
      uploader={uploader}
      className="max-w-lg rounded-xl border bg-card p-4 shadow-xs"
    >
      <UploadHeader>
        <div className="flex flex-col gap-0.5">
          <h3 className="text-sm font-medium">Attachments</h3>
          <UploadSummary />
        </div>
        <UploadTrigger size="sm" variant="outline">
          Browse
        </UploadTrigger>
      </UploadHeader>
      <UploadProgress />
      <UploadDropzone size="sm" variant="muted">
        <UploadDropzoneTitle>Drop more files</UploadDropzoneTitle>
        <UploadDropzoneDescription>Up to 10 files</UploadDropzoneDescription>
      </UploadDropzone>
      <UploadQueue size="sm" />
      <UploadFooter>
        <UploadClear>Clear finished</UploadClear>
      </UploadFooter>
    </Upload>
  )
}

Accessibility

  • UploadDropzone is a button: it's focusable and opens the picker with Enter or Space, the alternative to dragging.
  • UploadItemProgress is a progressbar labelled with the file name.
  • A polite live region announces added, finished, failed and rejected files.
  • Every action has a name such as “Retry upload of report.pdf”.
  • Motion respects prefers-reduced-motion.

API Reference

Upload

The root. Accepts every uploader option, adapter, accept, maxSize, maxFiles, concurrency, retry, transform, process, persistence…, or an uploader from useUploader.

Prop

Type

UploadDropzone

Prop

Type

Data attributes: data-dragging, data-drag-reject, data-disabled.

UploadDropzoneMedia

Prop

Type

UploadList

Prop

Type

UploadItem

Prop

Type

Data attributes: data-status (uploading, success, error…), data-variant, data-size.

UploadItemMedia

Prop

Type

UploadItemProgress

Prop

Type

UploadOverlay

Prop

Type

UploadSlot

Prop

Type

UploadSummary

Pass children to replace the text. Without children it renders nothing for an empty queue.

Actions

UploadItemPause, UploadItemResume, UploadItemRetry, UploadItemCancel and UploadItemRemove render only when they apply, pause only for resumable adapters. They accept Button props (variant, size) and forceMount.

Headless

The same parts without styles live in @uploadcn/react, see the custom UI example.

On this page