useCopyToClipboard — Copy text to the clipboard with status feedback
Recipe
import { useState, useCallback, useRef } from "react";
interface UseCopyToClipboardReturn {
/** The most recently copied text, or null */
copiedText: string | null;
/** Whether text was recently copied (resets after timeout) */
isCopied: boolean;
/** Copy the given text to the clipboard */
copy: (text: string) => Promise<boolean>;
/** Reset the copied state manually */
reset: () => void;
}
function useCopyToClipboard(
resetDelay: number = 2000
): UseCopyToClipboardReturn {
const [copiedText, setCopiedText] = useState<string | null>(null);
const [isCopied, setIsCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reset = useCallback(() => {
setCopiedText(null);
setIsCopied(false);
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const copy = useCallback(
async (text: string): Promise<boolean> => {
// Try the modern Clipboard API first
if (navigator?.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
setCopiedText(text);
setIsCopied(true);
// Auto-reset after delay
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setIsCopied(false);
timerRef.current = null;
}, resetDelay);
return true;
} catch {
// Clipboard API failed (e.g., permissions denied)
}
}
// Fallback: execCommand for older browsers
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const success = document.execCommand("copy");
document.body.removeChild(textarea);
if (success) {
setCopiedText(text);
setIsCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setIsCopied(false);
timerRef.current = null;
}, resetDelay);
}
return success;
} catch {
return false;
}
},
[resetDelay]
);
return { copiedText, isCopied, copy, reset };
}When to reach for this: You have a "Copy" button next to code snippets, API keys, URLs, or share links and want visual feedback when the copy succeeds.
Working Example
"use client";
function CodeBlock({ code }: { code: string }) {
const { isCopied, copy } = useCopyToClipboard(3000);
return (
<div style={{ position: "relative", background: "#1e1e1e", padding: 16, borderRadius: 8 }}>
<pre style={{ color: "#d4d4d4", margin: 0 }}>
<code>{code}</code>
</pre>
<button
onClick={() => copy(code)}
style={{
position: "absolute",
top: 8,
right: 8,
padding: "4px 12px",
background: isCopied ? "#22c55e" : "#3b82f6",
color: "#fff",
border: "none",
borderRadius: 4,
cursor: "pointer",
transition: "background 0.2s",
}}
>
{isCopied ? "Copied!" : "Copy"}
</button>
</div>
);
}
function ShareLink({ url }: { url: string }) {
const { isCopied, copy } = useCopyToClipboard();
return (
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input value={url} readOnly style={{ flex: 1, padding: 8 }} />
<button onClick={() => copy(url)}>
{isCopied ? "Link copied!" : "Share"}
</button>
</div>
);
}What this demonstrates:
- Button text and color change to confirm the copy succeeded
isCopiedauto-resets after 3 seconds (or the default 2 seconds)- The async
copyfunction returns a boolean for programmatic success checks - Fallback to
document.execCommandfor browsers without the Clipboard API
Deep Dive
How It Works
- Clipboard API:
navigator.clipboard.writeTextis the modern, promise-based API. It requires a secure context (HTTPS) and may prompt for permission. - execCommand fallback: For older browsers, the hook creates an off-screen
textarea, selects its content, and executes thecopycommand. This is deprecated but widely supported. - Auto-reset timer: After a successful copy,
isCopiedflips totrueand automatically resets afterresetDelayms. This drives the "Copied!" feedback without manual cleanup. - Timer cleanup: Previous timers are cleared before setting new ones, preventing stale state from overlapping copies.
Parameters & Return Values
| Parameter | Type | Default | Description |
|---|---|---|---|
resetDelay | number | 2000 | Milliseconds before isCopied resets to false |
| Return | Type | Description |
|---|---|---|
copiedText | string or null | The last successfully copied text |
isCopied | boolean | Whether a recent copy succeeded (auto-resets) |
copy | (text: string) => Promise<boolean> | Trigger copy, returns success |
reset | () => void | Manually reset state |
Variations
Copy rich text (HTML): Use navigator.clipboard.write with a ClipboardItem for formatted content:
const blob = new Blob([htmlString], { type: "text/html" });
const item = new ClipboardItem({ "text/html": blob });
await navigator.clipboard.write([item]);Copy from element: Accept a ref instead of a string and read innerText:
const copyFromRef = async (ref: React.RefObject<HTMLElement>) => {
const text = ref.current?.innerText ?? "";
return copy(text);
};TypeScript Notes
- The return type is a named interface for clear documentation.
copyreturnsPromise<boolean>so callers canawaitand react to failure.- No generics needed since the input and output are always strings.
Gotchas
- Secure context required — The Clipboard API only works over HTTPS or on localhost. Fix: The fallback handles HTTP contexts, but test both paths.
- User activation required — Browsers require the copy to happen in response to a user gesture (click, keypress). Fix: Always call
copyfrom an event handler, not from a timer or effect. - Permissions popup — Some browsers show a permission prompt for clipboard access. Fix: This is browser-controlled; the hook handles the rejection gracefully by returning
false. - execCommand deprecation —
document.execCommand("copy")is deprecated and may be removed. Fix: The Clipboard API is the primary path; the fallback is a safety net for legacy browsers. - Large text — Copying very large strings (multiple MB) may hang the browser. Fix: Consider truncating or warning the user for unusually large content.
Alternatives
| Package | Hook Name | Notes |
|---|---|---|
usehooks-ts | useCopyToClipboard | Similar API, no fallback |
@uidotdev/usehooks | useCopyToClipboard | Minimal, Clipboard API only |
react-use | useCopyToClipboard | Includes error state |
copy-to-clipboard (npm) | copy() | Not a hook; utility function with fallback |
FAQs
Why does the hook try the Clipboard API first and fall back to execCommand?
navigator.clipboard.writeTextis the modern, promise-based API and is the preferred approach.document.execCommand("copy")is deprecated but has wider support in older browsers.- The hook tries the modern API first and only uses the fallback if it fails or is unavailable.
What does the isCopied flag do and when does it reset?
isCopiedflips totrueimmediately after a successful copy.- It automatically resets to
falseafterresetDelaymilliseconds (default: 2000). - This drives the "Copied!" visual feedback without manual cleanup.
Can I await the copy function to check if it succeeded?
Yes. copy returns Promise<boolean>:
const success = await copy("some text");
if (!success) {
showErrorToast("Copy failed");
}What is the reset function used for?
reset() manually clears copiedText, sets isCopied to false, and cancels any pending auto-reset timer. Use it when you need to reset state before the auto-reset fires (e.g., when closing a modal).
Gotcha: My copy call fails silently on an HTTP page. Why?
The Clipboard API requires a secure context (HTTPS or localhost). On plain HTTP, navigator.clipboard is undefined. The hook falls back to execCommand, but test both paths to be sure.
Gotcha: I call copy() inside a useEffect and the browser blocks it. What is wrong?
Browsers require clipboard access to happen in response to a user gesture (click, keypress). Calling copy() from a timer, effect, or async callback without a preceding user action will be blocked. Always trigger it from an event handler.
How does the fallback textarea approach work?
- An off-screen
<textarea>is created and appended to the DOM. - Its value is set to the target text, then
select()andexecCommand("copy")are called. - The textarea is immediately removed from the DOM after the operation.
How would I copy rich text (HTML) instead of plain text?
Use navigator.clipboard.write with a ClipboardItem:
const blob = new Blob([htmlString], { type: "text/html" });
const item = new ClipboardItem({ "text/html": blob });
await navigator.clipboard.write([item]);Why does the return type use a named interface instead of an inline type?
The named UseCopyToClipboardReturn interface provides clear documentation, IDE autocompletion, and can be reused if consumers need to type props that accept the hook's return value.
What TypeScript type does the copy function have?
copy is typed as (text: string) => Promise<boolean>. The input is always a string and the return indicates success or failure. No generics are needed.
Related
- useToggle — simple boolean for showing feedback
- useEventListener — listen for paste events