EmpireUI
Get Pro
← Blog8 min read#copy button#clipboard#react

Copy to Clipboard Button in React: Hook, Feedback and Patterns

Build a copy-to-clipboard button in React with a custom hook, visual feedback states, and UX patterns that actually work - including async fallbacks and accessibility.

Developer writing React code on a laptop with a dark theme editor

Why Copy Buttons Are Harder Than They Look

You've seen this pattern a thousand times - code snippet, little clipboard icon, click it, the icon flips to a checkmark, two seconds later it resets. Dead simple. Except the first time you build one from scratch you'll discover navigator.clipboard is async, requires secure context (HTTPS or localhost), and throws in iframes depending on browser permissions policy. Not so simple.

The Clipboard API landed in Chrome 66 back in 2018, but browser quirks stuck around for years. In 2026 you mostly don't have to worry about the document.execCommand('copy') fallback anymore - it's deprecated and gone from most contexts - but you *do* need to handle the promise rejection gracefully. If the user denies clipboard permissions, your button should fail silently or show an error state, not blow up the page.

In practice, the biggest mistake devs make is putting the clipboard logic directly inside a component event handler with zero abstraction. Works fine once. Becomes a copy-paste nightmare the moment you need it in five different components. Write the hook once, use it everywhere - that's the whole point of this article.

Worth noting: there's also the accessibility dimension. A button that visually changes state needs aria-label updates too. Screen reader users deserve the same "Copied!" feedback as sighted users. We'll cover that.

Building the useCopyToClipboard Hook

Here's the hook. Nothing fancy - just solid, reusable logic that handles the async flow, the reset timeout, and the error state.

import { useState, useCallback, useRef } from 'react';

type CopyState = 'idle' | 'copied' | 'error';

export function useCopyToClipboard(resetDelay = 2000) {
  const [copyState, setCopyState] = useState<CopyState>('idle');
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const copy = useCallback(async (text: string) => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);

    try {
      await navigator.clipboard.writeText(text);
      setCopyState('copied');
    } catch {
      setCopyState('error');
    }

    timeoutRef.current = setTimeout(() => {
      setCopyState('idle');
    }, resetDelay);
  }, [resetDelay]);

  return { copy, copyState };
}

The useRef for the timeout is important. If you store the timeout ID in state, calling copy twice fast will trigger an extra re-render. The ref approach avoids that entirely. You cancel the previous timeout before setting a new one, so rapid clicks don't stack up and leave you in a permanent 'copied' state 10 seconds later.

The three-state model (idle | copied | error) gives you enough to drive any UI without over-engineering it. You could add a loading state if your use case involves copying from an async source (like fetching a URL before copying), but for static text this is plenty.

Quick aside: resetDelay defaults to 2000ms. That's the sweet spot - long enough for the user to register the feedback, short enough that it doesn't feel frozen. Less than 1500ms feels glitchy. More than 3000ms feels abandoned. 2000 is the answer, and you can always override it per-call-site.

The Button Component With Proper Feedback

The hook is useless without a component that actually surfaces the three states. Here's a minimal but complete implementation using Tailwind:

import { useCopyToClipboard } from './useCopyToClipboard';
import { Check, Copy, AlertCircle } from 'lucide-react';

interface CopyButtonProps {
  text: string;
  label?: string;
}

export function CopyButton({ text, label = 'Copy' }: CopyButtonProps) {
  const { copy, copyState } = useCopyToClipboard();

  const icons = {
    idle: <Copy size={16} />,
    copied: <Check size={16} className="text-green-400" />,
    error: <AlertCircle size={16} className="text-red-400" />,
  };

  const ariaLabels = {
    idle: label,
    copied: 'Copied!',
    error: 'Copy failed',
  };

  return (
    <button
      onClick={() => copy(text)}
      aria-label={ariaLabels[copyState]}
      className={[
        'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
        copyState === 'idle' && 'bg-neutral-800 text-neutral-200 hover:bg-neutral-700',
        copyState === 'copied' && 'bg-green-950 text-green-300',
        copyState === 'error' && 'bg-red-950 text-red-300',
      ].filter(Boolean).join(' ')}
    >
      {icons[copyState]}
      <span>{ariaLabels[copyState]}</span>
    </button>
  );
}

The aria-label swap on each state change is what makes this accessible. When the button transitions to 'Copied!', a screen reader will announce the new label on next focus - which is good enough for most contexts. If you need immediate announcement, wrap the label <span> in a role="status" aria-live="polite" element instead.

Honestly, the icon-only version (no text label) is popular for code blocks, but it fails accessibility without extra work. If you're going icon-only, you *need* a tooltip, the aria-label update, and a visible focus ring of at least 2px. Don't skip the ring - keyboard users exist.

That said, if you're building within a design system that already has a <Tooltip> component, composing the two is straightforward. Pass copyState up and render the tooltip content conditionally.

Code Block Pattern: Clipboard Button Overlay

The most common real-world usage is a copy button floating over a <pre> block. The trick is positioning - you want the button at top-right of the block without it affecting the block's scroll behavior.

function CodeBlock({ code, language }: { code: string; language: string }) {
  return (
    <div className="relative group rounded-lg overflow-hidden">
      <pre className="bg-neutral-900 p-4 overflow-x-auto text-sm text-neutral-200">
        <code>{code}</code>
      </pre>
      <div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
        <CopyButton text={code} label="Copy code" />
      </div>
    </div>
  );
}

The opacity-0 group-hover:opacity-100 pattern hides the button until hover, which keeps the code block clean. But - and this is worth stressing - if you do this, the button *must* remain focusable even when visually hidden. Replace opacity-0 with a class that uses sr-only toggling, or use opacity-0 focus-within:opacity-100 on the wrapper. Otherwise keyboard users can't reach it.

One more thing - if you're rendering user-supplied code (like in a playground or docs site), consider that navigator.clipboard.writeText has a character limit of around 1MB in practice. You won't hit it with code snippets, but if you're copying large JSON blobs, test it.

For doc sites built with MDX, you'll often want this wired into your syntax highlighter (Shiki, Prism, highlight.js). In 2026 most Shiki integrations accept a transformers array where you can inject the copy button as a wrapper element. Check the Shiki docs for transformerCopyButton - it's a one-liner at that point.

Edge Cases and Browser Gotchas

Let's talk about the things that bite you in production. First: navigator.clipboard is undefined in non-secure contexts. If your local dev runs over HTTP (not localhost), you'll get a TypeError before the try-catch even runs. Guard it: if (!navigator?.clipboard) { setCopyState('error'); return; } at the top of your copy function.

Second: Firefox still requires the tab to be focused for clipboard writes. If you're triggering a copy from a blur event or a delayed callback, Firefox will silently reject the permission. Keep clipboard writes inside direct user interaction handlers (clicks, keydowns) and you're fine.

Third - the iframe problem. Embedded iframes on cross-origin pages need allow="clipboard-write" in the iframe's allow attribute before navigator.clipboard.writeText will work. If you're building a widget that gets embedded, document this for your users. They will forget. Every time.

<!-- Required for clipboard in embedded iframes -->
<iframe src="..." allow="clipboard-read; clipboard-write"></iframe>

Look, none of these are dealbreakers. They're just papercuts you'd rather know about before your users file bugs. The hook pattern from earlier handles the async rejection gracefully - as long as you show the error state, your UI degrades cleanly regardless of which browser quirk fires.

Styling Patterns That Work

Most copy buttons live in dark contexts - code blocks, terminal-style components, dark sidebars. That's why the green-on-dark confirmation color works so well: green reads as 'success' universally, and at 16px it's visible without shouting. The exact Tailwind values text-green-400 on bg-green-950 hit a contrast ratio of around 6.5:1 - comfortably above the WCAG AA threshold of 4.5:1.

If you're building for a light theme, the inverted palette (text-green-700 on bg-green-100) gets you similar contrast. Don't just invert the dark values blindly - text-green-400 on a white background is below 3:1 and fails accessibility.

Want to add micro-animation? A quick scale-down on click (12ms) and scale-up on the state change gives tactile feedback without feeling like a theme park. Keep it subtle - 95% scale max. If you're already using Framer Motion in your project, a whileTap={{ scale: 0.95 }} on the button element is all you need. For pure CSS, active:scale-95 transition-transform in Tailwind is just as good.

You can push this further with animated icons - the classic pattern is a <Check> that draws in using stroke-dashoffset animation. SVG path animation takes about 20 lines of CSS and the payoff is a polished feel that sets your component apart. If you want pre-built components like this out of the box, browse components on Empire UI - the library ships interactive copy button variants you can drop in directly.

That said, don't over-animate. The state change should be perceptible in under 100ms and complete by 300ms. Anything slower starts to feel like the UI is fighting you. Use animation to confirm, not to entertain.

Integrating With Toast Notifications

Some teams prefer to show clipboard feedback via a toast instead of (or alongside) the button state change. This works well when the copy action is decoupled from a visible button - like a right-click context menu or a keyboard shortcut.

import toast from 'react-hot-toast';
import { useCopyToClipboard } from './useCopyToClipboard';

function CopyWithToast({ text }: { text: string }) {
  const { copy, copyState } = useCopyToClipboard(1500);

  const handleCopy = async () => {
    await copy(text);
    if (copyState === 'copied') {
      toast.success('Copied to clipboard');
    } else {
      toast.error('Copy failed - check browser permissions');
    }  
  };

  return <button onClick={handleCopy}>Copy</button>;
}

Wait - there's a subtle bug in that snippet. copyState is stale inside handleCopy because the state update from copy() is asynchronous and the closure captures the old value. The cleaner pattern is to return a boolean from copy() directly: const success = await copy(text); if (success) toast.success(...). Update the hook to return true on success and false on error.

For most documentation sites and component libraries, the button-state approach is sufficient and you don't need toasts at all. Toast notifications shine in scenarios where the trigger is ambiguous or hidden from view. If the button is right there next to the content being copied, the visual state change is clearer feedback than a floating notification.

One design consideration worth raising: if you're building with a style like neobrutalism or cyberpunk, your toast and your button colors need to align with the overall palette. A generic green success toast looks jarring inside a high-contrast brutalist UI. Theme your feedback states to match.

FAQ

Does navigator.clipboard work in all browsers in 2026?

Yes, all major browsers support it in secure contexts (HTTPS or localhost). The one catch is Firefox requires the tab to be focused for write operations - keep clipboard calls inside direct user interaction handlers and you're fine.

Should I still support the execCommand fallback?

document.execCommand('copy') is deprecated and removed from most contexts in 2026. Guard against a missing navigator.clipboard with an error state instead - don't resurrect the old API.

How do I make the copy button accessible?

Update aria-label to reflect the current state ('Copy', 'Copied!', 'Copy failed'). For icon-only buttons, also add a visible tooltip and a 2px minimum focus ring - those two things cover keyboard and screen reader users.

What's the best reset delay for the copied state?

2000ms is the standard. It's long enough to register but short enough to feel responsive. Anything under 1500ms can feel like a flicker; over 3000ms feels broken.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Stepper Component in React: Multi-Step Forms and OnboardingReact Error Boundaries: Catching Crashes Without Losing Your MindReact Concurrent Rendering: useTransition, useDeferredValue ExplainedSpatial UI Design in 2026: Vision Pro, Depth and the Glass Era