Featured image of post Wrapping clipboard.js as a Promise: One Function for All Browser Compatibility

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

navigator.clipboard fails on non-HTTPS local dev and iOS Safari. Wrap clipboard.js into a unified Promise interface so the fallback is transparent to callers and works with any framework.

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():

1
navigator.clipboard.writeText(text: string): Promise<void>

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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);
    }
  });
}

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

1
2
3
4
5
button.addEventListener('click', () => {
  copyToClipboard(button.dataset.text)
    .then(() => showToast('Copied!'))
    .catch(() => showToast('Copy failed'));
});

Alpine.js

1
2
3
<button @click="copyToClipboard($el.dataset.text).then(() => copied = true)">
  Copy
</button>

Vue

1
2
3
4
5
6
7
8
async function handleCopy(text: string) {
  try {
    await copyToClipboard(text);
    toast.success('Copied!');
  } catch {
    toast.error('Copy failed');
  }
}

Custom Event (for Livewire or cross-component use)

Trigger via DOM event β€” no direct import needed:

1
2
3
4
5
6
document.addEventListener('clipboard', (e: Event) => {
  const { text } = (e as CustomEvent<{ text: string }>).detail;
  copyToClipboard(text)
    .then(() => notify('Copied!'))
    .catch(() => notify('Copy failed'));
});

Livewire dispatch:

1
$this->dispatch('clipboard', text: 'content to copy');

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

1
npm install clipboard

TypeScript types are bundled β€” no separate @types/clipboard needed.

References