Copying text into the system’s clipboard with JavaScript

The Clipboard API makes copying text from a React app straightforward. Use the older execCommand() method only when you need a legacy fallback.

Copy text with the Clipboard API

navigator.clipboard.writeText() accepts a string and returns a promise. Call it from a user action, such as a button click:

import { useState } from 'react'
 
const text = 'Here is some text to copy.'
 
/** Renders a button that copies text to the clipboard */
export default function CopyApp() {
  const [copyMessage, setCopyMessage] = useState('')
 
  async function handleCopy() {
    try {
      await navigator.clipboard.writeText(text)
      setCopyMessage('Text copied')
    } catch {
      setCopyMessage('Unable to copy')
    }
  }
 
  return (
    <div className='app'>
      <p>{text}</p>
      <button type='button' onClick={handleCopy}>
        Copy text
      </button>
      <span aria-live='polite'>{copyMessage}</span>
    </div>
  )
}

The API works only in a secure context, usually HTTPS or localhost. Browser permission rules differ, but calling it directly from a click handler is the safest approach. In Chromium, a cross-origin iframe also needs clipboard-write allowed by its embedding page's Permissions Policy.

The Clipboard interface has four main methods:

  • writeText() writes a string
  • write() writes data such as images or HTML
  • readText() reads a string
  • read() reads arbitrary clipboard data

Reading is more restricted than writing. Both can fail, so always handle the rejected promise.

Legacy fallback: execCommand

document.execCommand('copy') is deprecated and may disappear. It copies the current selection, so the usual flow is:

  1. Focus an input or textarea
  2. Select its text
  3. Run the copy command
  4. Clear the selection

Here is the same UI using the old method:

import { useRef, useState } from 'react'
 
/** Renders a legacy clipboard-copy example */
export default function CopyApp() {
  const textareaRef = useRef<HTMLTextAreaElement>(null)
  const [copyMessage, setCopyMessage] = useState('')
 
  function handleCopy() {
    const textarea = textareaRef.current
    if (textarea === null) return
 
    textarea.focus()
    textarea.select()
 
    let wasCopied = false
 
    try {
      wasCopied = document.execCommand('copy')
    } catch {
      // Some browsers throw when the command is unavailable
    }
 
    setCopyMessage(wasCopied ? 'Text copied' : 'Unable to copy')
    textarea.setSelectionRange(0, 0)
    textarea.blur()
  }
 
  return (
    <div className='app'>
      <textarea defaultValue='Here is some text to copy.' ref={textareaRef} />
      <button type='button' onClick={handleCopy}>
        Copy text
      </button>
      <span aria-live='polite'>{copyMessage}</span>
    </div>
  )
}

The selection does not have to come from an input or textarea: a DOM Range can select text in another element. Inputs are simply easier to select.

Do not use <textarea hidden> for this fallback. It is not rendered, so it cannot be focused and selected like a normal control. To copy an arbitrary string, create an off-screen textarea, append it to the document, select it, copy, then remove it.

Which method should you use?

Use navigator.clipboard.writeText() for new code. Keep execCommand('copy') only when you have tested a real need for the deprecated fallback.