---
title: "One-Click Copy Button"
summary: "On success, two sheets fold away, a checkmark draws, and the text slides to \"已复制\"; on failure it turns red and shakes. Screen-reader aware."
category: "button (Buttons & controls)"
origin: "original"
stack: ["html","css","svg","js"]
tags: ["button","copy","code block","micro-interaction","spring"]
demo: "https://zhuoqidev.com/design/demos/copy-button/"
url: "https://zhuoqidev.com/en/design/copy-button/"
license: "MIT"
added: "2026-10-19"
translation: "https://zhuoqidev.com/design/copy-button/index.md"
---

# One-Click Copy Button

> On success, two sheets fold away, a checkmark draws, and the text slides to "已复制"; on failure it turns red and shakes. Screen-reader aware.

## What makes it work

- **One SVG, two states**: the icon contains both the "two sheets" and the checkmark. The checkmark is one path, hidden at first with `stroke-dasharray: 16; stroke-dashoffset: 16`; on success the offset goes to 0, so it looks drawn in one stroke, while the two sheets fade out with `scale(.4)` at the same time.
- **Swap lines, not characters**: the label is two lines of text in a window one line tall (`height: 1em; overflow: hidden`), and on success the whole thing slides up with `translate: 0 -100%` using a spring curve, with a little bounce.
- **Failure has a look too**: when the clipboard is refused (not https, permission not granted) it turns red and shakes left and right twice, instead of nothing happening.
- **It copies textContent**: the full text can be copied even if the code block is collapsed or hidden; `innerText` returns an empty string for elements without layout.
- **Screen readers**: the result is written into an `aria-live="polite"` region, so people who can't see the screen also know whether the copy succeeded.

## How to use it

1. Copy the CSS for `.copy` and the button's HTML (bring the SVG part along as is).
2. The button can go anywhere: in a code block's title bar it looks for the `<pre>` in the same block; you can also write the content to copy directly with `data-copy-text` (for example an API key or a command).
3. Put an empty `aria-live` element on the page, and the script writes the result into it.

## Pitfalls

- `navigator.clipboard` is only available on https or localhost; a locally double-clicked `file://` page fails in some browsers, and the example then shows the failure look.
- When content must be fetched with `fetch` before copying, Safari requires writing to the clipboard synchronously at the moment of the click: use `new ClipboardItem({ "text/plain": promise })`, or fetch the content in advance.
- Rapid repeated clicks must replay the animation: first remove the class name, read `offsetWidth` once to trigger a reflow, then add it back.

## Reference implementation (full source)

Live demo: https://zhuoqidev.com/design/demos/copy-button/. One file: save it as .html and open it.

### index.html

```html
<!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>
```
