Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Listening scope.
 * - `"document"` (default): a ⌘V anywhere on the page counts — the "screenshot, paste, uploaded" flow.
 * - a ref: only pastes whose **focus sits inside that element** count (decided with `contains`,
 *   not by attaching the listener to that element — see the hook's JSDoc).
 */
export type ClipboardPasteTarget = "document" | React.RefObject<Element | null>

/**
 * Why a file was dropped. Branch UI copy on `reason`; `message` is a displayable English

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-clipboard-paste.json

Prompt

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 drainclipboardData is alive only while the event is being dispatched; once that task ends the store flips to protected mode and getAsFile() returns null (measured in Chromium: items.length 1 → 0 after one setTimeout(0)). Files, text/plain and text/html are therefore collected in one straight-line pass before any filtering, state update or await.
  • Containment scoping — the listener always sits on document; a ref target is honoured by testing node.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 (with contenteditable="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 accept first, then maxFiles, so a rejected type never eats a capacity slot; every excluded file comes back in payload.rejected with its File and a reason, so the UI can explain instead of losing files quietly. maxFiles: 0 is a first-class value meaning "the queue is full".
  • Two paths, one payload — the passive paste event needs no permission, while readFromClipboard() needs a user gesture, permission and a focused document; both feed the same accept/maxFiles gate and produce the same payload shape, distinguished only by source.
  • Honest read failuresdocument.hasFocus() is checked before calling clipboard.read(), because Chromium reports an unfocused document as NotAllowedError, 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 stays ok: true with an empty payload rather than masquerading as an error.

On This Page