react-smart-copy
v1.21.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
Downloads
3,196
Maintainers
Keywords
Readme
react-smart-copy
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 timeuseCopyInterceptor: control what Ctrl/⌘+C copies from your page — attribution, redaction, Excel-ready tables, no-copy zonesreact-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 ArkTypereact-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 lettersreact-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 toreact-smart-copy/files: take pasted and dropped files and whole folders safely. Types come from each file's content, so a program namedinvoice.pdf.exeor a web page namedreport.pdfis turned away with a reason, names are made safe for every operating system, and limits cap count, size and depthreact-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 secretreact-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 linkreact-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 HTMLreact-smart-copy/color: copy colours in any CSS format (hex tooklch()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 huereact-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 errorreact-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-copyWorks 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 thanpendingDelayMs(150 ms), then stays at leastminPendingMs(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 valueA 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 atmaxLength - Deadlines.
timeoutMs(default 15 s) and an optionalsignal: 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; passwindow.devicePixelRatioor 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.
useCopywrites to the clipboard when the user clicks a button.useDragCopywrites to theDataTransferwhen 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
inputso React state follows, respectsmaxlength - 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:andmailto:(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"writesforhttps:/http:images,"drop"removes them maxLength(16 MiB),maxNodes(200,000) andmaxDepth(200) bound the work; content left out setstruncated: 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 testfollowed by its output copies asnpm test. Prompts frombash/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: noneandaria-hiddenparts, hidden parts, buttons and anything withdata-copy-ignoreare left out.ignoretakes 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: trueremoves 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
BEGINline to theirENDline. - 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.
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 textLinks 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-ignoreoraria-hidden. Every attribute is checked against an allowlist. - Styles inline, from what the reader sees. For content on the page, the computed
styles are read: colours, fonts, sizes, weight, alignment, borders, padding, list markers.
They are written only where they change, so the HTML stays small. Layout CSS (
flex,grid,position) is never copied, because no destination keeps it. - Colours every mail client understands. Every colour, from
oklch(),color(),rgb(… / 50%)or a CSS variable, becomes an opaque hex value drawn over its background. Withcolors: "readable"(the default), text that would fall under 3:1 contrast where it lands, like light text from a dark theme, takes the destination's own colour."keep"copies every colour, and"none"copies none. - Absolute, safe URLs. Links resolve against the page and keep only
https:,http:,mailto:andtel:(protocolschanges this). Images keephttps:andhttp:sources with their displayed size as attributes, which Outlook needs.data:images needdataImages: true, because many mail clients block them.images: "alt"writes their alt text instead, andimages: "drop"leaves them out. - Structure that survives the paste. Rows sit in table bodies. A block inside a paragraph is written inline. Nothing is left where an HTML parser would move it.
- Readable plain text for editors and plain fields: lists with bullets or numbers,
table cells separated by tabs, line breaks kept.
text: (root) => htmlToMarkdown(root).markdowngives Markdown instead.
prepareRich(source, options) returns { html, text, removed, truncated }. removed
lists what was left out and why (unsafe-element, unsafe-link, unreadable-color,
hidden, …), for logs and audits. The source can be an element, a selection Range, a
fragment or HTML text. HTML text is parsed inertly, and only its style attributes are
read. The output is built in a separate, inert document, so nothing is inserted into your
page and no image loads while i
