Copy is an interaction. Treat it like one.
A headless React primitive for the whole clipboard moment: copying, pasting, coordinated groups, SVG and DOM capture. Honest errors, no flicker, accessible by default.
useCopy — try it. Then paste anywhere.
Hover a row, or Tab to it and press Enter.
State tape — real vs. displayed
- Each copy logs how long it really took, and what you were shown.
usePaste — read from the clipboard
Click the button or focus the field and press Ctrl+V / ⌘V.
CopyGroup — one "Copied" at a time
Copy any row. The previous one resets immediately.
react-smart-copy/capture — copy SVG as PNG
The chart below is a live SVG element. Copy it to paste as a PNG image.
svgToPngBlob() rasterizes the element to a PNG Blob. No canvas API needed.
What it copies and pastes, and what it won't pretend to
This library only promises what browsers actually support. Everything else is a typed error, never a silent success.
| Content | Support | Notes |
|---|---|---|
| Plain text | Copy + Paste | Everywhere the async Clipboard API exists. |
| Rich HTML | Copy + Paste | Copy always paired with a required plain-text fallback. |
| Images | PNG |
image/png only. Paste images are byte-verified before acceptance.
|
| JSON | Copy | Serialised to text; JSON isn't a native clipboard format. |
| Several formats at once | Copy | kind: 'multi', each type checked with ClipboardItem.supports(). |
| SVG / DOM element as image | Capture | react-smart-copy/capture — rasterizes to PNG, no extra dep for SVG. |
| Files (PDF, DOCX, ZIP) | No | No browser API can put files on the system clipboard. |
| Plain HTTP pages | No | The Clipboard API needs HTTPS or localhost. You get insecure-context. |
Quick start
One component gives you a real button named "Copy Email", reveal on hover, focus and touch, a screen-reader announcement, ignored double-clicks, and a return to idle after two seconds.
import { CopyField } from 'react-smart-copy';
export function ContactRow() {
return (
<CopyField.Root value="shubh@example.com" label="Email" className="group row">
<CopyField.Label />
<CopyField.Value />
<CopyField.Trigger className="opacity-0 group-data-[revealed]:opacity-100 focus-visible:opacity-100" />
</CopyField.Root>
);
}
Prefer your own markup? Use the hook. copy() never rejects; it resolves to an outcome you can branch on.
import { useCopy, useDisplayStatus } from 'react-smart-copy';
function InvoiceNumber({ id }: { id: string }) {
const { copy, status } = useCopy();
const shown = useDisplayStatus(status);
return (
<button type="button" onClick={() => void copy(id)}>
{shown === 'copied' ? 'Copied' : 'Copy'}
</button>
);
}
usePaste
Two paths: a button that calls paste() (may prompt for permission),
and targetProps.onPaste for keyboard paste (no prompt, fires inside the event).
import { usePaste } from 'react-smart-copy';
function PasteZone() {
const { paste, targetProps, status, result } = usePaste({
accept: ['text/plain', 'image/png'],
});
return (
<>
<button type="button" onClick={paste}>
{status === 'reading' ? 'Reading…' : 'Paste'}
</button>
<textarea {...targetProps} placeholder="Or Ctrl+V here" />
{result?.text && <pre>{result.text}</pre>}
</>
);
}
CopyGroup
Wrap a table or list in <CopyGroup> — only one row shows "Copied" at a time, automatically.
import { CopyGroup, CopyField } from 'react-smart-copy';
function ContactCard({ contact }) {
return (
<CopyGroup>
<CopyField.Root value={contact.name} label="Name" ><CopyField.Trigger /></CopyField.Root>
<CopyField.Root value={contact.email} label="Email" ><CopyField.Trigger /></CopyField.Root>
<CopyField.Root value={contact.phone} label="Phone" ><CopyField.Trigger /></CopyField.Root>
</CopyGroup>
);
}
Capture — SVG and DOM elements
import { svgToPngBlob, captureImage } from 'react-smart-copy/capture';
import { useCopy } from 'react-smart-copy';
// SVG element → PNG blob (no rasteriser needed):
const blob = await svgToPngBlob(svgRef.current);
// DOM element → image (bring your own rasteriser):
const { copy } = captureImage(
() => divRef.current,
{ rasterize: (el) => html2canvas(el).then(c => c.toDataURL()) }
);
No flicker, by design
A text copy takes one to five milliseconds. Render that state
directly and "Copying…" flashes for a single frame while the button
resizes. Look at the state tape above: the real state went through
copying, but you were never shown it.
status
The real, instantaneous state. Use it for logic, analytics and tests.
displayStatus
What to render. "Copying…" appears only after 150 ms, then stays at least 400 ms, so it can't blink.
Stable width
The default label stacks every state in one grid cell, so the button never changes size.
Payloads
A bare string is text. Everything else is a discriminated union, so TypeScript stops you from copying HTML without a plain-text fallback, or an image from a URL.
copy('PLT-29018');
copy({ kind: 'html', html: '<b>INV-29018</b>', text: 'INV-29018' });
copy({ kind: 'json', value: customer, pretty: true });
copy({ kind: 'image', blob: () => chartToPngBlob() }); // runs inside the click
copy(() => editor.getValue()); // read at click time
When it fails, you know why
error.message is for developers. Show users
describeCopyError(error), or map
error.type through your own translations.
error.type |
Meaning | Retry helps |
|---|---|---|
unsupported |
No Clipboard API: old browser, server, locked webview | No |
insecure-context |
Not HTTPS or localhost | No |
permission-denied |
Blocked by the browser or user | Yes |
not-focused |
The page lost focus mid-copy | Yes |
invalid-payload |
Empty or malformed input | No |
unsupported-format |
This browser can't write that type | No |
blob-generation-failed |
The image source threw or returned no Blob | Yes |
no-content |
Paste had no accepted MIME types | No |
too-large |
Content exceeded maxBytes or maxItems | No |
timeout |
Rasteriser or clipboard write exceeded timeoutMs | Yes |
aborted |
Caller aborted via AbortSignal | No |
max-retries-exceeded |
Retried too many times | No |
unknown |
Anything unclassified | Yes |
Accessible without extra work
A real button
Reachable with Tab, activated with Enter or Space, with a stable name like "Copy Email".
Announced results
A live region, mounted before its first message, says "Copied to clipboard" or explains the error.
Focus never jumps
The trigger is never disabled mid-copy, because disabling a focused button throws focus back to the page.
Hidden, not removed
Revealed actions stay in the tab order. On devices that can't hover, they're always visible.
Next.js and server rendering
The React entry ships with "use client", so it imports
cleanly from Server Components. Nothing touches window
during render, so server and client agree and hydration never mismatches.
react-smart-copy/core is framework-agnostic; this page runs on it with plain JavaScript.
API at a glance
| Export | Entry | What it is |
|---|---|---|
CopyField.Root / .Label / .Value / .Trigger |
default | Full compound component with reveal, announcement and retry built in. |
CopyGroup |
default | Wraps fields — only one shows "Copied" at a time, via React context. |
useCopy(options?) |
default | { state, status, error, canRetry, copy, retry, reset } — all stable. |
usePaste<T>(options) |
default | { status, result, error, paste, pasteEvent, reset, targetProps } |
useDisplayStatus(status) |
default | The flicker-free status for rendering — delays "copying", holds it for minPendingMs. |
useRevealOnInteraction() |
default | Hover, keyboard focus and touch reveal for any row of actions. |
createCopyMachine() |
/core | Framework-agnostic state machine — subscribe/snapshot pattern. |
createPasteMachine() |
/core | Same shape as copy machine. paste() and pasteEvent(event). |
createCopyCoordinator() |
/core | activate / deactivate / getActive — powers CopyGroup. |
svgToPngBlob(source, options?) |
/capture | SVG element or markup string → PNG Blob. No external dependency. |
captureElement(node, options) |
/capture | DOM element → PNG Blob via caller-supplied rasteriser. |
captureImage(getNode, options?) |
/capture | Returns a { kind: 'image', blob } payload ready for copy(). |
Styling hooks on Root and Trigger: data-display-state,
data-state and data-revealed. Works with
Tailwind, CSS modules, CSS-in-JS, shadcn, MUI and Ant Design.