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 removeStrategycreateRemoveStrategy({ 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.

On this page