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>
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);
}
});
}
Two paths only:
-
navigator.clipboardexists → return it directly, it's already a Promise - Doesn't exist → run
ClipboardJS.copy(), wrap the synchronous result innew 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'));
});
Alpine.js
<button @click="copyToClipboard($el.dataset.text).then(() => copied = true)">
Copy
</button>
Vue
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:
document.addEventListener('clipboard', (e: Event) => {
const { text } = (e as CustomEvent<{ text: string }>).detail;
copyToClipboard(text)
.then(() => notify('Copied!'))
.catch(() => notify('Copy failed'));
});
Livewire dispatch:
$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
npm install clipboard
TypeScript types are bundled — no separate @types/clipboard needed.
Top comments (0)