Copy to Clipboard
Press the button and it copies the text, its icon flips over to a tick that draws itself in, and the label rolls up to say “Copied”. If the browser won’t copy, it selects the text and tells you which keys to press.
npm install animejs
How to use it
- Paste the snippet just before
</head>. - Add
data-copy-to-clipboardto a button, withdata-targetpointing at the text to copy.
Options
Add any of these attributes to the same element.
data-target | A selector for the element whose text (or value) to copy. Without it, it copies the element just before the button. |
data-text | Copy this text instead. |
data-done | Label after copying. Default “Copied”. |
data-color | Colour of the tick. Default #16a34a. |
data-hold | How long before it flips back. Default 1800 ms. |
data-autoplay | It plays the animation every few seconds while in view, without copying anything. Handy for demos. |
An empty button shows just the icon. It fires a copied event with the text in e.detail.text, and screen readers hear “Copied to clipboard”.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Copy to Clipboard: press the button and it copies the text, its icon
// flips over to a tick that draws itself in, and the label rolls up to say
// "Copied". A moment later it flips back. If the browser won't copy, it
// selects the text instead and tells you which keys to press.
// Usage:
// <code id="cmd">npm install animejs</code>
// <button data-copy-to-clipboard data-target="#cmd">Copy</button>
// Or give the text itself with data-text="…". With neither, it copies the
// element just before the button. An empty button shows just the icon.
// Optional: data-done (label after copying, default "Copied"), data-color
// (tick colour, default #16a34a), data-hold (ms before it flips back,
// default 1800), data-autoplay (it plays the animation, without copying
// anything, every few seconds while in view; handy for demos).
// Listen for the "copied" event on the button: e.detail.text is what was copied.
var DEFAULTS = { done: 'Copied', color: '#16a34a', hold: 1800, every: 3400 };
var ns = 'http://www.w3.org/2000/svg';
var COPY = '<rect x="8.5" y="8.5" width="11" height="11" rx="2.5"/><path d="M15.5 8.5V6.5a2 2 0 0 0-2-2h-7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h2"/>';
var TICK = '<path d="M5 12.5l4.5 4.5L19 7.5"/>';
var css =
'.ctc{display:inline-flex;align-items:center;gap:0.5em;cursor:pointer}' +
'.ctc-icon{display:inline-block;width:1.15em;height:1.15em;flex:none;perspective:3em}' +
'.ctc-flip{position:relative;display:block;width:100%;height:100%}' +
'.ctc-icon svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}' +
'.ctc-icon svg + svg{opacity:0;stroke:var(--ctc-color);stroke-width:2.8}' +
'.ctc-label{display:inline-grid;overflow:hidden;line-height:1.3}' +
'.ctc-label > span{grid-area:1/1;white-space:nowrap}' +
'.ctc-label > span + span{transform:translateY(110%);opacity:0}' +
'.ctc-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var reduced = function () { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; };
function svg(inner) {
var s = document.createElementNS(ns, 'svg');
s.setAttribute('viewBox', '0 0 24 24');
s.setAttribute('aria-hidden', 'true');
s.innerHTML = inner;
return s;
}
function setup(btn) {
var d = btn.dataset;
btn.classList.add('ctc');
if (btn.tagName === 'BUTTON' && !btn.getAttribute('type')) btn.type = 'button';
btn.style.setProperty('--ctc-color', d.color || DEFAULTS.color);
var text = btn.textContent.trim();
btn.textContent = '';
// The outer box gives the flip its depth; the inner one turns.
var outer = document.createElement('span');
outer.className = 'ctc-icon';
var icon = document.createElement('span');
icon.className = 'ctc-flip';
var copyIcon = svg(COPY), tickIcon = svg(TICK);
icon.appendChild(copyIcon);
icon.appendChild(tickIcon);
outer.appendChild(icon);
btn.appendChild(outer);
var idle = null, done = null;
if (text) {
var label = document.createElement('span');
label.className = 'ctc-label';
idle = document.createElement('span');
idle.textContent = text;
done = document.createElement('span');
done.setAttribute('aria-hidden', 'true');
done.textContent = d.done || DEFAULTS.done;
label.appendChild(idle);
label.appendChild(done);
btn.appendChild(label);
} else if (!btn.getAttribute('aria-label')) btn.setAttribute('aria-label', 'Copy');
var status = document.createElement('span');
status.className = 'ctc-sr';
status.setAttribute('role', 'status');
btn.appendChild(status);
btn._ctc = {
busy: false, icon: icon, copyIcon: copyIcon, tickIcon: tickIcon, idle: idle, done: done, status: status,
tick: anime.svg.createDrawable(tickIcon.querySelector('path'))
};
}
function source(btn) {
var d = btn.dataset;
if (d.text !== undefined) return { text: d.text };
var el = d.target ? document.querySelector(d.target) : btn.previousElementSibling;
if (!el) return { text: '' };
return { el: el, text: 'value' in el && el.tagName !== 'BUTTON' ? el.value : el.textContent.trim() };
}
// The modern clipboard API, then the old execCommand way, then give up.
function write(text) {
var legacy = function () {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.appendChild(ta);
ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
ta.remove();
return ok ? Promise.resolve() : Promise.reject(new Error('copy failed'));
};
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text).catch(legacy);
return legacy();
}
function selectText(el) {
if (!el) return;
if (el.select) { el.focus(); el.select(); return; }
var range = document.createRange();
range.selectNodeContents(el);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
function copy(btn) {
var s = btn._ctc;
if (s.busy) return;
var src = source(btn);
write(src.text).then(function () {
btn.dispatchEvent(new CustomEvent('copied', { bubbles: true, detail: { text: src.text } }));
play(btn, true);
}, function () {
// Leave the text selected so the visitor can copy it themselves.
selectText(src.el);
play(btn, false);
});
}
// Flips the icon, rolls the label, and flips back after a moment.
function play(btn, ok) {
var s = btn._ctc;
if (s.busy) return;
s.busy = true;
var d = btn.dataset;
var keys = /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘C' : 'Ctrl+C';
if (s.done) s.done.textContent = ok ? d.done || DEFAULTS.done : 'Press ' + keys;
s.status.textContent = ok ? 'Copied to clipboard' : 'Couldn’t copy. Press ' + keys + ' to copy the selected text.';
var r = reduced();
var swap = function (on) {
anime.utils.set(s.copyIcon, { opacity: on ? 0 : 1 });
if (ok) anime.utils.set(s.tickIcon, { opacity: on ? 1 : 0 });
};
var roll = function (on) {
if (!s.done) return;
var dur = r ? 0 : 380;
anime.animate(s.idle, { y: on ? '-110%' : '0%', opacity: on ? 0 : 1, duration: dur, ease: 'out(3)' });
anime.animate(s.done, { y: on ? '0%' : '110%', opacity: on ? 1 : 0, duration: dur, ease: 'out(3)' });
};
if (r) {
swap(true);
anime.utils.set(s.tick, { draw: '0 1' });
} else if (ok) {
// Turn the icon edge-on, swap it for the tick, turn it back.
anime.animate(s.icon, {
rotateY: [{ to: 90, duration: 150, ease: 'in(2)' }, { from: -90, to: 0, duration: 350, ease: 'out(3)' }],
scale: [{ to: 0.8, duration: 150 }, { to: 1, duration: 500, ease: 'outElastic(1, .5)' }]
});
setTimeout(function () {
swap(true);
anime.animate(s.tick, { draw: ['0 0', '0 1'], duration: 350, ease: 'out(3)' });
}, 150);
} else {
anime.animate(s.icon, { x: [0, -4, 4, -3, 3, 0], duration: 400, ease: 'linear' });
}
roll(true);
setTimeout(function () {
if (r) swap(false);
else if (ok) {
anime.animate(s.icon, { rotateY: [{ to: -90, duration: 150, ease: 'in(2)' }, { from: 90, to: 0, duration: 350, ease: 'out(3)' }] });
setTimeout(function () { swap(false); }, 150);
}
roll(false);
s.status.textContent = '';
setTimeout(function () { s.busy = false; }, r ? 0 : 500);
}, +d.hold || DEFAULTS.hold);
}
function init() {
document.querySelectorAll('[data-copy-to-clipboard]').forEach(function (btn) {
if (btn._ctc) return;
setup(btn);
btn.addEventListener('click', function () { copy(btn); });
if (btn.dataset.autoplay !== undefined && btn.dataset.autoplay !== 'false' && !reduced()) {
var timer = null;
var tick = function () {
if (!btn.matches(':hover')) play(btn, true);
timer = setTimeout(tick, DEFAULTS.every);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tick, 600);
}).observe(btn);
}
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><code id="cmd">npm install animejs</code>
<button data-copy-to-clipboard data-target="#cmd">Copy</button>
<!-- icon only, copying a link: -->
<button data-copy-to-clipboard data-text="https://example.com" aria-label="Copy link"></button>