npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-smart-copy

v1.22.0

Published

Headless, type-safe clipboard interactions for React: copy and paste text, rich HTML, images, spreadsheet tables, typed application data, Markdown, code, privacy-safe images, links, sharing, colours, portable rich text, clean pastes from Word and Google D

Readme

react-smart-copy

npm npm downloads CI CodeQL OpenSSF Scorecard License: MIT React 18+ TypeScript GitHub stars

Live demo and docs

Headless, type-safe clipboard interactions for React. Not just a clipboard call: the whole interaction around it — copying, copied, failed, retry, pasting — with honest errors, hover/focus/touch reveal, and screen-reader announcements built in.

Email   [email protected]   [Copy]      →   Copied ✓
Phone   +91 98xxx xxxxx     [Copy]
ID      PLT-29018           [Copy]
  • ~3.5 kB for useCopy, ~5.6 kB for everything copy (min + brotli), zero runtime dependencies
  • Copy text, rich HTML (with plain-text fallback), PNG images, JSON, multi-format
  • Paste text, HTML and screenshots with usePaste: a Paste button or Ctrl/⌘+V, no permission prompt for keyboard paste
  • <CopyGroup>: only one row shows "Copied" at a time
  • useCopyInterceptor: control what Ctrl/⌘+C copies from your page — attribution, redaction, Excel-ready tables, no-copy zones
  • react-smart-copy/table: copy rows that paste into Excel and Google Sheets as cells, and read pasted ranges back as typed records. Works with AG Grid, TanStack Table and any <table>
  • react-smart-copy/structured: copy your own objects and paste them back as the same typed, validated objects in another tab or app, the way design tools do. Versioned, with migrations, and works with Zod, Valibot or ArkType
  • react-smart-copy/markdown: paste from Google Docs, Word, Notion or any web page into a Markdown field and get clean Markdown, with undo intact: <textarea onPaste={pasteAsMarkdown} />
  • react-smart-copy/code: copy code blocks the way developers expect (a terminal session copies as its commands, gutters and prompts stay behind, colours survive in Docs and Slack), paste code cleaned and indented, and catch Trojan Source, invisible identifiers and lookalike letters
  • react-smart-copy/image: get pasted photos and screenshots ready to upload. It removes GPS location, camera data and hidden thumbnails without touching a pixel, and fits size and byte limits, re-encoding only when it has to
  • react-smart-copy/files: take pasted and dropped files and whole folders safely. Types come from each file's content, so a program named invoice.pdf.exe or a web page named report.pdf is turned away with a reason, names are made safe for every operating system, and limits cap count, size and depth
  • react-smart-copy/secrets: keep API keys, tokens, private keys, passwords, card numbers and IBANs out of pastes and copies. Paste guards for chat boxes and forms, and redaction of what people copy from logs and consoles. Every pattern runs in linear time, and no finding holds the secret
  • react-smart-copy/otp and useOtpField: one-time code fields that typing, pasting a whole SMS, SMS autofill and WebOTP fill, with an accessible name on every box, and extractOtp to read the code in a message while leaving prices, dates, phone numbers and order numbers alone
  • react-smart-copy/link: "Copy link" buttons that paste as a titled link in Slack, Docs and Notion and as the URL in editors, links that scroll to a highlighted passage, tracking parameters removed, and pasting a URL over selected text to make a link
  • react-smart-copy/rich: copy styled content so it pastes as it looks in Gmail, Outlook, Apple Mail, Google Docs, Word, Notion and Slack: sanitized HTML with inline styles, email-safe colours and absolute links, plus readable plain text. Works for buttons and for Ctrl/⌘+C. And the other way: paste from Docs, Word and mail into a rich-text editor (TipTap, Lexical, contenteditable) as clean, semantic HTML
  • react-smart-copy/color: copy colours in any CSS format (hex to oklch() and Display P3) with a swatch for Docs and Slack, paste any format into a colour field, and check WCAG contrast. Wide-gamut colours are mapped the way CSS specifies, never clipped to a wrong hue
  • react-smart-copy/share: one "Share" button that opens the share sheet on phones and copies a titled link on desktop, with honest states: a closed share sheet is never an error
  • react-smart-copy/capture: copy an <svg> or any piece of your UI as a PNG
  • Every failure is a typed, classified error. Nothing silently no-ops
  • Unstyled. Works with Tailwind, CSS modules, CSS-in-JS, shadcn, MUI, Ant Design
  • SSR / Next.js App Router safe, React 18 and 19, StrictMode-proof
  • Tested in real browser engines on every change: Chromium, Firefox and WebKit (Safari's engine)

What it can and cannot copy

This package only promises what browsers actually support.

| Content | Supported | Notes | | -------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Plain text | ✅ | Everywhere with the async Clipboard API | | Rich HTML | ✅ | Always paired with a required plain-text fallback. Degrades to text when ClipboardItem is missing | | Images | ✅ PNG only | image/png is the only format with reliable cross-browser write support. Convert with canvas.toBlob(cb, 'image/png') | | JSON | ✅ | Serialised to text. JSON is not a native clipboard format | | Tables (Excel, Sheets, Docs) | ✅ | react-smart-copy/table: an HTML table plus tab-separated text in one entry. Pasting reads both back | | Several formats at once | ✅ | kind: 'multi'; each MIME type is checked with ClipboardItem.supports() first | | Your app's own data (typed) | ✅ | react-smart-copy/structured: a web custom format, a custom type and an HTML envelope, so every browser and paste path gets one | | Rich text as Markdown | ✅ paste | react-smart-copy/markdown: CommonMark + GFM from Docs, Word, web pages and mail; plain-text pastes stay untouched | | Code (blocks, sessions, diffs) | ✅ | react-smart-copy/code: plain text plus monospaced, highlighted HTML; commands without prompts or output; Trojan Source checks on paste | | Pasted images, made safe | ✅ paste | react-smart-copy/image: removes location and camera metadata losslessly; resizes and compresses under a byte budget | | Links (titled, to a passage) | ✅ | react-smart-copy/link: a titled <a> plus the URL; text/uri-list when dragged; text fragment links; never javascript: | | Styled content, portable | ✅ | react-smart-copy/rich: sanitized HTML with inline styles and email-safe colours, plus plain text; for buttons and Ctrl/⌘+C | | Rich text pasted into an editor | ✅ | react-smart-copy/rich: Docs, Word and mail into TipTap, Lexical or contenteditable as clean, semantic HTML | | Colours (every CSS format) | ✅ | react-smart-copy/color: the value as text, plus a swatch for rich-text apps; any CSS colour read back on paste | | Share sheet (Web Share API) | ✅ with copy fallback | react-smart-copy/share: the share sheet on phones and tablets, a titled link copied elsewhere; files where the share sheet takes them | | SVG (icons, charts, logos) | ✅ as PNG | svgToPngBlob() from react-smart-copy/capture rasterises natively, no dependency. Raw SVG is not a clipboard image format | | A section of your UI as an image | ✅ bring a rasteriser | Browsers have no "screenshot this element" API. captureElement() wraps one you choose (e.g. modern-screenshot) with limits and checks | | Files (PDF, DOCX, ZIP) | ❌ | Not writable to the system clipboard by any browser API | | Anything over plain HTTP | ❌ | The Clipboard API requires HTTPS or localhost. You get an insecure-context error, not a silent failure |

Install

npm install react-smart-copy

Works with React 18 and 19 and TypeScript 5.0 or later (the published types are checked on 5.0, 6 and 7 in CI). React is an optional peer: every entry point except the main one runs without it, in browsers and in Node.js 18 or later.

Quick start

import { CopyField } from "react-smart-copy";

export function ContactRow() {
  return (
    <CopyField.Root
      value="[email protected]"
      label="Email"
      className="group flex items-center gap-3"
    >
      <CopyField.Label className="w-20 text-sm text-gray-500" />
      <CopyField.Value className="font-mono" />
      <CopyField.Trigger className="opacity-0 transition-opacity group-data-[revealed]:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none">
        {({ displayStatus }) =>
          displayStatus === "copied"
            ? "Copied ✓"
            : displayStatus === "error"
              ? "Retry"
              : "Copy"
        }
      </CopyField.Trigger>
    </CopyField.Root>
  );
}

That one component gives you: a real <button type="button"> named "Copy Email", reveal on mouse hover, keyboard focus and touch (always visible on devices that can't hover), a polite "Copied to clipboard" announcement for screen readers, ignored double-clicks, and automatic return to idle after 2 seconds.

No flicker

A text copy takes 1–5 ms, so rendering the raw state flashes "Copying…" for one frame and resizes the button. Two statuses solve this:

  • status / data-state: the real, instantaneous state. Use it for logic and tests.
  • displayStatus / data-display-state: what to render. "Copying…" only appears when a copy takes longer than pendingDelayMs (150 ms), then stays at least minPendingMs (400 ms) so it can't blink.

The default trigger label also stacks every state in one grid cell, so its width never changes, and crossfades between them (off under prefers-reduced-motion).

<CopyField.Root
  value={big}
  label="Report"
  pendingDelayMs={200}
  minPendingMs={500}
>
  …
</CopyField.Root>;

// With the bare hook:
const { status } = useCopy();
const shown = useDisplayStatus(status);

Just the hook

import { useCopy } from "react-smart-copy";

function InvoiceNumber({ id }: { id: string }) {
  const { copy, status, error } = useCopy({ resetAfterMs: 1500 });

  return (
    <button type="button" onClick={() => void copy(id)} data-state={status}>
      {status === "copied" ? "Copied" : "Copy"}
      {error && <span role="alert">{error.type}</span>}
    </button>
  );
}

copy() never rejects. It resolves to a CopyOutcome you can branch on:

const outcome = await copy(id);
if (outcome.status === "error") track("copy_failed", outcome.error.type);

Payloads

A bare string is shorthand for text. Everything else is a discriminated union, so the compiler stops you from, say, copying HTML without a plain-text fallback.

copy("PLT-29018");
copy({
  kind: "html",
  html: "<b>INV-29018</b> · ₹12,400",
  text: "INV-29018 · ₹12,400",
});
copy({ kind: "json", value: row, pretty: true });
copy({ kind: "image", blob: () => chartToPngBlob() });
copy({
  kind: "multi",
  items: [
    { mimeType: "text/plain", data: "INV-29018" },
    { mimeType: "text/html", data: "<b>INV-29018</b>" },
  ],
});
copy(() => editor.getValue()); // resolved at click time, always the latest value

A multi item can be marked optional: true: it is written only when ClipboardItem.supports() confirms its type, and skipped otherwise instead of failing the copy. That is how a web custom format ("web application/vnd.acme.rows+json", Chromium) rides along with HTML and text that every browser writes. Copy and drag events skip web custom formats, which only the async Clipboard API can write.

Copying an image, including a piece of your UI

Pass a function that produces the Blob. It is called synchronously inside the click, and its promise is handed straight to ClipboardItem, which is what Safari needs to allow asynchronous image generation. The react-smart-copy/capture entry does this for you.

Copying SVG and UI as images

react-smart-copy/capture is a separate, opt-in entry (~3.3 kB, no dependencies). It never ships in your bundle unless you import it.

import { captureImage, svgToPngBlob } from "react-smart-copy/capture";
import { domToBlob } from "modern-screenshot"; // any rasteriser you like, for HTML

// Any <svg> (chart, logo, icon): rasterised natively, no rasteriser needed.
<CopyField.Root label="Chart" value={captureImage(() => chartRef.current)}>
  <CopyField.Trigger>Copy chart</CopyField.Trigger>
</CopyField.Root>;

// Any HTML element (invoice card, receipt): bring the rasteriser.
<CopyField.Root
  label="Invoice"
  value={captureImage(() => invoiceRef.current, {
    rasterize: (node, { scale }) => domToBlob(node, { scale }),
    background: "#ffffff",
  })}
>
  <CopyField.Trigger>Copy as image</CopyField.Trigger>
</CopyField.Root>;

// Or by hand, e.g. from SVG markup:
const png = await svgToPngBlob('<svg viewBox="0 0 24 24">…</svg>', { width: 256 });

What you get beyond calling a rasteriser yourself:

  • Always a real PNG. Output is verified from its bytes; JPEG/WebP/canvas/data-URL outputs are re-encoded, anything else is refused
  • Size limits. maxPixels (default 4096 × 4096, Safari's canvas limit) is checked before rendering and again on the output; SVG markup is capped at maxLength
  • Deadlines. timeoutMs (default 15 s) and an optional signal: AbortSignal; the rasteriser receives a signal it can honour
  • Safe SVG handling. SVG is decoded as an image, where browsers disable scripts and external loads, and it is never inserted into your page. Network URLs from a rasteriser are never fetched
  • Crisp by default. scale: 2; pass window.devicePixelRatio or anything up to 10

Failures are classified like everything else: invalid-payload (no element / unknown size), unsupported, too-large, timeout, aborted, blob-generation-failed. Rasterisers have real limits (cross-origin images taint the canvas, web fonts must be loaded, <video> and <iframe> rarely render), so offer a text fallback when capture fails.

Controlling native copy (Ctrl/⌘+C)

Everything above copies from a button. useCopyInterceptor handles the other way people copy: selecting text and pressing Ctrl/⌘+C, Ctrl/⌘+X or using the context menu. Inside the element you attach it to, you decide what reaches the clipboard.

import { useCopyInterceptor } from "react-smart-copy";

function Article({ children }: { children: React.ReactNode }) {
  const { ref } = useCopyInterceptor<HTMLElement>({
    transform: ({ text }) => `${text}\n\nSource: ${location.href}`,
  });
  return <article ref={ref}>{children}</article>;
}

The transform receives the selection (text, html, a detached fragment you may mutate, the field for inputs, and whether it was a copy or cut) and returns:

| Return | Effect | | ---------------------------------------- | -------------------------------------------------------------- | | a string or a text/HTML/JSON/multi payload | written instead of the selection | | false | nothing is copied; a blocked cut deletes nothing | | null / undefined | the browser's normal copy, untouched |

It writes through the copy event itself, so there is no permission prompt, it works over plain HTTP, and in every browser. The trade-off: only strings can be written, so the types reject image payloads and async transforms (the clipboard closes when the event returns). Use useCopy for images.

Redaction. Mutate the fragment and return HTML with a text fallback:

const { ref } = useCopyInterceptor<HTMLDivElement>({
  priority: 10, // see "Overlapping scopes"
  transform: ({ fragment, text }) => {
    if (!fragment) return text.replace(/sk-live-\w+/g, "••••"); // inside an <input>
    fragment.querySelectorAll("[data-redact]").forEach((node) => (node.textContent = "••••"));
    const box = document.createElement("div");
    box.append(fragment);
    return { kind: "html", html: box.innerHTML, text: box.textContent };
  },
});

Tables to Excel. Return a multi payload with tab-separated text/plain and an HTML <table>; spreadsheets paste either into cells.

Cuts in inputs and contenteditable still remove the selection after your content is written, through the browser's own editing path: undo works and React sees an input event. outcome.deleted tells you whether that happened.

Failures fail closed. If the transform throws or returns something unwritable, nothing is copied and onError fires. That is the safe default for redaction; pass failureMode: "native" to let the original copy through instead (e.g. for attribution).

Overlapping scopes. One selection can touch several intercepted elements. Exactly one handles it: the highest priority, then the innermost, then the first in the document. The winner receives the whole selection, so give redaction scopes a higher priority than attribution scopes and copying across both is still masked. A React onCopy handler that calls preventDefault() always wins.

Other options: events (["copy"], ["cut"] or both, the default), enabled, and onIntercept(outcome) for telemetry. Without React, call registerCopyInterceptor(element, options) from react-smart-copy/core; it returns an unsubscribe function. For a single element's own onCopy handler, interceptCopyEvent(event, { transform }) applies a transform to one event.

This guards against accidents, not adversaries. Anyone can still screenshot, open dev tools or disable JavaScript. Never send data to the page that the user must not see.

Drag-to-copy

useDragCopy makes any element draggable and populates its DataTransfer with structured data when the user starts dragging it. No Clipboard API, no permission prompt — it works in every browser that supports HTML5 drag-and-drop.

import { useDragCopy } from "react-smart-copy";
import { linkPayload } from "react-smart-copy/link";

function DraggableCard({ title, url }: { title: string; url: string }) {
  const { ref, status } = useDragCopy({
    // Escaped, scheme-checked, and with text/uri-list so it drops into the address bar too.
    source: () => linkPayload({ url, title }, { target: "drag" }),
    effectAllowed: "copy",
  });
  return (
    <div ref={ref} style={{ opacity: status === "dragging" ? 0.5 : 1 }}>
      {title}
    </div>
  );
}

The source callback is called synchronously on dragstart and must return synchronously (the DataTransfer closes when the event handler returns). Return:

| Return value | Effect | | ------------------------------------ | --------------------------------------------------- | | a string | written as text/plain | | a text / HTML / JSON / multi payload | written according to its kind | | false | preventDefault() is called; the drag is cancelled | | null / undefined | the browser's default drag data, untouched |

The same payload shapes as useCopy are accepted (kind: "text", kind: "html", kind: "json", kind: "multi"), minus images — DataTransfer accepts strings only.

// Plain text
source: () => `${title}\n${url}`

// HTML + text fallback (e.g. for dropping into a rich-text editor)
source: () => ({ kind: "html", html: `<b>${title}</b>`, text: title })

// JSON (serialised as text/plain)
source: () => ({ kind: "json", value: { id, title }, pretty: true })

// Multiple MIME types (e.g. custom application type for your own drop targets)
source: () => ({
  kind: "multi",
  items: [
    { mimeType: "text/plain",          data: title },
    { mimeType: "application/x-my-id", data: String(id) },
  ],
})

effectAllowed controls the drag cursor and the effects drop targets may request. Defaults to "copy"; pass "move" to signal that the source will be removed after a successful drop (your app must do the actual removal in dragend or on the server).

Always-fresh source. The source callback reads the latest props and state on every drag. You never need to list it in a dependency array or wrap it in a ref yourself.

enabled removes draggable and the listener without unmounting the component. Status resets to idle while disabled.

const { ref, status, outcome } = useDragCopy({
  source: () => text,
  enabled: !isLocked,
  effectAllowed: "copyMove",
  onDrag: (state) => {
    if (state.status === "done") analytics.track("drag_complete");
  },
  onError: (err) => console.error("drag source failed", err),
});

| Return value | Type | Description | | ------------ | ----------------------------- | -------------------------------------------------- | | ref | RefCallback<T> | Attach to the element you want to make draggable | | status | 'idle' \| 'dragging' \| 'done' | Current drag state | | outcome | DragCopyOutcome \| null | Result of the last dragstart; null before any drag | | onDragStart| (event) => void | For wiring via React's onDragStart prop if needed |

onDragStart prop path. The hook attaches its own native listener through ref, so you rarely need onDragStart. It is there for render-prop components or controlled drag libraries that require a React prop:

const { ref, onDragStart } = useDragCopy({ source: () => "data" });
return (
  <SortableItem
    ref={ref}
    onDragStart={(e) =>
      onDragStart({
        type: "dragstart",
        dataTransfer: e.dataTransfer,
        defaultPrevented: e.defaultPrevented,
        preventDefault: () => e.preventDefault(),
      })
    }
  />
);

SSR-safe. draggable is never set during server rendering; the ref callback runs only in the browser.

Without React. applyDragCopy and registerDragCopy are exported from react-smart-copy/core for framework-agnostic use:

import { registerDragCopy } from "react-smart-copy/core";

const { unsubscribe } = registerDragCopy(element, () => ({
  source: () => `${title}\n${url}`,
  effectAllowed: "copy",
}));
// later: unsubscribe();

Drag vs. copy. useCopy writes to the clipboard when the user clicks a button. useDragCopy writes to the DataTransfer when the user drags an element. Use them together if your UI supports both flows.

Tables: Excel, Google Sheets, AG Grid, TanStack

react-smart-copy/table copies rows so they land as cells in Excel, Google Sheets, Numbers and LibreOffice, and as a real table in Docs, Word, Notion and mail. It also reads ranges pasted from those apps back into rows and typed records. It has no dependencies, works without React, and tree-shakes to what you use (about 1.9 kB for copying).

import { useCopy } from "react-smart-copy";
import { fromRows, tablePayload } from "react-smart-copy/table";

function CopyInvoices({ invoices }: { invoices: Invoice[] }) {
  const { copy, status } = useCopy();
  return (
    <button
      type="button"
      onClick={() =>
        void copy(() =>
          tablePayload(
            fromRows(invoices, [
              { header: "Invoice", value: "id" },
              { header: "Amount", value: "amount" }, // stays a number in Excel
              { header: "Due", value: (row) => row.due.toLocaleDateString() },
            ]),
          ),
        )
      }
    >
      {status === "copied" ? "Copied" : "Copy as table"}
    </button>
  );
}

tablePayload returns an ordinary html payload: an HTML <table> with tab-separated text as its fallback. That means it works everywhere a payload does: copy(), useCopyInterceptor transforms and useDragCopy sources. Where rich copy is unavailable, the plain-text fallback still pastes into cells.

What survives the trip. Cells keep their types. Numbers, bigints, booleans and dates stay values the spreadsheet can calculate with, while strings are marked as text so Excel keeps 00123, 1/2 and 1E5 as written (preserveText). Tabs, quotes and line breaks inside cells are quoted the way Excel quotes them. A one-column table ending in an empty cell keeps that row. Use formatCell for your own number or date formats.

Formula injection is off by default. A text cell starting with =, +, -, @, a tab or a line break (or their full-width forms) would run as a formula when pasted. Following OWASP's CSV Injection guidance, such cells get a leading ', which spreadsheets read as "this is text". Typed numbers are never touched, so -5 passed as a number stays a number. Rich editors show that apostrophe, so pass formulaGuard: false only for data you trust.

From your grid

import { fromAgGrid, fromTanStackTable, fromTableElement } from "react-smart-copy/table";

// AG Grid v31+ (Community or Enterprise): selected rows in on-screen order.
copy(() => tablePayload(fromAgGrid(gridRef.current!.api, { rows: "selected" })));

// TanStack Table v8 or v9, in any framework adapter, and libraries built on it
// such as Material React Table and Mantine React Table.
copy(() => tablePayload(fromTanStackTable(table, { rows: "selected" })));

// Any library that renders a real <table>.
copy(() => tablePayload(fromTableElement(containerRef.current!)));

| Adapter | Rows | Columns and values | | ------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | fromAgGrid | displayed (default), selected, filtered, all, or row nodes | Displayed columns minus the selection and row-number columns. values: "auto" keeps numbers raw and formats the rest | | fromTanStackTable | visible (default: what getRowModel() renders), selected, or rows | Visible columns that hold data; display columns such as checkboxes are skipped. header and value overrides | | fromTableElement | Every visible row of the first table | Merged cells, <br> line breaks, hidden rows and cells handled | | fromRows | Your objects | You choose: a property (type-checked to hold a cell) or a function |

AG Grid ships clipboard support only in Enterprise. This gives Community grids Excel-ready copy; for Ctrl/⌘+C, call it from onCellKeyDown:

onCellKeyDown={(event) => {
  const key = event.event as KeyboardEvent | null;
  if (!key || !(key.ctrlKey || key.metaKey) || key.key.toLowerCase() !== "c") return;
  const selected = event.api.getSelectedNodes().length > 0;
  void copy(() => tablePayload(fromAgGrid(event.api, selected ? { rows: "selected" } : { rows: [event.node] })));
}}

Pasting a range from Excel or Google Sheets

import { usePaste } from "react-smart-copy";
import { parseTable, toRecords } from "react-smart-copy/table";

const { targetProps } = usePaste<HTMLDivElement>({
  accept: ["html", "text"], // the HTML flavour keeps merged cells and line breaks exact
  onPaste: (result) => {
    const table = parseTable(result, { headers: true });
    if (!table) return;
    const { records, missing, errors } = toRecords(table, {
      email: { header: ["Email", "E-mail"], required: true },
      seats: { header: "Seats", parse: (text) => Number.parseInt(text, 10) },
    });
    // records: { email: string | undefined; seats: number | undefined }[]
  },
});

parseTable reads the HTML flavour when it holds a table (Excel, Google Sheets, Word and web pages all write one) and falls back to tab-separated text, which is also what it uses on the server. Pasted HTML is parsed in an inert document: scripts never run and nothing is fetched. Every limit is explicit: maxRows (100,000), maxColumns (16,384, Excel's own), maxCells (1,000,000) and maxLength (32 MiB). Content beyond them sets truncated: true instead of being dropped silently, and merged cells are counted too, so a tiny document can't claim millions of cells. toRecords matches headers ignoring case, spacing and full-width forms, reports missing required columns and every cell whose parse threw, and never stops at the first bad row.

Structured data: copy objects, paste typed objects

react-smart-copy/structured puts your app's own data on the clipboard: invoice rows, a dashboard widget, a form's configuration, a set of shapes. Paste it in another tab, another window or another of your apps and you get the same typed objects back, validated against your schema. Text editors, chat and mail get readable text and HTML instead. No dependencies, about 1.9 kB for the copy side.

import { z } from "zod"; // or Valibot, ArkType, any Standard Schema, or a plain function
import { useCopy, usePaste } from "react-smart-copy";
import { clipAccept, clipPayload, defineClipFormat, readClip } from "react-smart-copy/structured";

// Define each format once and share it between the copying and the pasting code.
export const InvoiceRows = defineClipFormat({
  id: "acme.invoice-rows", // namespaced, stable: it names the clipboard type
  version: 2,
  schema: z.array(z.object({ id: z.string(), amount: z.number(), issued: z.coerce.date() })),
  migrate: { 1: (rows) => upgradeFromV1(rows) }, // data from older deployments still pastes
  text: (rows) => rows.map((row) => `${row.id}\t${row.amount}`).join("\n"),
});

function CopyRows({ rows }: { rows: Invoice[] }) {
  const { copy } = useCopy();
  return <button onClick={() => void copy(() => clipPayload(InvoiceRows, rows))}>Copy rows</button>;
}

function PasteRows({ onRows }: { onRows: (rows: Invoice[]) => void }) {
  const { paste, targetProps } = usePaste<HTMLDivElement>({
    accept: [...clipAccept(InvoiceRows), "text"],
    onPaste: async (result) => {
      const clip = await readClip(result, InvoiceRows);
      switch (clip.status) {
        case "ok": return onRows(clip.value); // typed, validated, migrated
        case "invalid": return report(clip.issues); // [{ message, path }]
        case "unsupported-version": return askToReload(); // written by a newer deployment
        case "absent": return; // not ours: handle result.text if you like
      }
    },
  });
  return <div {...targetProps} tabIndex={0}>Paste rows here <button onClick={() => void paste()}>Paste</button></div>;
}

clipPayload works everywhere a payload does: copy(), a useCopyInterceptor transform (so Ctrl/⌘+C on selected rows copies the objects) and a useDragCopy source. readClip reads from a PasteResult (paste button, Ctrl/⌘+V, drops, PasteField), a raw DataTransfer or a payload. Use readClipSync when you need the answer synchronously and your schema is synchronous.

Why three carriers. No single clipboard entry survives every path in every browser, so each copy writes three and readClip takes the first that holds this format:

| Carrier | Written by | Read back by | | ------- | ---------- | ------------ | | web application/vnd.<id>+json | copy() in Chromium | the paste button (clipboard.read()), also across origins. Hidden from paste events | | application/vnd.<id>+json | copy interceptor, drag | Ctrl/⌘+V and drops. Hidden from clipboard.read() | | hidden envelope in text/html | every path | every path, every browser, including Safari and Firefox |

Security. The clipboard is untrusted input: any page or app can put any format on it, so the data is never trusted, only checked.

  • Each carrier is size-checked (maxBytes, default 8 MiB) before it is parsed or decoded
  • Own __proto__ keys are dropped while parsing, so merging pasted data can't swap a prototype
  • Migrations run on untrusted data, then the schema validates the result; nothing unvalidated reaches clip.value
  • The HTML carrier is found with a strict token match on a closed alphabet (id;base64url): no HTML parsing, no DOM
  • Reading never throws for clipboard content: every outcome is a result
  • The data is not authenticated. Validate it as you would a form submission, and check permissions server-side
  • The HTML envelope travels wherever the HTML is pasted (a mail, a document), hidden but readable. Copy only what the user may share, or set embedInHtml: false (keyboard paste in Chromium and drops still work through the custom type)

Copying is strict too. The data must survive JSON.stringify unchanged: NaN, Infinity, BigInt, Map, Set, functions and symbols throw an invalid-payload error naming the key instead of turning silently into null or {}. Dates become ISO strings, so read them back with a coercing schema (z.coerce.date()), or convert in encode.

Paste as Markdown

react-smart-copy/markdown turns what people paste into comment boxes, editors and CMS fields into clean Markdown: headings, bold and italic, links, lists (nested and task lists), tables, code blocks and quotes, from Google Docs, Word, Notion, GitHub, web pages and mail. No dependencies, about 7.6 kB.

import { pasteAsMarkdown } from "react-smart-copy/markdown";

<textarea value={body} onChange={(event) => setBody(event.target.value)} onPaste={pasteAsMarkdown} />

That one handler:

  • converts rich pastes (the HTML flavour) and inserts the Markdown at the cursor, replacing the selection
  • keeps the browser's undo (Ctrl/⌘+Z undoes the paste), fires input so React state follows, respects maxlength
  • leaves plain-text pastes alone, because plain text pasted into a Markdown field is usually Markdown already, and leaves code from editors (VS Code, JetBrains) as plain code
  • in a single-line <input>, joins the result into one line

Pass options with onPaste={(event) => pasteAsMarkdown(event, { images: "keep" })}. For other flows, the pieces are separate:

import { escapeMarkdown, htmlToMarkdown, insertText, pasteToMarkdown } from "react-smart-copy/markdown";

// With usePaste or PasteField: Markdown for a paste result (plain text stays as it is).
const pasted = pasteToMarkdown(result); // { markdown, source: "html" | "text", truncated } | null
if (pasted) insertText(textarea, pasted.markdown);

// Copy a rendered article as Markdown (a DOM node is converted in place, not re-parsed).
copy(() => htmlToMarkdown(articleRef.current).markdown);

// Any text, rendered literally by any Markdown renderer.
escapeMarkdown("1. *Not* a list <b>"); // "1\\. \\*Not\\* a list \\<b>"

What real clipboards look like, handled. Google Docs sets bold and italic with CSS on spans, wraps everything in a non-bold <b>, nests lists as siblings and wraps links in google.com/url?q= redirects. Word writes lists as mso-list paragraphs with hidden markers. Mail clients use <div> per line and layout tables. Spreadsheets and Docs tables become GFM tables, merged cells included.

Correct by construction. Every delimiter the converter writes is checked with CommonMark's own pairing algorithm before it is kept, so text always renders as the same text: where CommonMark can't express a style (x**[y]**z), the style is dropped, never the text, and asterisks never leak into the output. Property tests render thousands of random documents with a CommonMark parser and compare the result character by character.

Security. Pasted HTML is untrusted, so:

  • it is parsed in an inert document: scripts never run and nothing is fetched, not even images
  • the output never contains raw HTML: any < in the text is escaped
  • links keep only http:, https: and mailto: (linkProtocols); javascript:, data: and relative links keep only their text
  • images default to their alt text (images: "alt"), so rendering the Markdown loads no tracking pixels. "keep" writes ![alt](url) for https:/http: images, "drop" removes them
  • maxLength (16 MiB), maxNodes (200,000) and maxDepth (200) bound the work; content left out sets truncated: true, and merged-cell expansion is capped

| Option | Default | Notes | | ------ | ------- | - | | images | "alt" | "alt", "keep" or "drop" | | links | "keep" | "text" keeps only the link text | | linkProtocols / imageProtocols | http(s), mailto / https, http | allowed URL schemes | | baseUrl | none | resolves relative links and images | | bullet | "-" | "*" or "+" | | hardBreak | "backslash" | "spaces" for two trailing spaces | | codeAsText (paste) | true | false turns code-only pastes into fenced code blocks |

Copy and paste code

react-smart-copy/code is for the copy button on every code block and for fields people paste code into. It also catches code that reads differently from how it runs. No dependencies, about 7.4 kB (inspectCode alone is about 2.5 kB).

import { useCopy } from "react-smart-copy";
import { codePayload } from "react-smart-copy/code";

const { copy, status } = useCopy();
<pre ref={preRef}>…</pre>
<button onClick={() => copy(() => codePayload(preRef.current!, { language: "tsx" }))}>Copy</button>

What gets copied is the code the reader means to run:

  • A terminal or REPL session copies as its commands. $ npm test followed by its output copies as npm test. Prompts from bash/zsh ($, user@host:~$, (venv) $), PowerShell, cmd, Python, IPython and Node are recognised. Continued commands stay whole: a trailing \, a heredoc or an open quote. prompt: "auto" (the default) only acts when the first line starts with a prompt
  • A highlighted block copies without its chrome. Line-number gutters (Prism, highlight.js, Rouge, react-syntax-highlighter and others), user-select: none and aria-hidden parts, hidden parts, buttons and anything with data-copy-ignore are left out. ignore takes a CSS selector for more
  • It pastes well everywhere. Plain text for editors and terminals, plus monospaced HTML with the block's syntax colours for Google Docs, Word, Slack, Teams and mail. Both flavours always hold the same code
  • Diffs, line numbers, indentation. diff: "after" (or "before") copies one side of a unified diff. lineNumbers: true removes typed line numbers when they count up on every line. Shared indentation, trailing whitespace and blank edges are removed, and line endings are normalised (lineEnding: "crlf" for Windows)

For a code field, pasteAsCode is a ready paste handler:

import { pasteAsCode } from "react-smart-copy/code";

<textarea onPaste={pasteAsCode} />
<textarea onPaste={(event) => pasteAsCode(event, { onPaste: (r) => !r.danger || confirm("Paste anyway?") })} />

It fixes what chat apps and word processors do to code (curly quotes, — for --, no-break spaces, zero-width characters) and dedents the paste. Each line after the first is indented to the cursor line, as code editors do. The paste is one step on the browser's undo stack. onPaste sees every finding first and can cancel. For CodeMirror, Monaco or usePaste, pasteToCode(result) returns the same { code, findings, fixed, remaining, danger }.

Security: code that runs differently from how it reads. inspectCode reports, with line, column and a label such as U+202E RIGHT-TO-LEFT OVERRIDE:

| Kind | Severity | What it catches | | ---- | -------- | --------------- | | bidi-control | danger | Trojan Source (CVE-2021-42574): overrides and isolates that reorder how code is shown. Implicit marks are a warning | | invisible | danger / warning | Hangul fillers that make invisible identifiers, and tag characters that hide ASCII (including hidden instructions in prompts) are danger. Zero-width spaces, joiners outside words and emoji, and runs of variation selectors that smuggle data are warnings | | mixed-script | danger / warning | A Cyrillic or Greek letter inside a Latin word (аdmin with a Cyrillic а). A Latin lookalike is danger; another letter is a warning | | control | warning | Control characters other than tab, line feed, carriage return and form feed, and Unicode line separators | | unusual-space | warning | No-break, em, ideographic and similar spaces | | confusable-punctuation | warning | Curly quotes, dashes, …, the Greek question mark that looks like ;, full-width punctuation outside CJK text |

Joiners inside Arabic, Persian or Indic words and emoji, a single emoji variation selector, and full-width punctuation in CJK comments are left alone. cleanCode applies the safe replacement of every finding. It runs until nothing fixable is left, because removing a zero-width space can expose a mixed word. It never guesses a letter that has no Latin lookalike; those stay in remaining. Copying doesn't clean by default (clean: true turns it on). Pasting does (clean: false turns it off).

import { cleanCode, inspectCode, normalizeCode } from "react-smart-copy/code";

inspectCode(source).danger;              // true for Trojan Source, lookalikes, invisible identifiers
cleanCode("const s = “hi”;").code;      // 'const s = "hi";'
normalizeCode("$ npm i\nadded 1 package"); // "npm i" (also works on the server)

Pasted code is never cut: past maxLength (2,000,000 characters) pasteToCode throws a too-large error, and pasteAsCode leaves the paste to the browser. HTML is parsed inertly and only its text is used.

| Option | Default | Notes | | ------ | ------- | - | | prompt | "auto" copy, false paste | a style ("shell", "powershell", "cmd", "python", "ipython", "node"), { command, continuation } regexes, or false | | diff | false | "after" or "before" | | lineNumbers | false | remove typed line numbers that count up | | dedent / trimTrailingWhitespace / trimBlankLines | true | | | lineEnding / finalNewline | "lf" / false | | | clean | false copy, true paste | true, false or a list of kinds | | html / language (copy) | true / none | add the HTML flavour; class="language-…" on <code> | | indentToCursor / onPaste (pasteAsCode) | true / none | |

Pasted images: private and small

react-smart-copy/image gets pasted and dropped images ready to upload. A photo from a phone usually carries the exact location it was taken, the device and its serial number, and an embedded preview that can show what was cropped out. This module removes that data, and makes the image fit your size and byte limits. No dependencies, about 7.3 kB (about 4.8 kB for the server-safe stripMetadata and readImageInfo).

import { usePaste } from "react-smart-copy";
import { prepareImage } from "react-smart-copy/image";

usePaste({
  accept: ["image"],
  onPaste: async ({ imageFiles }) => {
    const image = await prepareImage(imageFiles[0], { maxWidth: 2048, maxBytes: 1_000_000 });
    upload(image.file); // a File named like the original, metadata removed
    if (image.original.info.metadata.includes("gps")) notice("Location removed from the photo.");
  },
});

Lossless whenever it can be. When only the metadata has to go, nothing is re-encoded. JPEG, PNG, WebP and GIF are rewritten without their metadata, and every pixel stays the same, so screenshots stay sharp and photos lose no quality. The image is decoded and encoded again only when it has to change: to fit maxWidth, maxHeight, maxPixels or maxBytes, to change its format, or because it is AVIF or HEIC.

What is removed: Exif (camera, lens, dates, serial numbers), the GPS location, embedded thumbnails, XMP (edit history, authors), IPTC (captions, credits), comments, PNG text chunks, multi-picture and depth images (MPF), content credentials (C2PA), and data appended after the image (motion-photo videos). The colour profile is kept (keepColorProfile: false removes it). The orientation is kept in a 26-byte Exif block holding nothing else, so a photo still displays the right way up (keepOrientation: false removes it).

Fits a byte budget. For maxBytes, JPEG and WebP search for the highest quality that fits (down to minQuality, 0.5 by default), and only then reduce the size. A screenshot stays PNG when it fits. When it doesn't, type: "auto" turns it into WebP, or JPEG where the browser can't encode WebP. If the limit can't be met, you get a too-large error, never a file that is still too big. Large reductions are drawn in halving steps, so text and edges stay clean.

Safe with hostile files. The format is read from the bytes, never from the file name or the claimed type. Sizes come from the header and are checked against maxInputPixels (100 million) before decoding, which stops decompression bombs. Every parser read is bounded by its own box or chunk. Fuzz tests feed random and mutated files to every parser. Animations keep every frame by default (animated: "keep"): a resize they would need is a too-large error, "first-frame" makes a still image, and "reject" refuses them. Every failure is a typed error: too-large, unsupported-format, unsupported (re-encoding outside a browser), timeout or aborted (signal).

import { readImageInfo, stripMetadata } from "react-smart-copy/image";

// Anywhere, including the server (Node 18+): no decoding, no canvas.
const info = await readImageInfo(file);
// { format: "jpeg", width: 4032, height: 3024, orientation: 6, animated: false,
//   metadata: ["exif", "gps", "thumbnail", "icc", "other"] }

const { blob, removed } = await stripMetadata(file); // bit-for-bit the same picture

| Option (prepareImage) | Default | Notes | | ----------------------- | ------- | - | | maxWidth / maxHeight | none | only ever makes images smaller | | maxPixels | 16,777,216 | Safari's canvas limit | | maxBytes | none | quality first, then size; too-large when it can't | | type | "auto" | "image/jpeg", "image/png" or "image/webp" | | quality / minQuality | 0.85 / 0.5 | JPEG and WebP | | background | "#fff" | under transparent pixels in JPEG | | animated | "keep" | "first-frame" or "reject" | | keepColorProfile | true | re-encoded images are written in sRGB | | maxInputPixels / maxInputBytes | 100,000,000 / 100 MiB | checked before decoding | | timeoutMs / signal | 30 s / none | | | environment | the browser | your own decoder (e.g. WebAssembly HEIC) or a test double |

width and height in the result are the displayed size. Browsers turn JPEG and PNG by their orientation, and Chromium doesn't turn WebP. removed is read back from the output, not assumed, because some browsers' encoders add their own colour profile.

Pasted and dropped files: safe by content

react-smart-copy/files takes the files people paste or drop, whole folders included, and decides for each one whether it is safe and wanted. The type comes from the file's content, never from its name or the type the browser claims. So an HTML page named invoice.pdf, a program named invoice.pdf.exe, or a name that hides its real extension behind a right-to-left character is turned away, with a reason you can show. No dependencies, about 6.9 kB (about 2.3 kB for safeFileName alone). It works the same on a server.

import { acceptFiles } from "react-smart-copy/files";

<div
  onDragOver={(event) => event.preventDefault()}
  onDrop={(event) => {
    event.preventDefault();
    // Call it in the handler, before any await: the browser empties the drop when the event ends.
    void acceptFiles(event, { accept: ".pdf,image/*", maxFileBytes: 20_000_000, digest: true }).then(({ accepted, rejected }) => {
      for (const file of accepted) upload(file.upload, file.path); // safe name, real type
      for (const file of rejected) toast(file.message); // "invoice.pdf.exe is a program or script, which can't be added."
    });
  }}
/>

It takes a drop or paste event (DOM or React), its DataTransfer, the FileList of an <input type="file">, or any list of files. With usePaste and PasteField, pass the result's files.

Known by content. The first 8 KB of each file is read, and more than 50 formats are recognised by their signatures: PDF; images (JPEG, PNG, GIF, WebP, AVIF, HEIC, TIFF, BMP, ICO, PSD, SVG); audio and video; Office and OpenDocument files, told apart inside their ZIP; archives; fonts; SQLite; HTML, XML, JSON and other text; and programs (Windows, Linux and macOS executables, WebAssembly, Java and Android archives, shortcuts and scripts). Each accepted file's type is the detected one, never the claimed one: upload is the same bytes as a File with that type and the safe name.

Risky files are rejected by default. Each file is checked for these risks, and by default any of them rejects it. Pass block to choose which ones do.

| Risk | What it catches | | --- | --- | | executable | Programs and scripts, by content or by a name the system runs (.exe, .bat, .ps1, .js, .jar, .iso, .lnk, ...) | | active-content | HTML and SVG, and XML with XHTML or scripts: they run script when opened from your site | | macros | Office files that can hold macros (.docm, .xlsm, or a vbaProject.bin inside) | | type-mismatch | The content is not what the extension says: report.pdf holding HTML | | double-extension | invoice.pdf.exe, or invoice.pdf .exe | | hidden-characters | Bidirectional controls and invisible characters in the name: photo\u202Egpj.exe shows as photoexe.jpg | | empty | No bytes: often a folder dropped where folders aren't read |

Folders, within limits. Dropped folders are walked depth-first, and each file's path keeps where it was (photos/2024/a.jpg). maxFiles (100), maxFileBytes (100 MiB), maxTotalBytes (1 GiB) and maxDepth (8) bound the walk, so a dropped home folder can't hang the page. With digest: true, each file's SHA-256 is computed and a repeated file is rejected as a duplicate.

Names safe everywhere. safeFileName keeps only the last path segment (no ../), removes hidden and control characters, replaces the characters Windows, macOS and Linux reject, prefixes Windows device names (CON, NUL, COM1), and fits the name to 255 UTF-8 bytes, keeping its extension.

import { inspectFile, safeFileName } from "react-smart-copy/files";

// In the browser or on a server (Node 18+), for a file you already have.
const inspection = await inspectFile(file);
// { name: "invoice.pdf", safeName: "invoice.pdf", extension: "pdf", type: "text/html",
//   detected: { mime: "text/html", family: "text", ... }, mismatch: true,
//   risks: ["active-content", "type-mismatch"], ... }

safeFileName("../../uploads/CON.txt"); // "_CON.txt"

| Option (acceptFiles) | Default | Notes | | --- | --- | --- | | accept | any | as in <input accept>: ".pdf,image/*", judged by content | | block | every risk | [] accepts risky files; their inspection.risks still say | | maxFiles | 100 | the walk stops at the next file, and truncated is true | | maxFileBytes / maxTotalBytes | 100 MiB / 1 GiB | | | folders / maxDepth | true / 8 | | | digest | false | SHA-256 for each file, and duplicate rejections; needs Web Crypto (a secure page, or Node.js 20+) | | names | | maxBytes, replacement and fallback for safeFileName | | signal | none | aborts with an aborted CopyFailure |

Each rejection has a reason (blocked, not-accepted, too-large, total-too-large, too-many, too-deep, unreadable or duplicate), the file's risks, and a message that names the file by its safe name. Checking files in the browser is for the people using your app: check them again on your server, where inspectFile works the same, and serve uploads with Content-Disposition: attachment and X-Content-Type-Options: nosniff.

Secrets: keep keys out of pastes and copies

react-smart-copy/secrets keeps secrets from travelling through the clipboard. It finds API keys, tokens, private keys, passwords, card numbers and IBANs in text people paste or copy, and redacts or blocks them: in a chat box, a support ticket, or a log someone copies from your app. No dependencies, about 4.1 kB (about 3.3 kB for scanSecrets alone), and it works the same on a server.

import { pasteWithoutSecrets } from "react-smart-copy/secrets";

<textarea
  onPaste={(event) =>
    pasteWithoutSecrets(event, {
      // "redact" (the default) inserts the paste with each secret replaced; "block" inserts nothing.
      action: "redact",
      onSecrets: (found) => toast(`Removed a ${found[0].label} from your message.`),
    })
  }
/>

What it finds. Each finding has a kind, a confidence, and a masked preview.

  • Service keys and tokens, by their documented formats: AWS access key IDs, GitHub (classic and fine-grained), GitLab, Slack tokens and webhooks, Stripe live keys, Google API keys, npm, PyPI, SendGrid, Twilio, Azure storage keys, and JSON Web Tokens (whose header must decode).
  • Private keys (PEM and OpenSSH), from their BEGIN line to their END line.
  • Passwords inside other text: in URLs (postgres://app:password@db) and in connection strings (;Password=...).
  • Card numbers and IBANs, by checksum: Luhn plus the card brand's ranges and lengths, and mod-97 plus the country's IBAN length.
  • Random-looking values assigned to secret names: API_KEY="...", "password": "...", client_secret: ..., including an AWS secret key next to its name. Placeholders (changeme, <token>, ${TOKEN}) and references (process.env.TOKEN) are left alone.

Safe to run on anything. Every pattern runs in linear time: no nested quantifiers, no lookbehind. Hostile text of hundreds of kilobytes is scanned in milliseconds. A finding never holds the secret: it holds where the secret is (start, end), its kind and label, a confidence, and masked. masked keeps at most a known prefix and the last 4 characters (ghp_ and the last 4, or •••• 4242 for a card), and nothing at all for passwords. Text past maxLength (1,000,000 characters) is not scanned, and redactSecrets drops it rather than pass it on unchecked.

import { redactSecrets, scanSecrets, secretRedactor } from "react-smart-copy/secrets";

const { findings } = scanSecrets(text, { minConfidence: "high" });
// [{ kind: "github-token", label: "GitHub token", confidence: "high", start: 13, end: 53, masked: "ghp_…a1B2" }]

redactSecrets("token=ghp_…").text; // "token=[redacted GitHub token]"

// Mask secrets in what people copy from a log viewer or an admin console.
useCopyInterceptor(logRef, { transform: secretRedactor() });

| Option | Default | Notes | | --- | --- | --- | | kinds | every kind | the kinds to look for | | minConfidence | "medium" | "high" leaves out values that only look like secrets | | maxLength | 1,000,000 | characters scanned | | replace | [redacted <label>] | redactSecrets, pasteWithoutSecrets and secretRedactor | | action | "redact" | pasteWithoutSecrets: "block" inserts nothing | | onSecrets | | pasteWithoutSecrets: called with the findings and what was done |

A paste is redacted in a <textarea> or text <input>, with undo intact; anywhere else, a paste with secrets is blocked. Scanning in the browser protects the people using your app. Scan again on your server before you store or forward text, because scanSecrets works the same there.

One-time codes

react-smart-copy/otp handles the one-time codes people copy from an SMS or an e-mail. extractOtp finds the code in a message, and leaves prices, dates, times, phone numbers and order numbers alone. useOtpField (in the React entry) wires a row of single-character boxes: typing moves on, Backspace goes back, and pasting a whole message into any box fills them all. No dependencies, about 3.6 kB (about 2 kB for extractOtp alone).

import { useOtpField } from "react-smart-copy";

const otp = useOtpField({ length: 6, onComplete: verify });

<div role="group" aria-label="Verification code">
  {Array.from({ length: 6 }, (_, index) => (
    <input key={index} {...otp.getInputProps(index)} />
  ))}
</div>
  • SMS autofill on iOS and Android (autocomplete="one-time-code" on the first box), and the WebOTP API with webOtp: true, cancelled when the boxes unmount or the code is typed.
  • Every box is named (Digit 1 of 6), numeric codes get the numeric keyboard, and the arrow keys, Home, End, Backspace and Delete work as people expect.
  • extractOtp reads codes on their own (482 913), origin-bound lines (@example.com #482913, refused for another domain), and numbers next to a word that names them in twenty languages, with digits of any script. Linear time, first 100,000 characters.
import { extractOtp } from "react-smart-copy/otp";

extractOtp("Your verification code is 482913. It expires in 10 minutes.");
// { code: "482913", source: "keyword", confidence: "high", domain: null }
extractOtp("Your order 48291 of $1,249.00 ships on 24/10."); // null

Copy links

react-smart-copy/link is for "Copy link" and "Share" buttons, and for fields people paste links into. No dependencies, about 4 kB (linkPayload alone is about 1.8 kB).

import { useCopy } from "react-smart-copy";
import { linkPayload } from "react-smart-copy/link";

const { copy, status } = useCopy();
<button onClick={() => copy(() => linkPayload({ url: issue.url, title: issue.title }, { clean: true }))}>Copy link</button>

It pastes as the right thing everywhere. The clipboard gets a titled link (<a href>) and the URL. Slack, Google Docs, Notion, Teams, Word and mail paste the title as a link; editors, terminals and the address bar paste the URL. text: "markdown" makes the plain text [title](url) for Markdown fields, "title-and-url" puts both on two lines. Dragged with useDragCopy ({ target: "drag" }), it also carries text/uri-list and Firefox's text/x-moz-url, so it drops into tabs, bookmarks and other apps as a link.

Safe by default. Every URL is resolved to an absolute one and checked: only https:, http:, mailto: and tel: are allowed (protocols changes that), so javascript: and data: links are never copied or pasted. User names and passwords in a URL are always removed. International domains are written in punycode, so a lookalike domain shows as one. Titles are cleaned of hidden and reordering characters and capped at 300 characters (maxTitleLength), and all HTML is escaped. Invalid input is a typed invalid-payload error.

Tracking parameters removed. clean: true (or cleanLinkUrl(url)) removes utm_*, fbclid, gclid, msclkid and other click trackers, and keeps every other parameter and the #section. clean: { params, keep } sets your own list.

import { cleanLinkUrl, linkPayload, resolveLink } from "react-smart-copy/link";

cleanLinkUrl("https://shop.example/p/42?utm_source=news&color=red#reviews");
// "https://shop.example/p/42?color=red#reviews"

linkPayload(anchorElement); // an <a>: its resolved href and its text

Links to a passage. textFragmentUrl makes a link that scrolls to a passage and highlights it (#:~:text=), in Chrome, Edge, Safari and Firefox. Other browsers open the page as usual. selectionLink() links to what the reader has selected, with the page title and the quote, ready for linkPayload. Passing a function to copy turns an invalid link into an invalid-payload error state rather than an exception:

import { linkPayload, selectionLink, textFragmentUrl } from "react-smart-copy/link";

<button onClick={() => { const link = selectionLink(); if (link) void copy(() => linkPayload(link)); }}>Copy link to selection</button>

textFragmentUrl("https://example.com/docs#install", "npm install react-smart-copy");
// "https://example.com/docs#install:~:text=npm%20install%20react%2Dsmart%2Dcopy"

Long passages link by their first and last words, each taken from within one paragraph, as the syntax requires. When the passage appears more than once on the page, the words around it are added so the link finds the right one.

Pasting links. pasteAsLink turns selected text into a link when a URL is pasted over it, as GitHub, Slack and Notion do. It writes [selection](url) by default (format changes that) and keeps the browser's undo. Every other paste is left alone. readLink(result) reads the one link a paste or drop holds, with its title, from text/uri-list, a single <a> or a single URL, or returns null.

import { pasteAsLink, readLink } from "react-smart-copy/link";

<textarea onPaste={pasteAsLink} />

usePaste({ accept: ["text", "html", "text/uri-list"], onPaste: (result) => {
  const link = readLink(result);
  if (link) addBookmark(link.url, link.title);
} });

| Option | Default | Notes | | ------ | ------- | - | | text | "url" | plain text: "url", "title", "title-and-url" or "markdown" | | target | clipboard | "drag" adds text/uri-list and text/x-moz-url | | clean | false | true, or { params, keep } | | protocols | https:, http:, mailto:, tel: | allowed schemes | | base | the page's address | resolves relative links; needed on the server | | maxTitleLength | 300 | | | maxWords / contextWords | 10 / 4 | text fragments |

Copy styled content: paste it as it looks

Copy a card, a note, a report or a table from your app and paste it into Gmail, Outlook or Google Docs, and it usually comes out wrong. The classes and stylesheets it depended on don't travel. Modern CSS colours like Tailwind's oklch() mean nothing to mail clients. Light text from a dark theme turns invisible on a white page. Relative links and images break. And the browser's own copy fills the paste with layout CSS and page chrome. react-smart-copy/rich copies what the reader sees, in HTML that every destination keeps.

import { useCopy, useCopyInterceptor } from "react-smart-copy";
import { richPayload, richTransform } from "react-smart-copy/rich";

const { copy } = useCopy();
<button onClick={() => copy(() => richPayload(cardRef.current))}>Copy for email</button>

// Ctrl/⌘+C of any selection inside the article is portable too:
useCopyInterceptor(articleRef, { transform: richTransform() });

What it writes:

  • Only what's safe and widely supported. Text, headings, lists, tables, links, images, quotes and code. Scripts, event handlers, embeds, SVG, media, forms and controls are left out. So are hidden content and anything marked data-copy-ignore or aria-hidden. Every attribute is checked against an allowlist.
  • Styles inline, from what the reader sees. For content on the page, the