generic-copy-button
v0.1.1
Published
A self-contained, context-aware React copy button. Point it at any element via a ref and it copies the right thing — text in render order, or a rasterised image for charts/canvases/images.
Maintainers
Readme
generic-copy-button
A single, self-contained, context-aware React copy button. Point it at any element via a ref and it figures out what to copy:
- All text (prose, a code block) → copies the text content, in render order, to the clipboard.
- Contains something graphical (
<img>,<canvas>,<svg>,<picture>,<video>) → rasterises it and copies an image blob to the clipboard, ready to paste into a doc, Slack, etc.
Unlike a wrapper-based design, GenericCopyButton is the whole thing in one component: it takes the
target/source element's ref as a prop and does everything itself — content detection, clipboard
write, the copied→reset feedback, disable-when-empty tracking, and debouncing. Works in any React
18+ app (Next.js included).
npm install generic-copy-button
# or: pnpm add generic-copy-button
# or: yarn add generic-copy-button
reactandreact-dom(>=18) are peer dependencies — they come from your app.
Usage
The button is decoupled from its target. Render it anywhere and point it at the element you want
to copy with targetRef.
import { useRef } from 'react';
import { GenericCopyButton } from 'generic-copy-button';
function CodeBlock() {
const ref = useRef<HTMLDivElement>(null);
return (
<div style={{ position: 'relative' }}>
{/* Button inside the target — it excludes itself from detection automatically. */}
<div ref={ref} style={{ position: 'relative' }}>
<GenericCopyButton targetRef={ref} ariaLabel="Copy code" className="copy-btn" />
<pre><code>const x = 1;</code></pre>
</div>
</div>
);
}Decoupled — the button lives in a toolbar, the target is elsewhere:
const chartRef = useRef<HTMLDivElement>(null);
return (
<>
<Toolbar>
<GenericCopyButton targetRef={chartRef} ariaLabel="Copy chart" />
</Toolbar>
<div ref={chartRef}>
<canvas /* … */ />
</div>
</>
);Props
| Prop | Type | Default | Description |
| ------------- | --------------------------------------------- | ----------- | ----------- |
| targetRef | RefObject<HTMLElement \| null> | — (required)| The element whose contents are inspected & copied. |
| className | string | — | Class name(s) for the underlying <button>. |
| ariaLabel | string | 'Copy' | Accessible label + visible idle text. |
| copiedLabel | string | 'Copied' | Label/text shown after a successful copy. |
| resetDelay | number (ms) | 2000 | How long the "copied" state lasts before reverting. |
| disableWhenEmpty | boolean | true | Auto-disable (with not-allowed cursor + tooltip) when there's nothing to copy. |
| emptyTitle | string | 'Nothing to copy' | Tooltip shown when disabled because the target is empty. |
| debounceDelay | number (ms) | 500 | Leading-edge debounce window; rapid repeat clicks within it are ignored. |
| iconOnly | boolean | false | Render only the icon; label stays available via aria-label. |
| onCopy | (content: CopyContent) => void \| Promise | — | Override the default clipboard write. Receives the resolved content. |
| onCopyError | (error: unknown) => void | — | Called when resolving content or writing fails. |
| copyIcon / copiedIcon | ReactNode | built-in | Custom idle / success icons. |
| …rest | ButtonHTMLAttributes | — | Any other prop is forwarded to the <button>. |
CopyContent is a discriminated union:
type CopyContent =
| { kind: 'text'; text: string }
| { kind: 'image'; blob: Blob; source: HTMLElement };Behaviour notes
- Text is copied with rendered structure, not as flat
textContent: block elements (<p>, headings, list items, …) keep their line breaks,<li>items get•/1.markers indented by nesting depth,<br>becomes a newline, and code-block (<pre>) whitespace is preserved verbatim. - Charts/images (
<canvas>,<svg>,<img>, …) are copied as an image. When the graphical element has sibling text labels (a caption above the chart, an axis/legend label below it, a data table, …), the whole target is snapshotted so those labels are baked into the picture. A graphical element on its own is copied directly (preserving the original bytes/resolution).- The whole-target snapshot uses an SVG
<foreignObject>(computed styles inlined, canvases converted to their pixel snapshots). Caveats: cross-origin images can taint the canvas and un-embedded web fonts fall back to a system font; if the snapshot fails it falls back to copying just the graphical node.
- The whole-target snapshot uses an SVG
- Self-exclusion: if you render the button inside its target, it (and its own icon
<svg>) is automatically excluded from the detection, so a text-only target is never misread as graphical. - Nothing to copy? The button auto-disables: it gets
aria-disabled, anot-allowedcursor, adata-emptyattribute (for styling) and anemptyTitletooltip. It tracks the target live with aMutationObserver, so it re-enables the moment copyable content appears. Disable viadisableWhenEmpty={false}. - Debounced: a leading-edge
debounceDelaywindow plus an in-flight guard mean a rapid double/triple click (or clicks while a copy is still resolving) only copy once. - On success the icon swaps to a checkmark and the label becomes
copiedLabel, reverting afterresetDelay. onClickyou pass is still called first; callevent.preventDefault()in it to cancel the copy.typedefaults to"button"so it never submits a surrounding form.- Layout is yours: the button renders inline; position it however you like (e.g. a
position: absolutecorner). If you place it inside the target, reserve space (padding) so it doesn't overlap the content it copies. - Copying images uses the async Clipboard API (
navigator.clipboard.write+ClipboardItem), which requires a secure context (https/localhost). Cross-origin images need CORS to be rasterised.
Development
Toolchain: pnpm + Vite (library build) + Vitest (tests) + Storybook (react-vite builder).
pnpm install
pnpm test # vitest run (jsdom + @testing-library)
pnpm test:watch # vitest watch mode
pnpm typecheck # tsc --noEmit
pnpm build # vite build → dist/ (ESM + CJS + bundled .d.ts)
pnpm storybook # storybook dev on :6006
pnpm build-storybook # static Storybook buildLicense
MIT © Mohib Mansuri
