react-smart-copy

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.

$ npm install react-smart-copy
useCopy usePaste CopyGroup CopyField svgToPngBlob captureElement 0 deps v0.2.0

useCopy — try it. Then paste anywhere.

Hover a row, or Tab to it and press Enter.

Email shubh@example.com
Phone +91 98765 43210
ID PLT-29018
Rich HTML INV-29018 · Web design · ₹12,400
JSON { "id": "CUS-77", "active": true }
PNG image
Failure path Fails on purpose, to show the honest error.

State tape — real vs. displayed

  1. 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.

Click the button or focus the field below and paste.

CopyGroup — one "Copied" at a time

Copy any row. The previous one resets immediately.

Name Priya Mehta
Email priya@designco.in
Phone +91 80001 22334
Website designco.in

react-smart-copy/capture — copy SVG as PNG

The chart below is a live SVG element. Copy it to paste as a PNG image.

Weekly active users Mon Tue Wed Thu Fri 0 50 100 Weekly Active Users

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.