UI components
The optional component layer: Uploader, SlottedUploader, confirmations, file preview, reading stored files, drop-to-replace, or none of it at all.
UI components
Both components are skins over the hooks — same validation, compression,
progress and abort. Import uploaderkit/tailwind.css once (see
Tailwind v4 Setup).
Uploader
One dropzone, one or many files in it.
import { Uploader } from 'uploaderkit/ui'
;<Uploader
scopes={scopes}
scope='invoice-evidence'
entityId={invoiceId}
strategy={strategy}
multiple
maxFiles={3}
label='Evidence'
description='PDF or photo, up to 8 MB'
stored={saved} // already persisted, rendered on the filesPosition side
filesPosition='below' // keep the drop target from sliding down the page
onRemoveStored={forget} // delete from storage here — see Removal below
confirmRemove // gate it behind a dialog; or { title, message }
onUploaded={persist}
resolveViewUrl={file => api.signedUrl(file.key)}
capture='environment' // mobile: open the rear camera directly
/>It accepts every useUploader option plus the presentation props above, and
defaults uploadOn to 'select'. 'manual' gives the zone an upload button
for the files still waiting; 'submit' hands the send to your form through
controllerRef (see Upload trigger). resolveViewUrl re-signs a
private object right before previewing it, for the case where the stored URL
has expired.
filesPosition decides which side of the dropzone the file lists sit on. It
defaults to 'above', the historical layout; 'below' keeps the zone anchored,
which matters when files are added one at a time — otherwise every addition
pushes the target the user is aiming at further down.
renderFiles replaces the rows themselves. It receives the persisted files, the
staged ones with their live status, the default rows already built, and the
view/remove callbacks — so a screen can render a thumbnail grid, a single
summary line, or a count folded into its own card:
<Uploader
{...props}
filesPosition='below'
renderFiles={({ staged, stored, isEmpty, remove }) =>
isEmpty ? null : (
<ul className='grid grid-cols-3 gap-2'>
{stored.map(file => (
<li key={file.key}>{file.fileName}</li>
))}
{staged.map(file => (
<li key={file.id} onClick={() => remove(file.id)}>
{file.file.name} · {file.status}
</li>
))}
</ul>
)
}
/>It changes the rows, not their place: the result still renders on the
filesPosition side. For a layout the zone itself has to be part of — files
BESIDE the dropzone, everything inside your own frame — skip this skin and
compose useUploader with the exported Dropzone, FileItem and
StoredFileItem. Nothing here is unavailable there.
The dropzone also accepts a pasted file while focused (screenshots land as
uploads), and capture makes a touch device offer its camera instead of the
picker. On a coarse pointer the prompt switches to the tap-first copy
(labels.tapPrompt) with press feedback — a phone user never reads about
dragging.
Presentation knobs: size='sm' compacts the zone and every row; icon
replaces the dropzone glyph with any node (icon={null} removes it). Colors
and radii come from the theme variables — see Theming.
shortcut='mod+u' binds a global key (⌘U / Ctrl+U) that opens the picker and
renders a small kbd hint inside the zone, so users discover it. It never
fires while typing in a field, and two zones claiming the same combo warn in
development — with several uploaders on screen, give each its own.
SlottedUploader
A status row per slot plus one bulk dropzone whose matcher routes each file:
import { SlottedUploader } from 'uploaderkit/ui'
;<SlottedUploader
scopes={scopes}
scope='company-identity'
entityId={companyId}
strategy={strategy}
title='Company documents'
slots={[
{ id: 'letterhead', label: 'Letterhead', extensions: ['pdf'] },
{
id: 'logo',
label: 'Logo',
extensions: ['png', 'svg'],
hint: 'Transparent background',
},
]}
value={slotFiles}
onChange={setSlotFiles}
confirmRemove // dialog before forgetting a filled slot
confirmReplace // dialog naming both files before overwriting
hideDropzone={false}
/>confirmReplace intercepts the picked file after the pick — the dialog can
then name what is about to be lost and what replaces it. Both props take
true for the default copy or { title, message } to override it.
Staged rows. Under uploadOn: 'submit' a pick does not travel: it rests on
its own row with a thumbnail, its name and ready to upload, plus Replace and
Remove, until the form calls controllerRef.current.upload(). The status dot
turns amber to say so. The name shown is the storage one — the file is renamed
to {slot}.{ext} before it enters the machine, which is what the entity will
actually serve.
Removal deletes, history keeps. Pass a removeStrategy — createRemoveStrategy({ endpoint, headers, credentials }), the DELETE mirror of the upload transport (DELETE {endpoint}/{scope}/{entityId} with { key }) — and a confirmed removal deletes the object from storage by itself, on Uploader and SlottedUploader alike. onRemoveStored(stored) still fires for the app's bookkeeping (clearing the DB reference), delivered BEFORE onChange. The reference is forgotten even when the delete fails — a dangling pointer is worse than an orphan — and a refused delete surfaces through onError. The opt-out is a scope contract, not a client choice: mark the scope keepOnRemove: true and both the components skip the delete and the server's storage.remove answers false — history enforced where no client can bypass it.
Language. English is the default across the kit. A Spanish app opts in once at the root — <UploaderProvider language='es'> (exported from /react and /ui) — and every component and hook under it, including validation messages like maxFilesReached, speaks Spanish; a per-component labels prop still wins for one-off rewording.
Confirmations
Destructive file actions get a second step: an accessible dialog (portal,
focus trapped, focus lands on cancel so a stray Enter never destroys
anything, Escape and the backdrop cancel). ConfirmDialog is exported for
wrapping your own actions in the same UX:
import { ConfirmDialog } from 'uploaderkit/ui'
;<ConfirmDialog
open={confirming}
title='Eliminar expediente'
message='Se borrarán también sus documentos.'
variant='danger'
onConfirm={destroy}
onCancel={() => setConfirming(false)}
/>File preview (FileViewer)
Both uploaders embed the full-screen viewer; it is exported standalone for any
screen that persists a StoredFile. It portals to <body> (no ancestor
stacking context can trap it), traps focus while open and restores it on
close, and locks the page scroll behind it. When resolveUrl fails — an
expired signature, a dropped connection — the viewer shows a retryable error
instead of loading forever. On narrow viewports a PDF renders as a download
card instead of an embedded frame (iOS Safari freezes embedded PDFs).
Pass files (the collection) alongside file (the one clicked) and the
viewer becomes a gallery: side arrows, ←/→ on the keyboard, a 2 / 5
counter, and — on fine pointers — a footer hinting the shortcuts (Esc,
← →), so nobody has to guess them:
<FileViewer file={viewing} files={storedImages} onClose={close} />Keyboard: Esc closes, ←/→ walk the gallery, D downloads and O opens
the file in a tab — each hinted in the footer on fine pointers. A modifier is
never claimed, so ⌘D still bookmarks.
renderError replaces the built-in "could not load" panel. It receives the
file that failed plus a retry that re-resolves it, so a custom panel keeps
the recovery the default one offers:
<FileViewer
file={viewing}
onClose={close}
renderError={({ file, retry }) => (
<MyErrorState name={file.fileName} onRetry={retry} />
)}
/>useFileViewer owns the open/close state every screen would otherwise repeat:
const viewer = useFileViewer({ resolveUrl })
<button onClick={() => viewer.open(stored)}>Ver</button>
<FileViewer {...viewer.viewerProps} />Reading a stored file
An <img> or an <iframe> cannot send an Authorization header, so a
private or encrypted object never renders from its raw url. Two helpers do
the authenticated read for you — same rule, two output shapes:
import {
createBlobUrlResolver,
createBytesResolver,
} from 'uploaderkit/react'
// For the viewer: fetches with the app's headers, hands back an object URL.
const resolveViewUrl = createBlobUrlResolver({
baseUrl: apiUrl,
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
credentials: 'include',
})
<Uploader {...props} resolveViewUrl={resolveViewUrl} />
// For code that processes the file instead of showing it.
const readBytes = createBytesResolver({ baseUrl: apiUrl, headers })
const pdf = await PDFDocument.load(await readBytes(stored.url))The rule both share is about origin, not shape: a url baseUrl serves —
app-relative, or absolute on the same origin — is yours and travels with your
headers and credentials; anything on a foreign origin is already
reachable and is fetched bare, because the token must never go to a
third-party host. Your server's encryptedUrl usually persists an absolute
url pointing back at your own /view route: that one counts as yours. Reading bytes through your own
endpoint is also what spares a public bucket its own CORS policy: an <img>
is exempt from CORS, a fetch for bytes is not.
viewUrlFileName(url) recovers the display name from a /view?key=… url.
Drop to replace
A filled SlottedUploader row is itself a drop target: dragging a file over
it lights the row up with a "Drop to replace" pill, and the drop runs through
the same confirmReplace dialog as the button path. An empty row accepts the
drop as a direct fill — no trip through the bulk zone's matcher.
Going fully headless
An app with its own design system uses /react directly and loses nothing —
validation, compression, progress, abort and slot routing all live in the
hooks. /ui exists so a screen that does not need custom markup does not have
to write any.