DEV Community

Recca Tsai
Recca Tsai

Posted on • Originally published at recca0120.github.io

Wrapping clipboard.js as a Promise: One Function for All Browser Compatibility

Originally published at recca0120.github.io

You click a copy button during local development and see NotAllowedError in the console. Switch to iPhone for testing and navigator.clipboard is flat-out undefined. Both situations share the same root cause: navigator.clipboard requires a Secure Context — HTTPS or localhost — but iOS Safari doesn't fully honor localhost either.

The fix is straightforward, but doing it cleanly requires one thing: navigator.clipboard.writeText() returns a Promise, while clipboard.js's ClipboardJS.copy() is synchronous. To make both paths transparently interchangeable, the fallback needs to be wrapped as a Promise too.

Why a Unified Promise Interface Matters

The signature of navigator.clipboard.writeText():

navigator.clipboard.writeText(text: string): Promise<void>
Enter fullscreen mode Exit fullscreen mode

Callers use await or .then() — clear and consistent. If the fallback is synchronous, callers have to detect which path to take themselves, scattering that logic everywhere.

Wrapping everything into the same Promise<void> means callers always see one function. Which path runs underneath is none of their business.

The Implementation

import ClipboardJS from 'clipboard';

function copyToClipboard(text: string): Promise<void> {
  if (navigator.clipboard) {
    return navigator.clipboard.writeText(text);
  }

  return new Promise((resolve, reject) => {
    try {
      ClipboardJS.copy(text);
      resolve();
    } catch (err) {
      reject(err);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Two paths only:

  1. navigator.clipboard exists → return it directly, it's already a Promise
  2. Doesn't exist → run ClipboardJS.copy(), wrap the synchronous result in new Promise, resolve() on success, reject() on error

Integrating with Any Framework

With this function, how you use it is entirely up to the caller.

Vanilla JS

button.addEventListener('click', () => {
  copyToClipboard(button.dataset.text)
    .then(() => showToast('Copied!'))
    .catch(() => showToast('Copy failed'));
});
Enter fullscreen mode Exit fullscreen mode

Alpine.js

<button @click="copyToClipboard($el.dataset.text).then(() => copied = true)">
  Copy
</button>
Enter fullscreen mode Exit fullscreen mode

Vue

async function handleCopy(text: string) {
  try {
    await copyToClipboard(text);
    toast.success('Copied!');
  } catch {
    toast.error('Copy failed');
  }
}
Enter fullscreen mode Exit fullscreen mode

Custom Event (for Livewire or cross-component use)

Trigger via DOM event — no direct import needed:

document.addEventListener('clipboard', (e: Event) => {
  const { text } = (e as CustomEvent<{ text: string }>).detail;
  copyToClipboard(text)
    .then(() => notify('Copied!'))
    .catch(() => notify('Copy failed'));
});
Enter fullscreen mode Exit fullscreen mode

Livewire dispatch:

$this->dispatch('clipboard', text: 'content to copy');
Enter fullscreen mode Exit fullscreen mode

The Role of clipboard.js

clipboard.js uses document.execCommand('copy') under the hood — an older API that doesn't require a Secure Context and works on both HTTP and iOS. It handles cross-browser edge cases, saving you from manually wiring up a textarea.

execCommand is marked deprecated, but all major browsers still support it and it isn't going away anytime soon. It remains the most reliable fallback available.

Installation

npm install clipboard
Enter fullscreen mode Exit fullscreen mode

TypeScript types are bundled — no separate @types/clipboard needed.

References

Top comments (0)