卓琪的开发笔记zhuoqidev.comRSSllms.txt

← 全部灵感~/design/copy-button

按钮与控件原创

一键复制按钮

复制成功:两张纸收起、画出对勾、文字往上换成「已复制」;失败:变红抖一下。还会念给读屏软件听。

html · css · svg · jsMIT收录于 #按钮#复制#代码块#微交互#弹簧

在线演示新窗口打开 ↗

看点

  • 一个 SVG 两个状态:图标里同时画了「两张纸」和对勾。对勾是一条路径,stroke-dasharray: 16; stroke-dashoffset: 16 先藏起来,成功时 offset 变 0,看起来是一笔画出来的;两张纸同时 scale(.4) 淡出。
  • 文字换行而不是换字:标签是两行字放在一行高的窗口里(height: 1em; overflow: hidden),成功时整体 translate: 0 -100% 往上滑,用弹簧曲线,有一点回弹。
  • 失败也有样子:剪贴板被拒绝(不是 https、权限没给)时变红并左右抖两下,而不是什么都不发生。
  • 复制的是 textContent:代码块就算被折叠、隐藏也能复制到全文;innerText 遇到没有布局的元素会拿到空串。
  • 读屏:结果写进一个 aria-live="polite" 的区域,看不见屏幕的人也知道复制成没成。

怎么用

  1. 拷 .copy 的 CSS 和按钮的 HTML(SVG 那段原样带上)。
  2. 按钮放在哪都行:代码块标题栏里会去找同一块里的 <pre>;也可以用 data-copy-text 直接写要复制的内容(比如 API key、命令)。
  3. 页面上放一个 aria-live 的空元素,脚本把结果写进去。

坑

  • navigator.clipboard 只在 https 或 localhost 下有;本地双击打开的 file:// 页面在有的浏览器里会失败,示例会走失败的样子。
  • 内容要先 fetch 再复制时,Safari 要求在点击当下同步写剪贴板:用 new ClipboardItem({ "text/plain": promise }),或者提前把内容取回来。
  • 连续快速点击要能重新播放:先移除类名、读一次 offsetWidth 触发重排,再加回去。
$给 Agent 读这篇:index.md

源码 MIT 开源,拿去改。

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>一键复制按钮</title>
    <style>
      :root {
        --paper: #fbf8f1;
        --card: #fffdf8;
        --ink: #1c2230;
        --ink-2: #4a5163;
        --ink-3: #636877;
        --line: #e6dfd0;
        --dot: #d8d0bf;
        --blue: #2f5bd3;
        --green: #277a4b;
        --seal: #c0391f;
        --term: #161a22;
        --term-ink: #d7dce6;
        --yellow: #ffe066;
        --spring: linear(0, 0.062, 0.215, 0.411, 0.615, 0.799, 0.95, 1.059, 1.126, 1.158, 1.161, 1.145, 1.116, 1.083, 1.051, 1.023, 1.001, 0.986, 0.977, 0.974, 0.974, 0.978, 0.983, 0.988, 0.994, 0.998, 1.001, 1.003, 1.004, 1.004, 1);
        --f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
        --f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #14161b;
          --card: #1e2129;
          --ink: #ece7dc;
          --ink-2: #bab6ac;
          --ink-3: #959aa8;
          --line: #31353f;
          --dot: #2c3039;
          --blue: #8eaaff;
          --green: #5cc189;
          --seal: #f0694e;
          --term: #0d0f13;
          color-scheme: dark;
        }
      }
      * { box-sizing: border-box; }
      body {
        margin: 0;
        min-height: 100vh;
        display: grid;
        place-items: center;
        padding: 32px 16px;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
        color: var(--ink);
        font: 16px/1.7 var(--f-sans);
      }
      .stack { width: min(560px, 100%); display: grid; gap: 22px; }

      /* ---------- the button ----------
         Two states drawn in one SVG: the "two sheets" icon and a check mark. The check is a path
         whose stroke is hidden with stroke-dashoffset and drawn in on success; the sheets fold
         away at the same time. The label is two lines in a one-line window that slides up. */
      .copy {
        --c: var(--ink-2);
        display: inline-flex;
        align-items: center;
        gap: 8px;
        height: 34px;
        padding: 0 12px 0 10px;
        border: 1px dashed var(--ink-3);
        border-radius: 10px;
        background: var(--card);
        color: var(--c);
        font: 500 13px/1 var(--f-sans);
        cursor: pointer;
        transition: color 0.2s, border-color 0.2s, background-color 0.2s, scale 0.45s var(--spring);
      }
      .copy:hover { border-style: solid; color: var(--ink); }
      .copy:active { scale: 0.92; }
      .copy:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
      .copy svg { width: 16px; height: 16px; overflow: visible; }
      .copy .sheets { transform-origin: 50% 50%; transition: scale 0.25s, opacity 0.2s; }
      .copy .check { stroke-dasharray: 16; stroke-dashoffset: 16; transition: stroke-dashoffset 0.35s 0.08s cubic-bezier(0.65, 0, 0.35, 1); }
      .copy .say { display: grid; height: 1em; overflow: hidden; line-height: 1; }
      .copy .say span { transition: translate 0.45s var(--spring); }
      .copy .say span:nth-child(2) { color: var(--ok, var(--green)); }

      .copy.done { --c: var(--green); border-style: solid; border-color: var(--green); }
      .copy.done .sheets { scale: 0.4; opacity: 0; }
      .copy.done .check { stroke-dashoffset: 0; }
      .copy.done .say span { translate: 0 -100%; }

      .copy.fail { --c: var(--seal); --ok: var(--seal); border-color: var(--seal); animation: shake 0.4s; }
      .copy.fail .say span { translate: 0 -100%; }
      @keyframes shake {
        20%, 60% { translate: -3px 0; }
        40%, 80% { translate: 3px 0; }
      }

      /* ---------- where it usually lives: a code block bar ---------- */
      .code { border-radius: 12px; overflow: hidden; background: var(--term); box-shadow: 0 10px 28px -14px rgba(28, 34, 48, 0.4); }
      .bar { display: flex; align-items: center; justify-content: space-between; padding: 6px 6px 6px 14px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); font: 12px/1 var(--f-mono); color: var(--term-ink); opacity: 0.9; }
      .code .copy { --c: var(--term-ink); background: transparent; border-color: rgba(255, 255, 255, 0.25); height: 30px; }
      .code .copy:hover { color: var(--yellow); border-color: var(--yellow); }
      .code .copy.done { --c: #5fd38a; --ok: #5fd38a; border-color: #5fd38a; }
      pre { margin: 0; padding: 14px 16px; color: var(--term-ink); font: 13px/1.7 var(--f-mono); overflow-x: auto; }
      .row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
      .key { padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); font: 13px/1 var(--f-mono); color: var(--ink-2); }
      .hint { margin: 0; font-size: 12px; color: var(--ink-3); }

      @media (prefers-reduced-motion: reduce) {
        .copy, .copy * { transition: none !important; animation: none !important; }
      }
    </style>
  </head>
  <body>
    <main class="stack">
      <div class="code">
        <div class="bar">
          <span>bash · install.sh</span>
          <button class="copy" type="button" data-copy="pre">
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
              <g class="sheets"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.5v-.5a1.5 1.5 0 0 0-1.5-1.5H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 10.5h.5" /></g>
              <path class="check" d="M3 8.5 6.5 12 13 4.5" />
            </svg>
            <span class="say"><span>复制</span><span>已复制</span></span>
          </button>
        </div>
        <pre>npm create astro@latest -- --template minimal
cd my-site &amp;&amp; npm run dev</pre>
      </div>

      <div class="row">
        <span class="key">sk-demo-••••••••3f9a</span>
        <button class="copy" type="button" data-copy-text="sk-demo-not-a-real-key">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <g class="sheets"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.5v-.5a1.5 1.5 0 0 0-1.5-1.5H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 10.5h.5" /></g>
            <path class="check" d="M3 8.5 6.5 12 13 4.5" />
          </svg>
          <span class="say"><span>复制</span><span>已复制</span></span>
        </button>
        <button class="copy" type="button" data-fail>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <g class="sheets"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.5v-.5a1.5 1.5 0 0 0-1.5-1.5H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 10.5h.5" /></g>
            <path class="check" d="M3 8.5 6.5 12 13 4.5" />
          </svg>
          <span class="say"><span>失败的样子</span><span>没复制上</span></span>
        </button>
      </div>
      <p class="hint">成功:两张纸收起、画出对勾、文字往上换一行;失败:变红、左右抖一下。1.6 秒后自己恢复。</p>
      <p class="hint" aria-live="polite" id="live"></p>
    </main>

    <script>
      const live = document.getElementById("live");
      const settle = (btn, cls) => {
        btn.classList.remove("done", "fail");
        void btn.offsetWidth; // restart the animation when clicked again quickly
        btn.classList.add(cls);
        // Screen readers hear the result; the visual label alone would be missed.
        live.textContent = cls === "done" ? "已复制到剪贴板" : "复制失败";
        clearTimeout(btn._t);
        btn._t = setTimeout(() => btn.classList.remove(cls), 1600);
      };
      for (const btn of document.querySelectorAll(".copy")) {
        btn.addEventListener("click", async () => {
          if (btn.hasAttribute("data-fail")) return settle(btn, "fail");
          // textContent, not innerText: works on folded or hidden blocks too.
          const text = btn.dataset.copyText ?? btn.closest(".code")?.querySelector("pre")?.textContent ?? "";
          try {
            await navigator.clipboard.writeText(text);
            settle(btn, "done");
          } catch {
            settle(btn, "fail");
          }
        });
      }
    </script>
  </body>
</html>

来加个微信吧

刘卓琪的微信二维码

微信扫码,或在手机上长按识别

加好友请备注来意,不然可能会漏掉哦~