uploaderkit@2.0.0
Playground
Live demonstrations of the published package. Every example imports the exact version available on npm.
Single file
One dropzone, one file. Validation, upload and the stored descriptor the server returns.
Document
Choose a file or drag it here
import { useState } from "react";
import { ES_LABELS, type StoredFile } from "uploaderkit";
import { Uploader } from "uploaderkit/ui";
import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";
import { ResultPanel, Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";
const strategy = createFakeStrategy();
/** One zone, one file. The default everything: validate → upload → done. */
export const BasicCase = ({ locale }: { locale: Locale }) => {
const [saved, setSaved] = useState<StoredFile[]>([]);
return (
<Split
main={
<Uploader
scopes={demoScopes}
scope="demo-document"
entityId="basic"
strategy={strategy}
label={locale === "es" ? "Documento" : "Document"}
shortcut="mod+u"
labels={locale === "es" ? ES_LABELS : undefined}
onUploaded={(stored) => setSaved((prev) => [...prev, ...stored])}
/>
}
aside={
saved.length > 0 ? (
<ResultPanel label="StoredFile" data={saved.at(-1)} />
) : undefined
}
/>
);
};Multiple files
Batch progress, per-file cancellation and a maximum file count enforced before anything is sent.
Images (up to 4)
Choose a file or drag it here
.png · .jpg · .jpeg · .webp
import { useState } from "react";
import { ES_LABELS, type StoredFile } from "uploaderkit";
import { Uploader } from "uploaderkit/ui";
import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";
import { ResultPanel, Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";
const strategy = createFakeStrategy({ duration: 2600 });
/** Many files: batch progress, per-file abort, a `maxFiles` cap, and the
* stored list living outside the uploader. */
export const MultipleCase = ({ locale }: { locale: Locale }) => {
const [saved, setSaved] = useState<StoredFile[]>([]);
return (
<Split
main={
<Uploader
scopes={demoScopes}
scope="demo-image"
entityId="multi"
strategy={strategy}
multiple
maxFiles={4}
label={locale === "es" ? "Imágenes (máximo 4)" : "Images (up to 4)"}
labels={locale === "es" ? ES_LABELS : undefined}
stored={saved}
onRemoveStored={(file) =>
setSaved((prev) => prev.filter((f) => f.key !== file.key))
}
onUploaded={(stored) => setSaved((prev) => [...prev, ...stored])}
/>
}
aside={
saved.length > 0 ? (
<ResultPanel
label={`StoredFile[] · ${saved.length}`}
data={saved.map((f) => f.key)}
/>
) : undefined
}
/>
);
};Avatar preset
The picture is the control: drop an image on it, it compresses to 512px and replaces the previous one.
Ricardo Tapia
Click the picture or drop an image on it
import { useState } from "react";
import { ES_LABELS } from "uploaderkit";
import { AvatarUploader } from "uploaderkit/presets";
import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";
import { ResultPanel, Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";
const strategy = createFakeStrategy({ duration: 2200 });
/** The avatar recipe as one component: the picture is the control, a drop
* lands on it, a ring closes while it uploads. */
export const AvatarCase = ({ locale }: { locale: Locale }) => {
const [url, setUrl] = useState<string | null>(null);
const [key, setKey] = useState<string | null>(null);
return (
<Split
main={
<div className="flex items-center gap-5">
<AvatarUploader
scopes={demoScopes}
scope="demo-avatar"
entityId="user-42"
strategy={strategy}
src={url}
fallback="RT"
size={96}
labels={locale === "es" ? ES_LABELS : undefined}
onUploaded={(stored) => {
setUrl(stored.url);
setKey(stored.key);
}}
onRemove={() => {
setUrl(null);
setKey(null);
}}
/>
<div>
<p className="text-sm font-medium text-gray-800">Ricardo Tapia</p>
<p className="text-xs text-gray-500">
{locale === "es"
? "Toca la foto o suelta una imagen encima"
: "Click the picture or drop an image on it"}
</p>
</div>
</div>
}
aside={
key ? <ResultPanel label="StoredFile.key" data={key} /> : undefined
}
/>
);
};Gallery preset
Tiles built from each file's own preview, with a full-screen viewer across the set.
import { useState } from "react";
import { ES_LABELS, type StoredFile } from "uploaderkit";
import { GalleryUploader } from "uploaderkit/presets";
import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";
import { ResultPanel, Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";
const strategy = createFakeStrategy({ duration: 1800 });
/** Tiles from each file's own preview, hover actions, the add tile as the
* dropzone, and a full-screen viewer over the whole set. */
export const GalleryCase = ({ locale }: { locale: Locale }) => {
const [stored, setStored] = useState<StoredFile[]>([]);
return (
<Split
main={
<GalleryUploader
scopes={demoScopes}
scope="demo-image"
entityId="gallery"
strategy={strategy}
stored={stored}
labels={locale === "es" ? ES_LABELS : undefined}
onUploaded={(files) => setStored((cur) => [...cur, ...files])}
onRemoveStored={(file) =>
setStored((cur) => cur.filter((f) => f.key !== file.key))
}
/>
}
aside={
stored.length > 0 ? (
<ResultPanel
label={`StoredFile[] · ${stored.length}`}
data={stored.map((f) => f.key)}
/>
) : undefined
}
/>
);
};Validation
Wrong extension, oversized file and a renamed file caught by its binary signature — all before upload.
Strict PDF
Choose a file or drag it here
.pdf · up to 1 MB · binary signature verified
import { useState } from "react";
import { ES_LABELS } from "uploaderkit";
import { Uploader } from "uploaderkit/ui";
import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";
import { ResultPanel, Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";
const ok = createFakeStrategy();
const failing = createFakeStrategy({
duration: 900,
failWith: "The server rejected the file",
});
/** Wrong extension, oversized file and a spoofed signature are caught before a
* byte leaves; toggle the checkbox to see a server that answers an error too. */
export const ValidationCase = ({ locale }: { locale: Locale }) => {
const [serverFails, setServerFails] = useState(false);
const [rejections, setRejections] = useState<string[]>([]);
const es = locale === "es";
return (
<Split
main={
<>
<label className="flex cursor-pointer items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={serverFails}
onChange={(e) => setServerFails(e.target.checked)}
/>
{es
? "Simular rechazo del servidor"
: "Simulate a server rejection"}
</label>
<Uploader
scopes={demoScopes}
scope="demo-strict-pdf"
entityId="validation"
strategy={serverFails ? failing : ok}
label={es ? "PDF estricto" : "Strict PDF"}
description={
es
? ".pdf · máximo 1 MB · firma binaria verificada"
: ".pdf · up to 1 MB · binary signature verified"
}
labels={es ? ES_LABELS : undefined}
onError={(message) => setRejections((prev) => [...prev, message])}
/>
</>
}
aside={
rejections.length > 0 ? (
<ResultPanel label="onError" data={rejections} />
) : undefined
}
/>
);
};Retry and concurrency
A simulated flaky network that fails twice per file; retry with backoff absorbs it, a concurrency cap queues the rest.
Images over a flaky network
Choose a file or drag it here
Drop several — at most 2 in flight
import { useMemo, useState } from "react";
import { ES_LABELS } from "uploaderkit";
import { Uploader } from "uploaderkit/ui";
import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";
import { Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";
/** A flaky network fails the first two attempts per file. With retry the
* hook absorbs both behind exponential backoff; `concurrency: 2` queues the
* rest. Remounting on toggle resets the attempt counters. */
export const RetryCase = ({ locale }: { locale: Locale }) => {
const [withRetry, setWithRetry] = useState(true);
const strategy = useMemo(
() => createFakeStrategy({ duration: 700, failTimes: 2 }),
[],
);
const es = locale === "es";
return (
<Split
main={
<>
<label className="flex cursor-pointer items-center gap-2 font-mono text-sm text-gray-700">
<input
type="checkbox"
checked={withRetry}
onChange={(e) => setWithRetry(e.target.checked)}
/>
retry: {"{ attempts: 3, backoffMs: 400 }"}
</label>
<Uploader
key={String(withRetry)}
scopes={demoScopes}
scope="demo-image"
entityId="retry"
strategy={strategy}
multiple
concurrency={2}
{...(withRetry ? { retry: { attempts: 3, backoffMs: 400 } } : {})}
label={
es
? "Imágenes sobre una red inestable"
: "Images over a flaky network"
}
description={
es
? "Suelta varias — máximo 2 en vuelo"
: "Drop several — at most 2 in flight"
}
labels={es ? ES_LABELS : undefined}
/>
</>
}
/>
);
};File viewer on its own
FileViewer with no uploader attached: images, PDFs and a fallback, reusable anywhere an app already has stored files.
Open any of them and use the arrow keys: the viewer receives the whole set.
import { Eye, FileText, Image as ImageIcon } from "lucide-react";
import { useState } from "react";
import { ES_LABELS } from "uploaderkit";
import { FileViewer, type ViewableFile } from "uploaderkit/ui";
import type { Locale } from "@/lib/i18n/config";
/**
* `FileViewer` on its own, with no uploader attached.
*
* It is a standalone overlay: hand it a file (or a set to page through) and it
* renders images, PDFs and the fallback for anything it cannot display inline.
* That makes it reusable anywhere an app already has stored files — a record
* detail, an attachments table — not only next to an upload zone.
*
* `resolveUrl` is the hook for authenticated reads: it runs per open, so a
* short-lived signed URL is fetched when the viewer needs it rather than
* embedded in the page.
*/
const FILES: ViewableFile[] = [
{
url: "https://images.unsplash.com/photo-1447933601403-0c6688de566e?w=1200",
fileName: "coffee.jpg",
mimeType: "image/jpeg",
},
{
url: "https://images.unsplash.com/photo-1511920170033-f8396924c348?w=1200",
fileName: "espresso.jpg",
mimeType: "image/jpeg",
},
{
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
fileName: "invoice.pdf",
mimeType: "application/pdf",
},
];
export const ViewerCase = ({ locale }: { locale: Locale }) => {
const [open, setOpen] = useState<ViewableFile | null>(null);
const es = locale === "es";
return (
<div className="space-y-4">
<p className="text-fd-muted-foreground text-sm">
{es
? "Abre cualquiera y navega con las flechas: el visor recibe el conjunto completo."
: "Open any of them and use the arrow keys: the viewer receives the whole set."}
</p>
<ul className="grid gap-2 sm:grid-cols-3">
{FILES.map((file) => (
<li key={file.url}>
<button
type="button"
onClick={() => setOpen(file)}
className="border-fd-border hover:border-fd-primary hover:text-fd-primary btn-lift flex w-full items-center gap-2 rounded-lg border px-3 py-2.5 text-left text-sm"
>
{file.mimeType === "application/pdf" ? (
<FileText className="size-4 shrink-0" />
) : (
<ImageIcon className="size-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{file.fileName}
</span>
<Eye className="size-3.5 shrink-0 opacity-60" />
</button>
</li>
))}
</ul>
<FileViewer
file={open}
files={FILES}
onClose={() => setOpen(null)}
labels={es ? ES_LABELS : undefined}
/>
</div>
);
};Headless
No shipped UI at all: the hook drives a fully custom interface with its own progress ring and controls.
import { useRef } from "react";
import { formatFileSize } from "uploaderkit";
import { useUploader } from "uploaderkit/react";
import { demoScopes } from "@/lib/playground/scopes";
import { createFakeStrategy } from "./fakeStrategy";
const strategy = createFakeStrategy({ duration: 2200 });
/** No shipped UI: the hook drives a fully custom interface — manual `upload()`,
* a per-file progress ring and an abort button. */
export const HeadlessCase = () => {
const inputRef = useRef<HTMLInputElement>(null);
const uploader = useUploader({
scopes: demoScopes,
scope: "demo-image",
entityId: "headless",
strategy,
multiple: true,
uploadOn: "manual",
});
const btn = "rounded-full px-4 py-2 text-sm font-medium";
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<input
ref={inputRef}
type="file"
accept={uploader.accept}
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) void uploader.addFiles(e.target.files);
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className={`${btn} bg-[#0e7490] text-white hover:bg-[#155e75]`}
>
Choose images
</button>
<button
type="button"
onClick={() => void uploader.upload()}
disabled={uploader.isUploading || uploader.files.length === 0}
className={`${btn} border border-[#0e7490] text-[#0e7490] disabled:opacity-40`}
>
Upload all
</button>
{uploader.isUploading && (
<button
type="button"
onClick={() => uploader.abort()}
className="text-sm text-red-600 hover:underline"
>
Cancel
</button>
)}
</div>
<ul className="space-y-1">
{uploader.files.map((file) => (
<li
key={file.id}
className="flex items-center gap-3 rounded-lg bg-cyan-50 px-3 py-2 text-sm"
>
<span
className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-xs font-semibold text-white"
style={{
background: `conic-gradient(#0e7490 ${file.progress * 3.6}deg, #cffafe 0deg)`,
}}
>
{file.status === "success" ? "✓" : file.progress}
</span>
<span className="min-w-0 flex-1 truncate">{file.file.name}</span>
<span className="text-xs text-gray-500">
{formatFileSize(file.file.size)}
</span>
<button
type="button"
onClick={() => uploader.removeFile(file.id)}
className="text-xs text-gray-400 hover:text-red-600"
>
✕
</button>
</li>
))}
</ul>
</div>
);
};Real server round trip
This one posts to a route handler on this site backed by the in-memory provider. Nothing is persisted.
Product image
Choose a file or drag it here
PNG, JPG or WebP up to 2 MB. Downscaled to 1280px before upload.
Invoice PDF
Choose a file or drag it here
PDF only, up to 2 MB, two files max.
What the server returned
Nothing uploaded yet. The panel fills with the stored descriptor.
Stored in memory and discarded immediately — nothing is persisted.
import { useState } from "react";
import { ES_LABELS, type StoredFile } from "uploaderkit";
import { createXhrUploadStrategy } from "uploaderkit/react";
import { Uploader } from "uploaderkit/ui";
import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";
const COPY = {
en: {
imageLabel: "Product image",
imageHint:
"PNG, JPG or WebP up to 2 MB. Downscaled to 1280px before upload.",
docLabel: "Invoice PDF",
docHint: "PDF only, up to 2 MB, two files max.",
result: "What the server returned",
empty: "Nothing uploaded yet. The panel fills with the stored descriptor.",
note: "Stored in memory and discarded immediately — nothing is persisted.",
failed: "Upload rejected",
},
es: {
imageLabel: "Imagen de producto",
imageHint: "PNG, JPG o WebP hasta 2 MB. Se reduce a 1280px antes de subir.",
docLabel: "Factura en PDF",
docHint: "Solo PDF, hasta 2 MB, máximo dos archivos.",
result: "Lo que devolvió el servidor",
empty: "Nada subido aún. El panel se llena con el descriptor almacenado.",
note: "Se guarda en memoria y se descarta de inmediato — nada se persiste.",
failed: "Subida rechazada",
},
} as const;
/**
* The one case that posts to the real route handler, backed by the package's
* in-memory provider; every other case uses `createFakeStrategy`. Each upload is a self-contained
* round trip: the result panel shows the descriptor the server returned rather
* than a persistent gallery, because on serverless nothing survives the
* invocation and a gallery would look broken.
*/
export const ServerCase = ({ locale }: { locale: Locale }) => {
const t = COPY[locale];
const [stored, setStored] = useState<StoredFile[]>([]);
const [error, setError] = useState<string | null>(null);
const strategy = createXhrUploadStrategy({
endpoint: "/api/playground/storage",
});
const common = {
scopes: demoScopes,
entityId: "demo",
strategy,
labels: locale === "es" ? ES_LABELS : undefined,
onUploaded: (files: StoredFile[]) => {
setError(null);
setStored((prev) => [...files, ...prev].slice(0, 6));
},
onError: setError,
};
return (
<div className="grid gap-8 lg:grid-cols-2">
<div className="space-y-8">
<Uploader
{...common}
scope="demo-image"
label={t.imageLabel}
description={t.imageHint}
/>
<Uploader
{...common}
scope="demo-document"
label={t.docLabel}
description={t.docHint}
multiple
/>
</div>
<div>
<h3 className="text-sm font-semibold">{t.result}</h3>
{error ? (
<p className="mt-3 rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
{t.failed}: {error}
</p>
) : null}
{stored.length === 0 ? (
<p className="text-fd-muted-foreground mt-3 text-sm">{t.empty}</p>
) : (
<pre className="bg-fd-muted mt-3 max-h-96 overflow-auto rounded-lg p-4 text-xs">
{JSON.stringify(stored, null, 2)}
</pre>
)}
<p className="text-fd-muted-foreground mt-4 text-xs">{t.note}</p>
</div>
</div>
);
};