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

.pdf

Ctrl+U
BasicCase.tsx
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

MultipleCase.tsx
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

AvatarCase.tsx
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
      }
    />
  );
};

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

ValidationCase.tsx
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

RetryCase.tsx
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.

ViewerCase.tsx
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.

    HeadlessCase.tsx
    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.

    ServerCase.tsx
    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>
      );
    };

    On this page