← ui

upload

ui

file uploads your app owns: progress, cancel, retry, local previews

npx shadcn add @ag/upload
Already installed? Preview an update
npx shadcn add @ag/upload --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-09-08
  • Added a typed uploader contract, multipart transport, and a React hook with cancellation, retries, progress, preparation status and local preview cleanup.
Updated 2026-09-08 · release notes

also called dropzone, file input

shelf media

uses media-asset

used by editor

the demo above, verbatim

upload.demo.tsx
"use client"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { type MediaUploader, useMediaUpload } from "./upload"

// #region local
// Demonstrates the UI without a public upload service. Replace this adapter with
// multipartUploader('/api/upload') to use an application-owned, authorized endpoint.
const local: MediaUploader = async (file, { signal, onProgress }) => {
  if (
    !/^image\/(png|jpeg|webp)$/.test(file.type) ||
    file.size > 8 * 1024 * 1024
  )
    throw new Error("Choose a PNG, JPEG or WebP under 8 MB")
  signal.throwIfAborted()
  const bitmap = await createImageBitmap(file)
  const { width, height } = bitmap
  bitmap.close()
  const src = await new Promise<string>((resolve, reject) => {
    const reader = new FileReader()
    const abort = () => reader.abort()
    signal.addEventListener("abort", abort, { once: true })
    reader.onloadend = () => signal.removeEventListener("abort", abort)
    reader.onload = () => resolve(String(reader.result))
    reader.onerror = () => reject(reader.error)
    reader.onabort = () => reject(new DOMException("Cancelled", "AbortError"))
    reader.readAsDataURL(file)
  })
  signal.throwIfAborted()
  onProgress?.(1)
  return {
    src,
    width,
    height,
    kind: "image",
    mime: file.type,
    bytes: file.size,
  }
}
// #endregion
export default function Demo() {
  const upload = useMediaUpload(local)
  return (
    <Sample
      name="upload"
      glance
      with="local"
      title="local demonstration"
      description="no file leaves your browser"
    >
      <div className="space-y-4">
        <input
          aria-label="Choose image"
          type="file"
          accept="image/png,image/jpeg,image/webp"
          className="max-w-full"
          onChange={(e) => {
            const file = e.target.files?.[0]
            if (file) void upload.start(file)
            e.target.value = ""
          }}
        />
        <p role="status">{upload.error ?? upload.status}</p>
        {(upload.status === "uploading" || upload.status === "processing") && (
          <progress
            value={upload.progress}
            max={1}
            aria-label="Upload progress"
          />
        )}
        {upload.status === "error" && (
          <Button size="sm" onClick={() => void upload.retry()}>
            retry
          </Button>
        )}
        {upload.status !== "idle" && (
          <Button size="sm" variant="ghost" onClick={upload.cancel}>
            clear
          </Button>
        )}
        {(upload.asset?.src ?? upload.preview) && (
          // biome-ignore lint/performance/noImgElement: local object/data URL preview, no remote optimizer
          <img
            src={upload.asset?.src ?? upload.preview}
            alt="Your selected file"
            width={upload.asset?.width}
            height={upload.asset?.height}
            className="max-h-80 max-w-full rounded-xl"
          />
        )}
      </div>
    </Sample>
  )
}

the source, every file an install writes

upload.tsx
"use client"

import { useEffect, useRef, useState } from "react"
import {
  isMediaAsset,
  type MediaAsset,
} from "@/registry/base-nova/lib/media-asset"

export type UploadOptions = {
  signal: AbortSignal
  onProgress?: (fraction: number) => void
}
export type MediaUploader = (
  file: File,
  options: UploadOptions,
) => Promise<MediaAsset>

/** Transport for an application-owned multipart endpoint returning MediaAsset JSON.
 * Other providers implement MediaUploader; neither the UI nor editor knows the store. */
export function multipartUploader(endpoint: string): MediaUploader {
  return (file, { signal, onProgress }) =>
    new Promise((resolve, reject) => {
      signal.throwIfAborted()
      const xhr = new XMLHttpRequest()
      const abort = () => xhr.abort()
      const finish = () => signal.removeEventListener("abort", abort)
      xhr.open("POST", endpoint)
      xhr.responseType = "json"
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) onProgress?.(e.loaded / e.total)
      }
      xhr.onload = () => {
        finish()
        if (xhr.status >= 200 && xhr.status < 300 && isMediaAsset(xhr.response))
          resolve(xhr.response)
        else
          reject(
            new Error(xhr.response?.error ?? `Upload failed (${xhr.status})`),
          )
      }
      xhr.onerror = () => {
        finish()
        reject(new Error("Upload failed: network unavailable"))
      }
      xhr.onabort = () => {
        finish()
        reject(new DOMException("Upload cancelled", "AbortError"))
      }
      signal.addEventListener("abort", abort, { once: true })
      const body = new FormData()
      body.append("file", file)
      xhr.send(body)
    })
}

/** One selected asset, with local preview, progress, cancellation and explicit retry.
 * A completed transfer can still be processing; only the resolved asset is publishable. */
export function useMediaUpload(upload: MediaUploader) {
  const controller = useRef<AbortController | null>(null)
  const file = useRef<File | null>(null)
  const previewRef = useRef<string | null>(null)
  const [state, setState] = useState<{
    status: "idle" | "uploading" | "processing" | "ready" | "error"
    progress: number
    preview?: string
    asset?: MediaAsset
    error?: string
  }>({ status: "idle", progress: 0 })
  const clear = () => {
    controller.current?.abort()
    controller.current = null
    if (previewRef.current) URL.revokeObjectURL(previewRef.current)
    previewRef.current = null
  }
  useEffect(
    () => () => {
      controller.current?.abort()
      controller.current = null
      if (previewRef.current) URL.revokeObjectURL(previewRef.current)
    },
    [],
  )
  async function start(next: File) {
    clear()
    file.current = next
    const active = new AbortController()
    controller.current = active
    const preview = URL.createObjectURL(next)
    previewRef.current = preview
    setState({ status: "uploading", progress: 0, preview })
    try {
      const asset = await upload(next, {
        signal: active.signal,
        onProgress: (progress) => {
          if (controller.current === active)
            setState({
              status: progress >= 1 ? "processing" : "uploading",
              progress,
              preview,
            })
        },
      })
      if (controller.current !== active) return
      URL.revokeObjectURL(preview)
      previewRef.current = null
      setState({ status: "ready", progress: 1, asset })
      return asset
    } catch (error) {
      if (controller.current === active)
        setState({
          status: "error",
          progress: 0,
          preview,
          error: error instanceof Error ? error.message : "Upload failed",
        })
    }
  }
  return {
    ...state,
    start,
    retry: () => file.current && start(file.current),
    cancel: () => {
      clear()
      file.current = null
      setState({ status: "idle", progress: 0 })
    },
  }
}