import { useCallback, useState } from 'react';
type CopiedValue = string | null;
type CopyFn = (text: string) => Promise<boolean>;
function legacyCopy(text: string): boolean {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '0';
textarea.style.left = '0';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
try {
textarea.select();
textarea.setSelectionRange(0, text.length);
return document.execCommand('copy');
} catch (error) {
return false;
} finally {
document.body.removeChild(textarea);
}
}
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
const copy: CopyFn = useCallback(async (text) => {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else if (!legacyCopy(text)) {
throw new Error('execCommand copy failed');
}
setCopiedText(text);
return true;
} catch (error) {
console.warn('Copy to clipboard failed', error);
setCopiedText(null);
return false;
}
}, []);
return [copiedText, copy];
}
import { useEffect, useState } from 'react';
import { useCopyToClipboard } from './useCopyToClipboard';
interface CopyButtonProps {
value: string;
idleLabel?: string;
copiedLabel?: string;
}
export function CopyButton({
value,
idleLabel = 'Copy',
copiedLabel = 'Copied!',
}: CopyButtonProps) {
const [, copy] = useCopyToClipboard();
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timer = window.setTimeout(() => setCopied(false), 2000);
return () => window.clearTimeout(timer);
}, [copied]);
const handleClick = async () => {
const ok = await copy(value);
setCopied(ok);
};
return (
<span className="copy-button">
<button
type="button"
onClick={handleClick}
disabled={!value}
aria-label={copied ? copiedLabel : `Copy ${value}`}
>
{copied ? copiedLabel : idleLabel}
</button>
<span role="status" aria-live="polite" className="sr-only">
{copied ? copiedLabel : ''}
</span>
</span>
);
}
.copy-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.copy-button button {
cursor: pointer;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 0.35rem 0.75rem;
background: #f6f8fa;
font: inherit;
transition: background 120ms ease;
}
.copy-button button:hover:not(:disabled) {
background: #eaeef2;
}
.copy-button button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
This snippet shows how to build a reliable copy-to-clipboard feature in React that works across modern and legacy browsers. The core problem is that the modern navigator.clipboard.writeText API is asynchronous, requires a secure context (HTTPS or localhost), and may be blocked or unavailable, so a robust implementation needs a fallback path and clear status feedback for the UI.
In useCopyToClipboard hook, the hook returns a tuple of [copiedText, copy] similar to useState, which keeps the API familiar. The copy function is wrapped in useCallback so it stays referentially stable across renders and is safe to pass to child components or effect dependencies. It first checks for navigator?.clipboard; when present it awaits writeText, and when absent it delegates to legacyCopy, a small helper that recreates the classic document.execCommand('copy') trick using an off-screen <textarea>. That helper positions the element with fixed and opacity: 0 so it never causes a visual flash or scroll jump, selects its contents, and cleans up the node in a finally block.
On success copy stores the copied string in state and returns true; on failure it logs a warning, resets state to null, and returns false. Returning a boolean lets callers react to failures without subscribing to state, which is useful for toasts or analytics.
In CopyButton component, the hook is composed with a short-lived copied flag driven by useState and a useEffect timer. When a copy succeeds the button flips to a confirmed label for two seconds, then a cleanup function clears the setTimeout to avoid updating an unmounted component. This separation matters: the hook owns the what (the last copied value), while the component owns the when (the transient UI state), keeping the hook reusable in contexts that need no visual reset.
Accessibility is handled with aria-live on a visually adjacent status region and a dynamic aria-label, so screen readers announce the result. The button is disabled while there is no value, guarding against copying empty strings. Together these files demonstrate a production pattern: an isolated, testable hook plus a thin presentational component that adds feedback and a11y, with graceful degradation for older or restricted environments.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
Share this code
Here's the card — post it anywhere.