useClipboardPaste
Catches pasted images, files and rich text — page-wide or scoped to a ref — draining clipboardData synchronously, gating files through accept/maxFiles with an itemised rejection list, plus an optional permission-checked active read.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-clipboard-paste.jsonPrompt
Build a React + TypeScript "useClipboardPaste" hook (React only — no npm
dependencies; browser Clipboard API: the paste event plus the optional
navigator.clipboard.read()).
Contract
- `useClipboardPaste({ onPaste, target = "document", accept, maxFiles,
includeText = true, captureInInputs = false, disabled = false } = {})`.
- Returns `{ isSupported, canRead, lastPaste, readFromClipboard }`.
- `onPaste(payload)` where `payload` is
`{ files: File[], text: string | null, html: string | null,
rejected: Rejection[], source: "event" | "read",
event: ClipboardEvent | null }`.
`lastPaste` holds the same object as the last delivery, or null.
- `Rejection` is `{ file: File, reason: "type-not-accepted" |
"too-many-files", message: string }`. The original File is handed back so
the consumer can offer "add it anyway" or point at another upload route;
`message` is a ready-to-render English fallback, but UI copy should branch
on `reason`.
- `target` is `"document"` (a page-wide paste-to-upload zone) or a
`RefObject<Element | null>` (only pastes originating inside that element).
- `isSupported` = this environment delivers paste events; `canRead` = the
browser exposes `navigator.clipboard.read`. `canRead` being true does NOT
mean a read will succeed — permission is decided at call time.
- `readFromClipboard(): Promise<{ ok: true, payload } | { ok: false, error }>`
with `error.kind` in `"disabled" | "unsupported" | "insecure-context" |
"document-not-focused" | "permission-denied" | "read-failed"`. It never
throws and never reports a failure as a success. It is referentially
stable, so it is safe inside dependency arrays.
Behavior
- Drain `event.clipboardData` SYNCHRONOUSLY. `DataTransferItem.getAsFile()`
and `getData()` only work while the event is being dispatched: once the task
that dispatched it ends, the store flips to protected mode — measured in
Chromium, `items.length` and `files.length` go from 1 to 0 and `getAsFile()`
returns null after a single `setTimeout(0)` (a bare microtask still saw the
data, but do not lean on that). So collect files, `text/plain` and
`text/html` first, in one straight-line pass, and only then filter, setState
and call back. This is the number one reason paste handlers "mysteriously"
get no file: any real `await` before `getAsFile()` loses it.
- Collect files from `clipboardData.items` where `kind === "file"`, and fall
back to `clipboardData.files` when that yields nothing — both reads are
equally short-lived, so both happen in the same pass.
- Always attach ONE listener on `document`, and decide scope per event by
reading `ref.current` at event time (`node.contains(event.target)`), instead
of attaching the listener to the ref's element. A ref object is read once,
when the effect runs, so an element that is conditionally rendered (null
first, mounted later, or swapped by a key change) leaves an element-bound
listener watching nothing forever. Reading at event time is always correct
and means changing `target` needs no re-arming. The documented trade-off:
if something calls `stopPropagation()` on the paste event before it reaches
`document`, this hook will not see it.
- With `target: "document"`, ignore pastes whose target is (or is inside) an
`input` / `textarea` / `select` / `[contenteditable]`, unless
`captureInInputs` is true — otherwise pasting a sentence into a search box
fires the page's "paste to upload" handler. Treat `contenteditable="false"`
as NOT editable. With a ref target, skip this filter entirely: the consumer
already drew the boundary, and pasting a screenshot into a composer's
textarea is exactly the use case.
- `accept` matches three rule shapes — `"image/*"` wildcard, exact
`"application/pdf"`, and `".png"` extension. The extension form matters:
clipboard files sometimes arrive with an empty `file.type`, and only a name
rule can rescue those. No `accept` (or an empty array) means no filtering.
- Apply `accept` BEFORE `maxFiles` so a rejected type does not consume a slot;
otherwise one stray attachment pushes out images that would have fit.
Clamp `maxFiles` with `Math.floor` and a floor of 0; NaN/Infinity mean no
limit. `0` is meaningful, not a bug guard: `maxFiles={capacity - used}`
becomes 0 when the queue is full and every file then comes back rejected
with "no capacity left", which is exactly the message the UI should show.
- Never drop a file silently. Everything filtered out appears in
`payload.rejected` with its reason.
- Deliver only non-empty payloads: if files, text, html and rejected are all
empty, do not call `onPaste` and do not touch `lastPaste`. Otherwise
`includeText: false` turns every stray text paste on the page into a bogus
event.
- `payload.event` is the live ClipboardEvent and `onPaste` runs synchronously
inside the handler, so consumers can call `payload.event?.preventDefault()`
to stop the browser from also inserting the image into a contenteditable.
The hook itself never calls preventDefault — swallowing the user's paste is
the consumer's decision.
- Capability detection must not happen during render: read
`"ClipboardEvent" in window` and `navigator.clipboard?.read` through
`useSyncExternalStore(noopSubscribe, detect, () => false)`, so SSR and the
hydrating first paint agree.
- `readFromClipboard()` pre-checks in order: disabled, missing
`navigator.clipboard.read`, `isSecureContext === false`, and
`document.hasFocus() === false`. The focus check is deliberate — Chromium
reports an unfocused document as `NotAllowedError`, which would otherwise be
mapped to "permission denied" and send the user off to change a site setting
when clicking back into the page is all that was needed. In the catch, map
`NotAllowedError` to `document-not-focused` when its message mentions focus,
otherwise `permission-denied`; anything else is `read-failed`.
- In the read path, turn only NON-`text/*` types into `File`s (naming them
`pasted-image.png` and so on) — a `text/uri-list` entry has no business
becoming a file — and wrap each `getType()` in try/catch so one unreadable
format does not fail the whole read. A read that succeeds but yields nothing
is still `ok: true` with an empty payload: permission was granted, the
clipboard was simply empty. Do not dress that up as an error.
- `onPaste` and every option live in latest-refs, so inline arrow callbacks
and inline option literals never re-arm the listener. A `mountedRef` is set
to true INSIDE the mount effect (setting it only in cleanup leaves it false
for the live instance under StrictMode's mount → cleanup → mount) so a read
that resolves after unmount does not setState — while still returning its
result to the caller.
- `disabled` removes the listener for real and makes `readFromClipboard()`
return the `disabled` error; `lastPaste` is frozen, not cleared. Unmount
removes the listener.
Rendering & styling
- The hook renders nothing; the consumer owns all UI. For a paste-to-upload
panel: build thumbnails with `URL.createObjectURL(file)` for `image/*` only
and `revokeObjectURL` them on clear and on unmount — and create the URL in
the event callback, never inside a setState updater, because StrictMode
invokes updaters twice and the second URL would leak forever. Key list rows
by a monotonic id, not by array index or file name, since the same file can
legitimately be pasted twice. Give the `img` an `onError` fallback (plus a
`complete && naturalWidth === 0` probe in a ref callback for images that
fail before the handler attaches): a clipboard can hold an `image/tiff` no
browser will paint, and a broken-image glyph is not an acceptable answer.
- Render `payload.rejected` as its own block — a destructive-toned row per
file with the reason as a badge (`bg-destructive/10`, `text-destructive`,
`border-destructive/40`). Semantic tokens only (`bg-card`, `bg-muted`,
`text-muted-foreground`, `border`, `focus-visible:ring-ring`); a ref-scoped
drop panel needs `tabIndex={0}` and a visible focus ring, because a paste
event goes to whatever holds focus. Format byte counts with an explicit
`"en-US"` locale so SSR and client agree.
- Never render a success state for a failed `readFromClipboard()`: show the
`kind` and a recovery hint per kind ("click back into the page", "this
browser has no clipboard.read, press Ctrl+V instead", "allow clipboard
access in site settings").
Customization levers
- `target` — page-wide screenshot capture ("document") versus a scoped
composer or a single card (a ref). Scoped panels want `tabIndex={0}`.
- `accept` / `maxFiles` — the intake policy. Wire `maxFiles` to remaining
queue capacity to get "queue full" rejections for free.
- `includeText` — false for a pure file dropzone (text pastes then become
no-ops), true when you also want the pasted `text/html` to import a
spreadsheet or rich text.
- `captureInInputs` — true only when the whole page is a paste target and you
genuinely want in-field pastes too (a chat app that uploads whatever is
pasted anywhere, including in the message box).
- `onPaste` — where the upload, the optimistic queue row, or
`payload.event.preventDefault()` hangs off; the hook uploads nothing itself.
- `readFromClipboard` — add a "Paste from clipboard" button for users who
cannot press ⌘V (touch devices, kiosks); keep the passive path as the
primary one since it needs no permission.
- Want dedupe or size limits? Filter inside `onPaste` on `file.size` or
`name + size + lastModified`; the hook ships only the two gates that need
clipboard knowledge and leaves policy to you.Concepts
- Synchronous clipboard drain —
clipboardDatais alive only while the event is being dispatched; once that task ends the store flips to protected mode andgetAsFile()returns null (measured in Chromium:items.length1 → 0 after onesetTimeout(0)). Files,text/plainandtext/htmlare therefore collected in one straight-line pass before any filtering, state update orawait. - Containment scoping — the listener always sits on
document; a ref target is honoured by testingnode.contains(event.target)at event time. Binding to the element instead would freeze the hook onto whatever the ref held when the effect ran, which is nothing at all for a conditionally rendered panel. - Editable-target skip — a page-wide listener ignores pastes coming from a text field or
contenteditable(withcontenteditable="false"counted as not editable), because otherwise "paste anywhere to upload" also fires when someone pastes a sentence into a search box. A ref-scoped instance skips this rule: the boundary was already drawn by hand. - Itemised rejection — filtering runs
acceptfirst, thenmaxFiles, so a rejected type never eats a capacity slot; every excluded file comes back inpayload.rejectedwith itsFileand a reason, so the UI can explain instead of losing files quietly.maxFiles: 0is a first-class value meaning "the queue is full". - Two paths, one payload — the passive
pasteevent needs no permission, whilereadFromClipboard()needs a user gesture, permission and a focused document; both feed the sameaccept/maxFilesgate and produce the same payload shape, distinguished only bysource. - Honest read failures —
document.hasFocus()is checked before callingclipboard.read(), because Chromium reports an unfocused document asNotAllowedError, and blaming permissions would send the user to change a site setting when clicking back into the page was enough. An empty-but-permitted read staysok: truewith an empty payload rather than masquerading as an error.
useList
Array state with every mutation you keep hand-writing — push, insertAt, move, upsert and friends, all batch-safe, out-of-range-safe, and reference-stable.
useWakeLock
A screen wake lock hook that keeps the display on for as long as a task lasts — and re-acquires the lock the browser silently takes away every time the tab goes to the background.