DEV3 Technologies home

Copy button with toast hook

A React hook and button that copy text with a clipboard fallback and show a self-dismissing confirmation toast.

React / Next.jsFeedback and empty statesreactclipboardtoasthook

Live preview

Code

'use client';
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';

// Fallback for browsers/frames where the async clipboard API is blocked.
function legacyCopy(value: string): boolean {
  const ta = document.createElement('textarea');
  ta.value = value;
  ta.setAttribute('readonly', '');
  ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none';
  document.body.appendChild(ta);
  ta.select();
  try {
    return document.execCommand('copy');
  } finally {
    ta.remove();
  }
}

export function useCopyToast(duration = 1800) {
  const [toast, setToast] = useState('');
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  // Clear any pending timer when the component unmounts.
  useEffect(() => () => clearTimeout(timer.current), []);

  const copy = useCallback(
    async (value: string, label = 'Copied to clipboard') => {
      let ok = true;
      try {
        await navigator.clipboard.writeText(value);
      } catch {
        ok = legacyCopy(value);
      }
      clearTimeout(timer.current); // rapid clicks restart the timer
      setToast(ok ? label : 'Copy failed');
      timer.current = setTimeout(() => setToast(''), duration);
      return ok;
    },
    [duration],
  );

  return { copy, toast };
}

export function CopyButton({ value, children = 'Copy' }: { value: string; children?: ReactNode }) {
  const { copy, toast } = useCopyToast();
  return (
    <span className="relative inline-flex items-center gap-3">
      <button
        type="button"
        onClick={() => copy(value)}
        className="rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700"
      >
        {children}
      </button>
      {/* Always mounted so assistive tech announces the first message. */}
      <span role="status" aria-live="polite" className="text-sm text-emerald-600">
        {toast}
      </span>
    </span>
  );
}

How it works

What it solves

Every developer tool needs a copy button, and most implementations either fail silently on insecure origins or leave the user guessing whether anything happened. This pattern bundles the clipboard write, a fallback for older browsers and a short-lived confirmation message into one hook you can reuse across a page.

How it works

useCopyToast wraps navigator.clipboard.writeText in a try/catch. If the async API is unavailable or rejected (for example in an embedded frame without permission), it falls back to a hidden, read-only textarea and execCommand('copy'). The result decides the toast text. A single timer ref is cleared before each new toast, so rapid clicks extend the message instead of stacking timers, and an effect cleanup clears it on unmount to avoid state updates on a dead component.

CopyButton consumes the hook and renders a polite live region (role="status") so screen readers announce the confirmation without stealing focus.

Adapting it

  • Pass a custom label as the second argument to copy for context, such as "JSON copied".
  • Lift the hook to a page and share its toast value if several buttons should reuse one toast element.
  • Swap the Tailwind classes for your design tokens; only the status element needs to stay mounted.

Pitfalls

  • The clipboard API requires a secure context and, in most browsers, a user gesture; call copy directly from a click handler.
  • Keep the toast element in the DOM even when empty, otherwise some screen readers miss the first announcement.
  • The fallback is deprecated but still the only option on very old mobile browsers.

How to use it

  1. Save the file as components/CopyButton.tsx in a Next.js 15 app (it is a client component).
  2. No packages are required; Tailwind classes are optional.
  3. Render <CopyButton value={text} /> anywhere, or call useCopyToast() yourself and render toast where you like.
  4. Pass a second argument to copy(value, 'Link copied') to customise the message.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets