ui-026

Copy to Clipboard Button

Free

A copy to clipboard button: the clipboard icon morphs into a drawn check, a Copied label floats up and the copied value flashes.

beginner
4 more details
micro-interactionclipboardmorphingsvg-stroke

This element reads better at your own screen size than in the frame below:

Updated

About this component

Copy buttons usually swap a word and hope you noticed. This one answers in three places at once: the clipboard icon shrinks and tips away while a tick scales in and draws itself stroke-first, a small pill rises above the button and fades in, and the value itself flashes so it is obvious which one was taken.

Read the full effect overview

The failure path is designed rather than ignored. When the Clipboard API is missing or refuses, the button shakes, turns to its error colour, selects the value for you and says so in a live region, so the keyboard shortcut is one press away. Every block on the page initialises itself, so a list of API keys and a share link all run the same code. One HTML file, one stylesheet, one script, GSAP core only, and two themes switched by a data-variant attribute.

Copy to Clipboard Button - UI component preview

What's included

11 items
  • Clipboard icon tips and shrinks away while a tick scales in over it
  • The tick draws itself by tweening strokeDashoffset, no DrawSVG plugin needed
  • Copied pill rises and fades in above the button, then leaves upward
  • The value flashes its success tone so it is clear which one was copied
  • A masked value carries the real string in data-cp-value
  • Clipboard failure shakes the button, selects the value and explains itself
  • Every block on the page initialises itself; nothing is queried by id
  • Every state change announced through one shared aria-live status line
  • Reduced motion switches states instantly instead of animating
  • window.copyButton.copy / reset / text, plus window.copyButtons
  • Two themes, light and dark, on one set of custom properties

Perfect for

4 use cases
  • API keys, tokens and webhook secrets in a dashboard
  • Share and invite links in a product UI
  • Install commands and CLI snippets on documentation pages
  • Any copy control that currently just swaps its label to Copied

How it works

4 sections

Two icons, one slot

The clipboard and the tick are two SVGs absolutely stacked in a 17 pixel box. Copying tweens the clipboard's opacity, scale and rotation away and scales the tick in behind it on a back ease, so the swap reads as one object changing rather than two elements toggling. Nothing in the CSS sets a transform on either, so every scale and rotation starts from a real zero.

Drawing the tick

The tick is one SVG path. Its length is read once with getTotalLength, then strokeDasharray and strokeDashoffset are both set to that length so the stroke is fully retracted. GSAP tweens strokeDashoffset to zero and the line draws itself. That is the whole trick, and it needs no plugin.

The failure path is a state, not an error

The copy runs through the Clipboard API's promise, and both a missing API and a rejected write land in the same handler. That handler puts the button into its error colour, shakes it on x, selects the value with a Range so the keyboard shortcut works, and writes a sentence explaining what happened into the live region. A demo toggle forces that path so the state can be seen without breaking the browser.

Colours come from the variant

The value flash and both pill colours are read from the --code-flash, --code-bg, --pill-ok-bg and --pill-err-bg custom properties with getComputedStyle on the block at the moment each tween starts, never hard-coded in the script. The flash hands the background back to the stylesheet with clearProps when it lands, so switching data-variant afterwards is never stuck on the previous theme.

Difficulty Beginner
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="robots" content="noindex, nofollow">
  <title>Copy to Clipboard Button &mdash; GSAP Vault</title>
  <script>document.documentElement.className += ' has-js';</script>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="assets/style.css">
  <style>
    /* Demo-only toolbar; not part of the component. */
    .showcase-toolbar {
      position: fixed; inset: 0 0 auto 0; z-index: 1000; height: 44px;
      display: flex; align-items: center; gap: 10px; padding: 0 12px;
      flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
      background: var(--ground-2); border-bottom: 1px solid var(--line);
      color: var(--ink-3); font-family: var(--font); font-size: 0.75rem;
      white-space: nowrap;
    }
    .showcase-toolbar::-webkit-scrollbar { display: none; }
    .showcase-toolbar__prompt { color: var(--ink-2); margin-right: auto; }
    .showcase-toolbar__group { display: flex; gap: 2px; padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--line); background: var(--raised); }
    .showcase-toolbar button {
      appearance: none; border: 0; cursor: pointer; font: inherit;
      min-height: 26px; padding: 0 9px; border-radius: 4px;
      background: transparent; color: var(--ink-2);
    }
    .showcase-toolbar button:hover { background: var(--hover); color: var(--ink); }
    .showcase-toolbar button[aria-pressed="true"] { background: var(--ink); color: var(--ground); }
    .showcase-toolbar button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    /* One row on a phone: the strip stays 44px, so the prompt goes rather
       than wrapping and breaking the body's reserved padding. */
    @media (max-width: 620px) { .showcase-toolbar__prompt { display: none; } }
  </style>
</head>
<body data-variant="dark">

  <header class="showcase-toolbar">
    <span class="showcase-toolbar__prompt">Copy a key, or the share link, and watch the button answer.</span>
    <div class="showcase-toolbar__group">
      <button type="button" id="cpFail" aria-pressed="false">Simulate no clipboard</button>
    </div>
    <div class="showcase-toolbar__group" role="group" aria-label="Theme">
      <button class="variant-btn" type="button" data-variant="light" aria-pressed="false">Light</button>
      <button class="variant-btn" type="button" data-variant="dark" aria-pressed="true">Dark</button>
    </div>
  </header>

  <main class="stage">
    <div class="stage__inner">
      <h1 class="cp-sr-only">Copy to clipboard button</h1>

      <section class="cp-panel" aria-labelledby="cp-keys-title">
        <div class="cp-panel__head">
          <h2 class="cp-panel__title" id="cp-keys-title">API keys</h2>
          <p class="cp-panel__note">Secret keys are shown once. Copy a value to use it.</p>
        </div>
        <ul class="cp-list">
          <li class="cp-block" id="cpBlock">
            <span class="cp-name">Production<small class="cp-meta">Created 4 Aug</small></span>
            <pre class="cp-pre"><code class="cp-code" id="cpCode" data-cp-value="sk_live_7Qa8xR2mVn41bZk9fD2pT6">sk_live_7Qa8&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;pT6</code></pre>
            <span class="cp-copy">
              <span class="cp-pill" id="cpPill" aria-hidden="true">Copied</span>
              <button type="button" class="cp-btn" id="cpBtn">
                <span class="cp-icon" aria-hidden="true">
                  <svg class="cp-clip" viewBox="0 0 24 24" width="17" height="17" fill="none">
                    <rect x="8.5" y="3.2" width="11.3" height="14.6" rx="2.2"
                          stroke="currentColor" stroke-width="1.8"></rect>
                    <path d="M15.5 20.8H6.4a2.2 2.2 0 0 1-2.2-2.2V7.6"
                          stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>
                  </svg>
                  <svg class="cp-check" viewBox="0 0 24 24" width="17" height="17" fill="none">
                    <path class="cp-check-path" d="M4.5 12.6 L9.6 17.7 L19.5 6.6"
                          stroke="currentColor" stroke-width="2.4"
                          stroke-linecap="round" stroke-linejoin="round"></path>
                  </svg>
                </span>
                <span class="cp-btn-label">Copy</span><span class="cp-sr-only">&nbsp;production key</span>
              </button>
            </span>
          </li>

          <li class="cp-block">
            <span class="cp-name">Staging<small class="cp-meta">Created 4 Aug</small></span>
            <pre class="cp-pre"><code class="cp-code" data-cp-value="sk_test_3Hb6yL9wQd57rMv2kC8sN4">sk_test_3Hb6&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;sN4</code></pre>
            <span class="cp-copy">
              <span class="cp-pill" aria-hidden="true">Copied</span>
              <button type="button" class="cp-btn">
                <span class="cp-icon" aria-hidden="true">
                  <svg class="cp-clip" viewBox="0 0 24 24" width="17" height="17" fill="none">
                    <rect x="8.5" y="3.2" width="11.3" height="14.6" rx="2.2"
                          stroke="currentColor" stroke-width="1.8"></rect>
                    <path d="M15.5 20.8H6.4a2.2 2.2 0 0 1-2.2-2.2V7.6"
                          stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>
                  </svg>
                  <svg class="cp-check" viewBox="0 0 24 24" width="17" height="17" fill="none">
                    <path class="cp-check-path" d="M4.5 12.6 L9.6 17.7 L19.5 6.6"
                          stroke="currentColor" stroke-width="2.4"
                          stroke-linecap="round" stroke-linejoin="round"></path>
                  </svg>
                </span>
                <span class="cp-btn-label">Copy</span><span class="cp-sr-only">&nbsp;staging key</span>
              </button>
            </span>
          </li>

          <li class="cp-block">
            <span class="cp-name">Webhook secret<small class="cp-meta">Created 21 Jul</small></span>
            <pre class="cp-pre"><code class="cp-code" data-cp-value="whsec_5Kd1tP8jZx36gWq7nB4uH9">whsec_5Kd1&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;uH9</code></pre>
            <span class="cp-copy">
              <span class="cp-pill" aria-hidden="true">Copied</span>
              <button type="button" class="cp-btn">
                <span class="cp-icon" aria-hidden="true">
                  <svg class="cp-clip" viewBox="0 0 24 24" width="17" height="17" fill="none">
                    <rect x="8.5" y="3.2" width="11.3" height="14.6" rx="2.2"
                          stroke="currentColor" stroke-width="1.8"></rect>
                    <path d="M15.5 20.8H6.4a2.2 2.2 0 0 1-2.2-2.2V7.6"
                          stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>
                  </svg>
                  <svg class="cp-check" viewBox="0 0 24 24" width="17" height="17" fill="none">
                    <path class="cp-check-path" d="M4.5 12.6 L9.6 17.7 L19.5 6.6"
                          stroke="currentColor" stroke-width="2.4"
                          stroke-linecap="round" stroke-linejoin="round"></path>
                  </svg>
                </span>
                <span class="cp-btn-label">Copy</span><span class="cp-sr-only">&nbsp;webhook secret</span>
              </button>
            </span>
          </li>
        </ul>
      </section>

      <section class="cp-panel" aria-labelledby="cp-link-title">
        <div class="cp-panel__head">
          <h2 class="cp-panel__title" id="cp-link-title">Share link</h2>
          <p class="cp-panel__note">Anyone with this link can view the project.</p>
        </div>
        <div class="cp-block cp-field">
          <pre class="cp-pre"><code class="cp-code">https://app.example.com/p/atlas/invite/r7m2-4kqx</code></pre>
          <span class="cp-copy">
            <span class="cp-pill" aria-hidden="true">Copied</span>
            <button type="button" class="cp-btn cp-btn--primary">
              <span class="cp-icon" aria-hidden="true">
                <svg class="cp-clip" viewBox="0 0 24 24" width="17" height="17" fill="none">
                  <rect x="8.5" y="3.2" width="11.3" height="14.6" rx="2.2"
                        stroke="currentColor" stroke-width="1.8"></rect>
                  <path d="M15.5 20.8H6.4a2.2 2.2 0 0 1-2.2-2.2V7.6"
                        stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>
                </svg>
                <svg class="cp-check" viewBox="0 0 24 24" width="17" height="17" fill="none">
                  <path class="cp-check-path" d="M4.5 12.6 L9.6 17.7 L19.5 6.6"
                        stroke="currentColor" stroke-width="2.4"
                        stroke-linecap="round" stroke-linejoin="round"></path>
                </svg>
              </span>
              <span class="cp-btn-label">Copy</span><span class="cp-sr-only">&nbsp;share link</span>
            </button>
          </span>
        </div>
      </section>

      <p class="cp-status" id="cpStatus" role="status" aria-live="polite"></p>
    </div>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="assets/script.js"></script>
  <script>
    // Demo-only: switches data-variant on <body>; also honours ?variant=name.
    (function () {
      var buttons = Array.prototype.slice.call(document.querySelectorAll('.variant-btn'));
      var names = buttons.map(function (b) { return b.dataset.variant; });
      function setVariant(name) {
        document.body.setAttribute('data-variant', name);
        buttons.forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.variant === name)); });
        requestAnimationFrame(function () { window.dispatchEvent(new Event('resize')); });
      }
      var wanted = new URLSearchParams(location.search).get('variant');
      var initial = names.indexOf(wanted) > -1 ? wanted : document.body.getAttribute('data-variant') || names[0];
      setVariant(initial);
      buttons.forEach(function (b) { b.addEventListener('click', function () { setVariant(b.dataset.variant); }); });
    })();
  </script>
</body>
</html>
/**
 * Copy to Clipboard Button
 *
 * A copy control whose clipboard icon morphs into a stroke-drawn check,
 * floats a "Copied" pill above itself and flashes the value it took. If the
 * clipboard is unavailable it selects the value instead and says so.
 *
 * Every `.cp-block` on the page becomes its own instance, so a list of keys
 * and a share link all run the same code.
 *
 * @plugins none (core GSAP only)
 */

(function () {
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})(function initCopyToClipboardButton() {
    if (typeof gsap === 'undefined') {
        console.warn('[copy-to-clipboard-button] GSAP is required but was not found.');
        return;
    }

    var blocks = Array.prototype.slice.call(document.querySelectorAll('.cp-block'));
    if (!blocks.length) return;

    /* One shared live region for the whole page: two blocks announcing at
       once would talk over each other. */
    var statusEl = document.querySelector('.cp-status');
    /* Demo-only control; the component works without it. */
    var failToggle = document.getElementById('cpFail');

    /* Button copy for each state. Change these three strings and nothing
       else moves. */
    var TEXT = { idle: 'Copy', ok: 'Copied', err: 'Select text' };
    /* How long the copied or fallback state is held before it resets. */
    var HOLD_SECONDS = 1.9;

    var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    /* One duration multiplier: under reduced motion every tween is instant
       and the states still occur in order, so nothing is skipped. */
    var d = reduced ? 0 : 1;

    /* Demo-only: forces the fallback path so the state can be seen. */
    var forceFail = false;

    /* Declared before first use: build:demos un-hoists function declarations. */
    function setStatus(text) {
        if (statusEl) statusEl.textContent = text;
    }

    function createBlock(block) {
        var btn = block.querySelector('.cp-btn');
        var code = block.querySelector('.cp-code');
        var pre = block.querySelector('.cp-pre');
        var pill = block.querySelector('.cp-pill');
        if (!btn || !code) return null;

        var label = btn.querySelector('.cp-btn-label');
        var clip = btn.querySelector('.cp-clip');
        var check = btn.querySelector('.cp-check');
        var checkPath = btn.querySelector('.cp-check-path');
        var checkLength = checkPath ? checkPath.getTotalLength() : 40;
        var holdTl = null;
        var api;

        /* Colours are read off the block at the moment a tween starts, so a
           theme swap is correct on the next copy and nothing is hard-coded. */
        function token(name) {
            return getComputedStyle(block).getPropertyValue(name).trim();
        }

        /* The visible value may be masked; data-cp-value carries the real
           string to put on the clipboard. */
        function snippet() {
            var override = code.getAttribute('data-cp-value');
            return (override !== null ? override : code.textContent).trim();
        }

        function armCheck() {
            if (checkPath) gsap.set(checkPath, { strokeDasharray: checkLength, strokeDashoffset: checkLength });
        }

        function showPill(text, bgToken, fgToken) {
            if (!pill) return;
            pill.textContent = text;
            pill.style.backgroundColor = token(bgToken);
            pill.style.color = token(fgToken);
            gsap.killTweensOf(pill);
            gsap.fromTo(pill,
                { opacity: 0, y: 10 },
                { opacity: 1, y: 0, duration: 0.3 * d, ease: 'power3.out' });
        }

        function hidePill() {
            if (!pill) return;
            gsap.killTweensOf(pill);
            gsap.to(pill, { opacity: 0, y: -6, duration: 0.25 * d, ease: 'power2.in' });
        }

        function reset() {
            btn.classList.remove('is-copied', 'is-error');
            if (label) label.textContent = TEXT.idle;
            gsap.killTweensOf([clip, check]);
            gsap.set(clip, { opacity: 1, scale: 1, rotation: 0 });
            gsap.set(check, { opacity: 0, scale: 0.6 });
            armCheck();
            hidePill();
            if (pre) { gsap.killTweensOf(pre); gsap.set(pre, { clearProps: 'backgroundColor' }); }
            setStatus('');
        }

        function hold() {
            if (holdTl) holdTl.kill();
            holdTl = gsap.timeline();
            holdTl.to({}, { duration: reduced ? 0.6 : HOLD_SECONDS }).add(reset);
        }

        function flashValue() {
            if (!pre || reduced) return;
            gsap.killTweensOf(pre);
            gsap.fromTo(pre,
                { backgroundColor: token('--code-flash') },
                {
                    backgroundColor: token('--code-bg'),
                    duration: 0.7,
                    ease: 'power2.out',
                    /* Hand the background back to the stylesheet, or a later
                       theme swap would be stuck on this theme's colour. */
                    onComplete: function () { gsap.set(pre, { clearProps: 'backgroundColor' }); }
                });
        }

        function succeed() {
            btn.classList.remove('is-error');
            btn.classList.add('is-copied');
            if (label) label.textContent = TEXT.ok;
            armCheck();
            gsap.killTweensOf([clip, check]);
            /* The clipboard shrinks and tips away, the tick scales in and then
               draws itself by tweening strokeDashoffset. No DrawSVG needed. */
            gsap.to(clip, { opacity: 0, scale: 0.5, rotation: -25, duration: 0.2 * d, ease: 'power2.in' });
            gsap.fromTo(check,
                { opacity: 0, scale: 0.5 },
                { opacity: 1, scale: 1, duration: 0.26 * d, ease: 'back.out(2.4)', delay: 0.1 * d });
            if (checkPath) {
                gsap.to(checkPath, { strokeDashoffset: 0, duration: 0.4 * d, ease: 'power2.out', delay: 0.14 * d });
            }
            showPill('Copied', '--pill-ok-bg', '--pill-ok-fg');
            flashValue();
            setStatus('Copied to your clipboard.');
            hold();
        }

        function selectValue() {
            var selection = window.getSelection();
            if (!selection || typeof document.createRange !== 'function') return;
            var range = document.createRange();
            range.selectNodeContents(code);
            selection.removeAllRanges();
            selection.addRange(range);
        }

        function fallback() {
            btn.classList.remove('is-copied');
            btn.classList.add('is-error');
            if (label) label.textContent = TEXT.err;
            gsap.killTweensOf([clip, check]);
            gsap.set(check, { opacity: 0, scale: 0.6 });
            gsap.set(clip, { opacity: 1, scale: 1, rotation: 0 });
            armCheck();
            selectValue();
            /* Shake on x only. Nothing in the CSS sets a transform on the
               button, so this starts and lands at a real zero. */
            if (!reduced) {
                gsap.fromTo(btn, { x: 0 }, { keyframes: { x: [0, -6, 5, -3, 0] }, duration: 0.36, ease: 'power2.out' });
            }
            showPill('Select and copy', '--pill-err-bg', '--pill-err-fg');
            setStatus('Clipboard unavailable. The value has been selected for you, press the copy shortcut.');
            hold();
        }

        function copy() {
            if (holdTl) holdTl.kill();
            var text = snippet();
            var clipboard = navigator.clipboard;
            if (forceFail || !clipboard || typeof clipboard.writeText !== 'function') {
                fallback();
                return;
            }
            clipboard.writeText(text).then(succeed, fallback);
        }

        /* Show the state the component exists for, for a thumbnail or a
           screenshot: tick drawn, pill up, value lit. */
        function preview() {
            if (holdTl) holdTl.kill();
            btn.classList.add('is-copied');
            if (label) label.textContent = TEXT.ok;
            gsap.set(clip, { opacity: 0, scale: 0.5, rotation: -25 });
            gsap.set(check, { opacity: 1, scale: 1 });
            if (checkPath) gsap.set(checkPath, { strokeDasharray: checkLength, strokeDashoffset: 0 });
            if (pill) {
                pill.textContent = 'Copied';
                pill.style.backgroundColor = token('--pill-ok-bg');
                pill.style.color = token('--pill-ok-fg');
                gsap.set(pill, { opacity: 1, y: 0 });
            }
            if (pre) gsap.set(pre, { backgroundColor: token('--code-flash') });
            setStatus('Copied to your clipboard.');
        }

        btn.addEventListener('click', copy);
        gsap.set(check, { opacity: 0, scale: 0.6 });
        armCheck();

        api = { el: block, copy: copy, reset: reset, text: snippet, preview: preview };
        return api;
    }

    var instances = [];
    for (var i = 0; i < blocks.length; i++) {
        var made = createBlock(blocks[i]);
        if (made) instances.push(made);
    }
    if (!instances.length) return;

    if (failToggle) {
        failToggle.addEventListener('click', function () {
            forceFail = !forceFail;
            failToggle.setAttribute('aria-pressed', String(forceFail));
        });
    }

    /* A resting Copy button says nothing on a thumbnail, so show the state
       the component exists for on the first block. */
    window.__thumbnail = function () { instances[0].preview(); };

    /* Small public API. The first block for the common single-block case,
       and the whole list for a page of them. */
    window.copyButton = instances[0];
    window.copyButtons = instances;
});
})();
!function(){var e;e=function(){if("undefined"!=typeof gsap){var e=Array.prototype.slice.call(document.querySelectorAll(".cp-block"));if(e.length){for(var t=document.querySelector(".cp-status"),o=document.getElementById("cpFail"),a="Copy",r="Copied",n="Select text",s=window.matchMedia("(prefers-reduced-motion: reduce)").matches,c=s?0:1,i=!1,l=[],p=0;p<e.length;p++){var u=g(e[p]);u&&l.push(u)}l.length&&(o&&o.addEventListener("click",function(){i=!i,o.setAttribute("aria-pressed",String(i))}),window.__thumbnail=function(){l[0].preview()},window.copyButton=l[0],window.copyButtons=l)}}else console.warn("[copy-to-clipboard-button] GSAP is required but was not found.");function d(e){t&&(t.textContent=e)}function g(e){var t=e.querySelector(".cp-btn"),o=e.querySelector(".cp-code"),l=e.querySelector(".cp-pre"),p=e.querySelector(".cp-pill");if(!t||!o)return null;var u=t.querySelector(".cp-btn-label"),g=t.querySelector(".cp-clip"),y=t.querySelector(".cp-check"),f=t.querySelector(".cp-check-path"),k=f?f.getTotalLength():40,w=null;function b(t){return getComputedStyle(e).getPropertyValue(t).trim()}function m(){var e=o.getAttribute("data-cp-value");return(null!==e?e:o.textContent).trim()}function C(){f&&gsap.set(f,{strokeDasharray:k,strokeDashoffset:k})}function h(e,t,o){p&&(p.textContent=e,p.style.backgroundColor=b(t),p.style.color=b(o),gsap.killTweensOf(p),gsap.fromTo(p,{opacity:0,y:10},{opacity:1,y:0,duration:.3*c,ease:"power3.out"}))}function v(){t.classList.remove("is-copied","is-error"),u&&(u.textContent=a),gsap.killTweensOf([g,y]),gsap.set(g,{opacity:1,scale:1,rotation:0}),gsap.set(y,{opacity:0,scale:.6}),C(),p&&(gsap.killTweensOf(p),gsap.to(p,{opacity:0,y:-6,duration:.25*c,ease:"power2.in"})),l&&(gsap.killTweensOf(l),gsap.set(l,{clearProps:"backgroundColor"})),d("")}function S(){w&&w.kill(),(w=gsap.timeline()).to({},{duration:s?.6:1.9}).add(v)}function T(){t.classList.remove("is-error"),t.classList.add("is-copied"),u&&(u.textContent=r),C(),gsap.killTweensOf([g,y]),gsap.to(g,{opacity:0,scale:.5,rotation:-25,duration:.2*c,ease:"power2.in"}),gsap.fromTo(y,{opacity:0,scale:.5},{opacity:1,scale:1,duration:.26*c,ease:"back.out(2.4)",delay:.1*c}),f&&gsap.to(f,{strokeDashoffset:0,duration:.4*c,ease:"power2.out",delay:.14*c}),h("Copied","--pill-ok-bg","--pill-ok-fg"),l&&!s&&(gsap.killTweensOf(l),gsap.fromTo(l,{backgroundColor:b("--code-flash")},{backgroundColor:b("--code-bg"),duration:.7,ease:"power2.out",onComplete:function(){gsap.set(l,{clearProps:"backgroundColor"})}})),d("Copied to your clipboard."),S()}function x(){t.classList.remove("is-copied"),t.classList.add("is-error"),u&&(u.textContent=n),gsap.killTweensOf([g,y]),gsap.set(y,{opacity:0,scale:.6}),gsap.set(g,{opacity:1,scale:1,rotation:0}),C(),function(){var e=window.getSelection();if(e&&"function"==typeof document.createRange){var t=document.createRange();t.selectNodeContents(o),e.removeAllRanges(),e.addRange(t)}}(),s||gsap.fromTo(t,{x:0},{keyframes:{x:[0,-6,5,-3,0]},duration:.36,ease:"power2.out"}),h("Select and copy","--pill-err-bg","--pill-err-fg"),d("Clipboard unavailable. The value has been selected for you, press the copy shortcut."),S()}function q(){w&&w.kill();var e=m(),t=navigator.clipboard;!i&&t&&"function"==typeof t.writeText?t.writeText(e).then(T,x):x()}return t.addEventListener("click",q),gsap.set(y,{opacity:0,scale:.6}),C(),{el:e,copy:q,reset:v,text:m,preview:function(){w&&w.kill(),t.classList.add("is-copied"),u&&(u.textContent=r),gsap.set(g,{opacity:0,scale:.5,rotation:-25}),gsap.set(y,{opacity:1,scale:1}),f&&gsap.set(f,{strokeDasharray:k,strokeDashoffset:0}),p&&(p.textContent="Copied",p.style.backgroundColor=b("--pill-ok-bg"),p.style.color=b("--pill-ok-fg"),gsap.set(p,{opacity:1,y:0})),l&&gsap.set(l,{backgroundColor:b("--code-flash")}),d("Copied to your clipboard.")}}}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}();
* { box-sizing: border-box; margin: 0; padding: 0; }

/* ---------------------------------------------------------------------
   Themes. One design at two token values: `light` and `dark`, selected
   with data-variant on <body> in this demo, or on any wrapper around the
   component in your own page.

   Every colour, radius and font the component uses is a custom property
   here, and so is every colour the demo's ground uses, so one attribute
   flips both. The script never hard-codes a colour: the value flash and
   the pill backgrounds are read from --code-flash, --code-bg, --pill-ok-bg
   and --pill-err-bg with getComputedStyle at the moment each tween starts,
   so a theme swap mid-demo stays correct.
   --------------------------------------------------------------------- */
body,
body[data-variant="light"] {
  color-scheme: light;

  /* the ground */
  --ground: #f2f2f1;
  --ground-2: #e9e9e7;
  --raised: #fbfbfa;
  --hover: #e2e2df;
  --ink: #141412;
  --ink-2: #4b4b47;
  --ink-3: #676762;
  --line: rgba(20, 20, 18, 0.12);
  --line-strong: rgba(20, 20, 18, 0.22);
  --shadow: 0 1px 2px rgba(20, 20, 18, 0.06), 0 4px 12px rgba(20, 20, 18, 0.06);
  --font: 'Inter', system-ui, -apple-system, sans-serif;
  --font-code: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --radius: 10px;
  --radius-sm: 6px;
  --radius-xs: 4px;

  --accent: #6d4cff;
  --accent-ink: #ffffff;

  /* the copyable value */
  --code-bg: #f0f0ee;
  --code-fg: #2b2b28;
  --code-flash: #d2e0d9;
  --code-size: 0.8125rem;

  /* the button */
  --btn-bg: #f0f0ee;
  --btn-hover: #e5e5e2;
  --btn-fg: #4b4b47;
  --btn-line: var(--line-strong);
  --btn-ok-fg: #1a7f5a;
  --btn-err-fg: #b4231f;

  /* the pill */
  --pill-ok-bg: #1a7f5a;
  --pill-ok-fg: #ffffff;
  --pill-err-bg: #b4231f;
  --pill-err-fg: #ffffff;

  --sel-bg: var(--accent);
  --sel-fg: var(--accent-ink);
  --focus: var(--accent);
}

body[data-variant="dark"] {
  color-scheme: dark;

  --ground: #141414;
  --ground-2: #0f0f0f;
  --raised: #1c1c1c;
  --hover: #232323;
  --ink: #f1efea;
  --ink-2: #c9c7c1;
  --ink-3: #9a988f;
  --line: rgba(241, 239, 234, 0.12);
  --line-strong: rgba(241, 239, 234, 0.22);
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);

  --accent: #6d4cff;
  --accent-ink: #ffffff;

  --code-bg: #232323;
  --code-fg: #d8d6d0;
  --code-flash: #24382f;

  --btn-bg: #262626;
  --btn-hover: #303030;
  --btn-fg: #c9c7c1;
  --btn-ok-fg: #55c08c;
  --btn-err-fg: #f08b84;

  --pill-ok-bg: #2a5c45;
  --pill-ok-fg: #d8f3e5;
  --pill-err-bg: #5c2a27;
  --pill-err-fg: #ffd9d5;
}

html { overflow-x: clip; }

body {
  min-height: 100svh;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: 0.875rem;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  padding-top: 44px;                 /* the demo toolbar strip */
}

::selection { background: var(--sel-bg); color: var(--sel-fg); }

/* ---------- Demo furniture: the ground the component sits on ---------- */
.stage { min-height: calc(100svh - 44px); display: grid; place-items: center; padding: 32px 20px; }
.stage__inner { width: 100%; max-width: 640px; display: flex; flex-direction: column; gap: 16px; }

.cp-sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ---------- The component ----------
   .cp-block is the unit the script initialises: one name, one copyable
   value, one button. Everything around it (the panel, the heading, the
   list) is layout you own. */
.cp-panel {
  background: var(--raised);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

.cp-panel__head { padding: 14px 16px 12px; border-bottom: 1px solid var(--line); }
.cp-panel__title { font-size: 0.875rem; font-weight: 600; letter-spacing: -0.006em; color: var(--ink); }
.cp-panel__note { margin-top: 3px; font-size: 0.75rem; color: var(--ink-3); }

.cp-list { list-style: none; }
.cp-list > li + li { border-top: 1px solid var(--line); }

.cp-block {
  display: grid;
  grid-template-columns: 116px minmax(0, 1fr) auto;
  grid-template-areas: "name value copy";
  align-items: center;
  gap: 12px;
  min-height: 56px;
  padding: 10px 16px;
}

.cp-field { padding: 14px 16px; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "value copy"; }

.cp-name { grid-area: name; font-size: 0.8125rem; font-weight: 500; color: var(--ink); }
.cp-meta { display: block; font-size: 0.6875rem; font-weight: 400; color: var(--ink-3); }

/* The value. A <pre><code> because it is code: a key, a token, a URL. */
.cp-pre {
  grid-area: value;
  min-width: 0;
  background: var(--code-bg);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 6px 10px;
  overflow: hidden;
}

.cp-code {
  display: block;
  font-family: var(--font-code);
  font-size: var(--code-size);
  line-height: 1.4;
  color: var(--code-fg);
  white-space: pre;
  overflow-x: auto;
  scrollbar-width: none;
  -webkit-font-smoothing: auto;
}
.cp-code::-webkit-scrollbar { display: none; }
.cp-code::selection { background: var(--sel-bg); color: var(--sel-fg); }

/* The button, and the pill pinned above it. .cp-copy must stay positioned:
   the pill is absolute inside it. */
.cp-copy { grid-area: copy; position: relative; display: inline-flex; }

.cp-btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  min-height: 34px; min-width: 104px; padding: 0 12px;
  border: 1px solid var(--btn-line);
  border-radius: var(--radius-sm);
  background: var(--btn-bg);
  color: var(--btn-fg);
  font: inherit; font-size: 0.8125rem; font-weight: 500;
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
@media (hover: hover) {
  .cp-btn:hover { background: var(--btn-hover); color: var(--ink); }
}
.cp-btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.cp-btn.is-copied { color: var(--btn-ok-fg); border-color: var(--btn-ok-fg); }
.cp-btn.is-error { color: var(--btn-err-fg); border-color: var(--btn-err-fg); }

/* The one primary action on the page wears the accent. */
.cp-btn--primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
@media (hover: hover) {
  .cp-btn--primary:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); filter: brightness(1.08); }
}
.cp-btn--primary.is-copied { background: var(--pill-ok-bg); border-color: var(--pill-ok-bg); color: var(--pill-ok-fg); }
.cp-btn--primary.is-error { background: var(--pill-err-bg); border-color: var(--pill-err-bg); color: var(--pill-err-fg); }

/* Both icons are stacked in one 17px box: that is what makes the swap read
   as one object changing rather than two elements toggling. */
.cp-icon { position: relative; width: 17px; height: 17px; flex: 0 0 auto; }
.cp-clip,
.cp-check { position: absolute; left: 0; top: 0; color: currentColor; }
.cp-check { opacity: 0; }
.cp-btn-label { display: inline-block; }

.cp-pill {
  position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 3;
  padding: 3px 8px; border-radius: var(--radius-xs);
  font-size: 0.6875rem; font-weight: 500; line-height: 1.4;
  white-space: nowrap; pointer-events: none; opacity: 0;
  background: var(--pill-ok-bg); color: var(--pill-ok-fg);
  box-shadow: var(--shadow);
}

.cp-status { font-size: 0.75rem; color: var(--ink-3); min-height: 1.2em; padding: 0 2px; }

/* ---------- Phone ---------- */
@media (max-width: 560px) {
  .cp-block {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas:
      "name copy"
      "value value";
    row-gap: 8px;
    align-items: center;
  }
  .cp-field { grid-template-areas: "value value" "copy copy"; }
  .cp-field .cp-copy { justify-self: stretch; }
  .cp-field .cp-btn { width: 100%; }
}

@media (prefers-reduced-motion: reduce) {
  .cp-btn { transition: none; }
}

A copy control whose clipboard icon morphs into a stroke-drawn check, floats a "Copied" pill above itself and flashes the value it took. Every .cp-block on the page becomes its own instance, so a list of API keys and a share link all run the same code.

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, every colour and size a plain CSS custom property

  • assets/script.js: readable, commented source with an onReady guard

  • Clipboard icon tips and shrinks away while a tick scales in over it

  • The tick draws itself by tweening strokeDashoffset, no DrawSVG plugin needed

  • "Copied" pill rises and fades in above the button, then leaves upward

  • The value flashes its success tone so it is clear which one was taken

  • A masked value can carry the real string in data-cp-value

  • Clipboard failure shakes the button, selects the value and explains itself

  • Every state change announced through one shared aria-live status line

  • window.copyButton.copy / reset / text, and window.copyButtons for the list

Quick Start

1. Add to your HTML <head>:

<script>document.documentElement.className += ' has-js';</script>
<link rel="stylesheet" href="assets/style.css">

The inline line is a convention shared across these components; nothing this one hides is content, so a no-JavaScript visitor still gets the full value and a real button.

2. Add to your <body>:

Copy one <div class="cp-block"> out of index.html. The classes the script needs inside it are cp-btn, cp-btn-label, cp-code, cp-pre and cp-pill, plus cp-clip, cp-check and cp-check-path on the two icons. One <p class="cp-status" role="status"> anywhere on the page is the shared live region for every block.

Put whatever you like inside the <code class="cp-code">: the script copies its textContent. If the value on screen is masked, put the real string in data-cp-value on the same element and that is what lands on the clipboard:

<pre class="cp-pre"><code class="cp-code" data-cp-value="sk_live_7Qa8xR2mVn41bZk9fD2pT6">sk_live_7Qa8••••••••••••pT6</code></pre>

Repeat the block as many times as you need; each one initialises itself. Nothing is queried by id.

3. Add before the closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="assets/script.js"></script>

4. Drive it from your own code:

// same as a click on the first block
window.copyButton.copy();

// what would be copied, trimmed
console.log(window.copyButton.text());

// back to the resting state early
window.copyButton.reset();

// every block on the page, in document order
window.copyButtons.forEach(function (b) { b.reset(); });

Using It With Your Own Design

What the component actually requires:

  • A .cp-icon box with position: relative holding both SVGs absolutely stacked. That stacking is what makes the swap read as a morph rather than a layout jump.
  • A .cp-copy wrapper with position: relative so the pill can be pinned above the button. Move that and the pill moves with it.
  • The check path needs to be a single open path. Its length is measured at runtime, so change the d attribute freely and the draw still works.
  • Nothing in your CSS may set a transform on the button, either icon or the pill. GSAP animates scale, rotation, x and y on those, and a CSS transform is parsed into the same values and fights them.
  • Give the button a min-width. The label swaps between Copy, Copied and Select text, and without one the row shuffles on every copy.

What is demo furniture and not part of what you bought: the toolbar strip across the top of index.html (its markup, CSS and script all live in that file, never in assets/), the .stage / .stage__inner ground, the .cp-panel cards and their headings, the "Simulate no clipboard" toggle, and the key and link copy. The component is the .cp-block and the status line.

Themes

The component ships in two themes, light and dark: one design at two token values. Set the attribute on <body>, or on any wrapper around the blocks:

<body data-variant="dark">

Nothing else changes: same markup, same script. The demo's toolbar toggle and the ?variant=light URL parameter only set that attribute.

Every colour the component and the demo ground use is a custom property in the two body[data-variant="..."] blocks at the top of assets/style.css:

  • Ground: --ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --line-strong, --shadow
  • Value: --code-bg, --code-fg, --code-flash, --code-size, --font-code
  • Button: --btn-bg, --btn-hover, --btn-fg, --btn-line, --btn-ok-fg, --btn-err-fg
  • Pill and selection: --pill-ok-bg, --pill-ok-fg, --pill-err-bg, --pill-err-fg, --sel-bg, --sel-fg
  • Accent and shape: --accent, --accent-ink, --focus, --radius, --radius-sm, --radius-xs, --font

To wear your own brand, re-value that list; there is no selector to override. The script reads --code-flash, --code-bg, --pill-ok-bg, --pill-ok-fg, --pill-err-bg and --pill-err-fg with getComputedStyle on the block at the moment each tween starts, so a theme swap is correct on the next copy.

How It Works

Two icons, one slot. The clipboard and the tick are two SVGs absolutely stacked in a 17px box. Copying tweens the clipboard's opacity, scale and rotation away and scales the tick in behind it on a back ease, so the swap reads as one object changing rather than two elements toggling.

Drawing the tick. The tick is one SVG path. Its length is read once with getTotalLength, then strokeDasharray and strokeDashoffset are both set to that length so the stroke is fully retracted. GSAP tweens strokeDashoffset to 0 and the line draws itself. That is the whole trick, and it needs no plugin.

The failure path is a state, not an error. The copy runs through the Clipboard API's promise, and both a missing API and a rejected write land in the same handler. That handler puts the button into its error colour, shakes it on x, selects the value with a Range so the keyboard shortcut works, and writes a sentence explaining what happened into the live region. The demo's "Simulate no clipboard" toggle forces that path so you can see the state without breaking your browser.

The flash gives the background back. The value flash tweens backgroundColor from --code-flash to --code-bg, then clears the inline property with clearProps so the stylesheet owns the colour again. Without that, switching theme after a copy would leave the block stuck on the previous theme's colour.

Customisation

Property Where Default (dark) Description
--code-bg style.css #232323 Value ground, and the colour the flash returns to
--code-flash style.css #24382f Colour the value flashes from on a successful copy
--code-size style.css 0.8125rem Value type size
--font-code style.css system mono stack The face the value is set in
--btn-ok-fg style.css #55c08c Button colour once the copy has landed
--btn-err-fg style.css #f08b84 Button colour on the fallback path
--pill-ok-bg / --pill-ok-fg style.css #2a5c45 / #d8f3e5 "Copied" pill
--pill-err-bg / --pill-err-fg style.css #5c2a27 / #ffd9d5 Fallback pill
--accent style.css #6d4cff The primary button, the focus ring and the selection
--sel-bg / --sel-fg style.css accent / white Selection colours used on the fallback path
data-cp-value markup none The real string to copy when the visible value is masked
TEXT script.js Copy / Copied / Select text The three button labels
HOLD_SECONDS script.js 1.9 How long a state is held before it resets

Easings and durations are the gsap.to and gsap.fromTo calls in succeed(), fallback() and showPill(). The tick shape is the d attribute of .cp-check-path in index.html; change it and the draw still works, because the path's length is measured at runtime.

Accessibility

  • The control is a real <button>, keyboard operable with Tab and Enter, and nothing about the copy relies on hover.
  • Each button's accessible name says which value it copies: the visible label plus a visually hidden phrase (Copy production key).
  • Every state writes a sentence into one shared role="status" line, including the fallback, which names the reason and tells the visitor the value has been selected for them.
  • The pill is aria-hidden decoration, so the same message is never read twice.
  • On the fallback path the value is selected with a Range, so the browser's own copy shortcut finishes the job without a mouse.
  • Under prefers-reduced-motion: reduce every duration collapses to zero and the shake and the flash are skipped: the states still happen in order, they just do not animate.
  • With JavaScript off every value is fully visible and selectable and the buttons are still buttons; nothing is hidden behind a class that never arrives.
  • Focus is visible on every button via a :focus-visible outline in the accent.

Requirements

  • GSAP 3.12+ (core only, no plugins)
  • A secure context (HTTPS or localhost) for the Clipboard API; anywhere else the component falls back to selecting the value
  • No build step, no framework