file uploads your app owns: progress, cancel, retry, local previews
npx shadcn add @ag/uploadAlready installed? Preview an update
npx shadcn add @ag/upload --dry-runYour 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.
the demo above, verbatim
"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
"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 })
},
}
}