20 CSS Copy Buttons01 / 20

Light JSMIT licensed

CSS Copy to Clipboard Button with Success Checkmark State

The reference build for the whole collection: a copy button that reads Copy, flips to Copied! with a checkmark, and reverts after two seconds. Icon and label crossfade in the same 200 ms, the width never jumps because both labels are stacked in one grid cell, and the success is announced to screen readers as well as shown. Every other demo is a variation on this state machine.

Published

Live Demo
Try it

The code

<div class="cpy-01">
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap">
  <input class="cpy-01__sr" type="checkbox" id="cpy-01-dark">
  <label class="cpy-01__theme" for="cpy-01-dark" title="Switch light or dark theme">
    <svg class="cpy-01__sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4.3" stroke="currentColor" stroke-width="1.7"/><path d="M12 2.7v2.2M12 19.1v2.2M2.7 12h2.2M19.1 12h2.2M5.4 5.4l1.6 1.6M17 17l1.6 1.6M18.6 5.4 17 7M7 17l-1.6 1.6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
    <svg class="cpy-01__moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.4A8.4 8.4 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
    <span class="cpy-01__vh">Switch light or dark theme</span>
  </label>

  <div class="cpy-01__stage">
    <div class="cpy-01__card">
      <p class="cpy-01__eyebrow"><span class="cpy-01__dot"></span>Install</p>
      <h2 class="cpy-01__title">Add the package</h2>
      <p class="cpy-01__lead">One command, one button, three states. Click it &mdash; the label and the icon change together, then hand themselves back after two seconds.</p>

      <div class="cpy-01__row">
        <code class="cpy-01__value" id="cpy-01-value">npm i @codefronts/clipboard</code>
        <button class="cpy-01__btn" type="button" data-state="idle" aria-describedby="cpy-01-hint">
          <span class="cpy-01__icons">
            <svg class="cpy-01__i1" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><rect x="9" y="9" width="11" height="11" rx="2.4" stroke="currentColor" stroke-width="1.8"/><path d="M15.5 5.6A2.6 2.6 0 0 0 13 4H6.6A2.6 2.6 0 0 0 4 6.6V13a2.6 2.6 0 0 0 1.6 2.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
            <svg class="cpy-01__i2" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M4.8 12.6 9.5 17.3 19.2 7.2" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
          </span>
          <span class="cpy-01__labels">
            <span class="cpy-01__l1">Copy</span>
            <span class="cpy-01__l2">Copied!</span>
          </span>
        </button>
      </div>

      <p class="cpy-01__hint" id="cpy-01-hint">Tab to the button and press <kbd class="cpy-01__kbd">Enter</kbd> &mdash; keyboard and pointer run the identical path.</p>
      <p class="cpy-01__live" role="status" aria-live="polite"></p>
    </div>
  </div>
</div>
.cpy-01 {
  --bg: #f6f7f9;
  --panel: #ffffff;
  --panel2: #f1f3f7;
  --ink: #0b1020;
  --muted: #5a6373;
  --line: rgba(11,16,32,.11);
  --accent: #4f46e5;
  --ok: #15803d;
  --ok-bg: rgba(21,128,61,.1);
  --err: #dc2626;
  --err-bg: rgba(220,38,38,.1);
  --dur: 200ms;
  --sans: system-ui,-apple-system,"Segoe UI",sans-serif;
  --mono: "JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
  width: 100%;
  min-height: 100vh;
  display: block;
  position: relative;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

.cpy-01 *,
.cpy-01 *::before,
.cpy-01 *::after {
  box-sizing: border-box;
}

@supports (color: oklch(.5 .1 250)) {
  .cpy-01 {
    --accent: oklch(.53 .21 274);
    --ok: oklch(.55 .15 149);
    --ok-bg: oklch(.55 .15 149/.12);
    --err: oklch(.58 .21 25);
    --err-bg: oklch(.58 .21 25/.12);
  }
}

.cpy-01__sr {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  opacity: 0;
  clip-path: inset(50%);
  pointer-events: none;
}

.cpy-01__vh {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cpy-01__theme {
  position: absolute;
  inset-block-start: clamp(1rem,3vw,1.75rem);
  inset-inline-end: clamp(1rem,3vw,1.75rem);
  z-index: 3;
  inline-size: 2.5rem;
  block-size: 2.5rem;
  display: grid;
  place-items: center;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: rgba(255,255,255,.6);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  color: var(--ink);
  cursor: pointer;
  transition: background var(--dur),color var(--dur);
}

.cpy-01__theme svg {
  inline-size: 1.15rem;
  block-size: 1.15rem;
}

.cpy-01__moon {
  display: none;
}

.cpy-01__sr:focus-visible + .cpy-01__theme {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.cpy-01__stage {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: clamp(1.25rem,6vw,4.5rem);
}

.cpy-01__card {
  inline-size: min(100%,34rem);
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 1.5rem;
  padding: clamp(1.4rem,4vw,2.25rem);
  display: grid;
  gap: .85rem;
  box-shadow: 0 1px 2px rgba(11,16,32,.05),0 26px 50px -34px rgba(11,16,32,.35);
}

.cpy-01__eyebrow {
  display: flex;
  align-items: center;
  gap: .5rem;
  margin: 0;
  font-family: var(--mono);
  font-size: .72rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
}

.cpy-01__dot {
  inline-size: .4rem;
  block-size: .4rem;
  border-radius: 999px;
  background: var(--accent);
}

.cpy-01__title {
  margin: 0;
  font-size: clamp(1.4rem,3.4vw,1.85rem);
  line-height: 1.15;
  letter-spacing: -.02em;
  font-weight: 640;
}

.cpy-01__lead {
  margin: 0 0 .35rem;
  font-size: .96rem;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.cpy-01__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .6rem;
  padding: .65rem .65rem .65rem .9rem;
  border: 1px solid var(--line);
  border-radius: 1rem;
  background: var(--panel2);
}

.cpy-01__value {
  flex: 1 1 12rem;
  min-inline-size: 0;
  font-family: var(--mono);
  font-size: clamp(.8rem,1.6vw,.92rem);
  overflow-x: auto;
  white-space: nowrap;
  scrollbar-width: none;
}

.cpy-01__value::-webkit-scrollbar {
  display: none;
}

.cpy-01__btn {
  flex: none;
  min-block-size: 2.75rem;
  min-inline-size: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .5rem;
  padding-inline: 1rem;
  border: 1px solid var(--line);
  border-radius: .8rem;
  background: var(--panel);
  color: var(--ink);
  font: inherit;
  font-size: .9rem;
  font-weight: 560;
  cursor: pointer;
  transition: background var(--dur),border-color var(--dur),color var(--dur),scale var(--dur);
}

.cpy-01__btn:hover {
  background: var(--panel2);
  border-color: color-mix(in srgb,var(--ink) 22%,transparent);
}

.cpy-01__btn:active {
  scale: .975;
}

.cpy-01__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.cpy-01__btn[data-state="copied"] {
  color: var(--ok);
  background: var(--ok-bg);
  border-color: color-mix(in srgb,var(--ok) 35%,transparent);
}

.cpy-01__btn[data-state="error"] {
  color: var(--err);
  background: var(--err-bg);
  border-color: color-mix(in srgb,var(--err) 35%,transparent);
}

.cpy-01__icons {
  position: relative;
  inline-size: 1.1rem;
  block-size: 1.1rem;
  flex: none;
}

.cpy-01__icons svg {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  transition: opacity var(--dur),scale var(--dur),rotate var(--dur);
}

.cpy-01__i2 {
  opacity: 0;
  scale: .5;
  rotate: -22deg;
}

.cpy-01__btn[data-state="copied"] .cpy-01__i1 {
  opacity: 0;
  scale: .5;
  rotate: 18deg;
}

.cpy-01__btn[data-state="copied"] .cpy-01__i2 {
  opacity: 1;
  scale: 1;
  rotate: 0deg;
}

.cpy-01__labels {
  display: grid;
}

.cpy-01__labels span {
  grid-area: 1/1;
  transition: opacity var(--dur),translate var(--dur);
}

.cpy-01__l2 {
  opacity: 0;
  translate: 0 .45rem;
}

.cpy-01__btn[data-state="copied"] .cpy-01__l1 {
  opacity: 0;
  translate: 0 -.45rem;
}

.cpy-01__btn[data-state="copied"] .cpy-01__l2 {
  opacity: 1;
  translate: 0 0;
}

.cpy-01__btn[data-state="error"] .cpy-01__l1 {
  opacity: 0;
}

.cpy-01__btn[data-state="error"] .cpy-01__l2 {
  opacity: 1;
  translate: 0 0;
}

.cpy-01__hint {
  margin: 0;
  font-size: .86rem;
  line-height: 1.55;
  color: var(--muted);
}

.cpy-01__kbd {
  font-family: var(--mono);
  font-size: .76rem;
  padding: .15rem .4rem;
  border: 1px solid var(--line);
  border-block-end-width: 2px;
  border-radius: .35rem;
  background: var(--panel2);
}

.cpy-01__live {
  margin: 0;
  min-block-size: 1.2rem;
  font-family: var(--mono);
  font-size: .78rem;
  color: var(--ok);
}

.cpy-01__btn[data-state="error"] ~ .cpy-01__live,
.cpy-01__card:has([data-state="error"]) .cpy-01__live {
  color: var(--err);
}

@media not (prefers-color-scheme: dark) {
  .cpy-01:has(#cpy-01-dark:checked) {
    --bg: #070910;
    --panel: #0d1119;
    --panel2: #151b26;
    --ink: #e9edf5;
    --muted: rgba(233,237,245,.62);
    --line: rgba(233,237,245,.14);
    --accent: #a5b4fc;
    --ok: #6ee7a8;
    --ok-bg: rgba(110,231,168,.14);
    --err: #fda4af;
    --err-bg: rgba(253,164,175,.14);
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__theme {
    background: rgba(255,255,255,.07);
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__sun {
    display: none;
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__moon {
    display: block;
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__card {
    box-shadow: 0 1px 2px rgba(0,0,0,.5),0 26px 50px -34px rgba(0,0,0,.8);
  }
}

@media (prefers-color-scheme: dark) {
  .cpy-01:not([data-theme="light"]):not(:has(#cpy-01-dark:checked)) {
    --bg: #070910;
    --panel: #0d1119;
    --panel2: #151b26;
    --ink: #e9edf5;
    --muted: rgba(233,237,245,.62);
    --line: rgba(233,237,245,.14);
    --accent: #a5b4fc;
    --ok: #6ee7a8;
    --ok-bg: rgba(110,231,168,.14);
    --err: #fda4af;
    --err-bg: rgba(253,164,175,.14);
  }

  .cpy-01__theme {
    background: rgba(255,255,255,.07);
  }

  .cpy-01__sun {
    display: none;
  }

  .cpy-01__moon {
    display: block;
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__sun {
    display: block;
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__moon {
    display: none;
  }
}

.cpy-01[data-theme="dark"] {
  --bg: #070910;
  --panel: #0d1119;
  --panel2: #151b26;
  --ink: #e9edf5;
  --muted: rgba(233,237,245,.62);
  --line: rgba(233,237,245,.14);
  --accent: #a5b4fc;
  --ok: #6ee7a8;
  --ok-bg: rgba(110,231,168,.14);
  --err: #fda4af;
  --err-bg: rgba(253,164,175,.14);
}

@media (prefers-reduced-motion: reduce) {
  .cpy-01 *,
    .cpy-01 *::before,
    .cpy-01 *::after {
    transition: none !important;
    animation: none !important;
  }
}

@media (forced-colors: active) {
  .cpy-01__card,
    .cpy-01__row,
    .cpy-01__btn,
    .cpy-01__theme {
    border-color: CanvasText;
    background: Canvas;
    box-shadow: none;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }

  .cpy-01__btn[data-state="copied"] {
    color: CanvasText;
  }
}
const cpy01 = document.querySelector('.cpy-01');
if (cpy01) {
  const btn = cpy01.querySelector('.cpy-01__btn');
  const live = cpy01.querySelector('.cpy-01__live');
  const value = cpy01.querySelector('.cpy-01__value');
  let timer;
  const set = (state, message) => {
    btn.dataset.state = state;
    live.textContent = message;
    clearTimeout(timer);                       // never let two reverts race
    timer = setTimeout(() => { btn.dataset.state = 'idle'; live.textContent = ''; }, 2000);
  };
  btn.addEventListener('click', async () => {
    const text = value.textContent.trim();
    try {
      await navigator.clipboard.writeText(text); // rejects outside a secure context
      set('copied', 'Copied to clipboard');
    } catch {
      set('error', 'Copy blocked \u2014 select the text and press Ctrl C');
    }
  });
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Copy Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Copy to Clipboard Button with Success Checkmark State
Source: https://codefronts.com/snippets/css-copy-button/css-copy-to-clipboard-button-with-success-checkmark-state/

The reference build for the whole collection: a copy button that reads Copy, flips to Copied! with a checkmark, and reverts after two seconds. Icon and label crossfade in the same 200&nbsp;ms, the width never jumps because both labels are stacked in one grid cell, and the success is announced to screen readers as well as shown. Every other demo is a variation on this state machine.
## HTML
```html
<div class="cpy-01">
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap">
  <input class="cpy-01__sr" type="checkbox" id="cpy-01-dark">
  <label class="cpy-01__theme" for="cpy-01-dark" title="Switch light or dark theme">
    <svg class="cpy-01__sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4.3" stroke="currentColor" stroke-width="1.7"/><path d="M12 2.7v2.2M12 19.1v2.2M2.7 12h2.2M19.1 12h2.2M5.4 5.4l1.6 1.6M17 17l1.6 1.6M18.6 5.4 17 7M7 17l-1.6 1.6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
    <svg class="cpy-01__moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.4A8.4 8.4 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
    <span class="cpy-01__vh">Switch light or dark theme</span>
  </label>

  <div class="cpy-01__stage">
    <div class="cpy-01__card">
      <p class="cpy-01__eyebrow"><span class="cpy-01__dot"></span>Install</p>
      <h2 class="cpy-01__title">Add the package</h2>
      <p class="cpy-01__lead">One command, one button, three states. Click it &mdash; the label and the icon change together, then hand themselves back after two seconds.</p>

      <div class="cpy-01__row">
        <code class="cpy-01__value" id="cpy-01-value">npm i @codefronts/clipboard</code>
        <button class="cpy-01__btn" type="button" data-state="idle" aria-describedby="cpy-01-hint">
          <span class="cpy-01__icons">
            <svg class="cpy-01__i1" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><rect x="9" y="9" width="11" height="11" rx="2.4" stroke="currentColor" stroke-width="1.8"/><path d="M15.5 5.6A2.6 2.6 0 0 0 13 4H6.6A2.6 2.6 0 0 0 4 6.6V13a2.6 2.6 0 0 0 1.6 2.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
            <svg class="cpy-01__i2" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M4.8 12.6 9.5 17.3 19.2 7.2" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
          </span>
          <span class="cpy-01__labels">
            <span class="cpy-01__l1">Copy</span>
            <span class="cpy-01__l2">Copied!</span>
          </span>
        </button>
      </div>

      <p class="cpy-01__hint" id="cpy-01-hint">Tab to the button and press <kbd class="cpy-01__kbd">Enter</kbd> &mdash; keyboard and pointer run the identical path.</p>
      <p class="cpy-01__live" role="status" aria-live="polite"></p>
    </div>
  </div>
</div>
```
## CSS
```css
.cpy-01 {
  --bg: #f6f7f9;
  --panel: #ffffff;
  --panel2: #f1f3f7;
  --ink: #0b1020;
  --muted: #5a6373;
  --line: rgba(11,16,32,.11);
  --accent: #4f46e5;
  --ok: #15803d;
  --ok-bg: rgba(21,128,61,.1);
  --err: #dc2626;
  --err-bg: rgba(220,38,38,.1);
  --dur: 200ms;
  --sans: system-ui,-apple-system,"Segoe UI",sans-serif;
  --mono: "JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
  width: 100%;
  min-height: 100vh;
  display: block;
  position: relative;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

.cpy-01 *,
.cpy-01 *::before,
.cpy-01 *::after {
  box-sizing: border-box;
}

@supports (color: oklch(.5 .1 250)) {
  .cpy-01 {
    --accent: oklch(.53 .21 274);
    --ok: oklch(.55 .15 149);
    --ok-bg: oklch(.55 .15 149/.12);
    --err: oklch(.58 .21 25);
    --err-bg: oklch(.58 .21 25/.12);
  }
}

.cpy-01__sr {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  opacity: 0;
  clip-path: inset(50%);
  pointer-events: none;
}

.cpy-01__vh {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cpy-01__theme {
  position: absolute;
  inset-block-start: clamp(1rem,3vw,1.75rem);
  inset-inline-end: clamp(1rem,3vw,1.75rem);
  z-index: 3;
  inline-size: 2.5rem;
  block-size: 2.5rem;
  display: grid;
  place-items: center;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: rgba(255,255,255,.6);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  color: var(--ink);
  cursor: pointer;
  transition: background var(--dur),color var(--dur);
}

.cpy-01__theme svg {
  inline-size: 1.15rem;
  block-size: 1.15rem;
}

.cpy-01__moon {
  display: none;
}

.cpy-01__sr:focus-visible + .cpy-01__theme {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.cpy-01__stage {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: clamp(1.25rem,6vw,4.5rem);
}

.cpy-01__card {
  inline-size: min(100%,34rem);
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 1.5rem;
  padding: clamp(1.4rem,4vw,2.25rem);
  display: grid;
  gap: .85rem;
  box-shadow: 0 1px 2px rgba(11,16,32,.05),0 26px 50px -34px rgba(11,16,32,.35);
}

.cpy-01__eyebrow {
  display: flex;
  align-items: center;
  gap: .5rem;
  margin: 0;
  font-family: var(--mono);
  font-size: .72rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
}

.cpy-01__dot {
  inline-size: .4rem;
  block-size: .4rem;
  border-radius: 999px;
  background: var(--accent);
}

.cpy-01__title {
  margin: 0;
  font-size: clamp(1.4rem,3.4vw,1.85rem);
  line-height: 1.15;
  letter-spacing: -.02em;
  font-weight: 640;
}

.cpy-01__lead {
  margin: 0 0 .35rem;
  font-size: .96rem;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.cpy-01__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .6rem;
  padding: .65rem .65rem .65rem .9rem;
  border: 1px solid var(--line);
  border-radius: 1rem;
  background: var(--panel2);
}

.cpy-01__value {
  flex: 1 1 12rem;
  min-inline-size: 0;
  font-family: var(--mono);
  font-size: clamp(.8rem,1.6vw,.92rem);
  overflow-x: auto;
  white-space: nowrap;
  scrollbar-width: none;
}

.cpy-01__value::-webkit-scrollbar {
  display: none;
}

.cpy-01__btn {
  flex: none;
  min-block-size: 2.75rem;
  min-inline-size: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .5rem;
  padding-inline: 1rem;
  border: 1px solid var(--line);
  border-radius: .8rem;
  background: var(--panel);
  color: var(--ink);
  font: inherit;
  font-size: .9rem;
  font-weight: 560;
  cursor: pointer;
  transition: background var(--dur),border-color var(--dur),color var(--dur),scale var(--dur);
}

.cpy-01__btn:hover {
  background: var(--panel2);
  border-color: color-mix(in srgb,var(--ink) 22%,transparent);
}

.cpy-01__btn:active {
  scale: .975;
}

.cpy-01__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.cpy-01__btn[data-state="copied"] {
  color: var(--ok);
  background: var(--ok-bg);
  border-color: color-mix(in srgb,var(--ok) 35%,transparent);
}

.cpy-01__btn[data-state="error"] {
  color: var(--err);
  background: var(--err-bg);
  border-color: color-mix(in srgb,var(--err) 35%,transparent);
}

.cpy-01__icons {
  position: relative;
  inline-size: 1.1rem;
  block-size: 1.1rem;
  flex: none;
}

.cpy-01__icons svg {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  transition: opacity var(--dur),scale var(--dur),rotate var(--dur);
}

.cpy-01__i2 {
  opacity: 0;
  scale: .5;
  rotate: -22deg;
}

.cpy-01__btn[data-state="copied"] .cpy-01__i1 {
  opacity: 0;
  scale: .5;
  rotate: 18deg;
}

.cpy-01__btn[data-state="copied"] .cpy-01__i2 {
  opacity: 1;
  scale: 1;
  rotate: 0deg;
}

.cpy-01__labels {
  display: grid;
}

.cpy-01__labels span {
  grid-area: 1/1;
  transition: opacity var(--dur),translate var(--dur);
}

.cpy-01__l2 {
  opacity: 0;
  translate: 0 .45rem;
}

.cpy-01__btn[data-state="copied"] .cpy-01__l1 {
  opacity: 0;
  translate: 0 -.45rem;
}

.cpy-01__btn[data-state="copied"] .cpy-01__l2 {
  opacity: 1;
  translate: 0 0;
}

.cpy-01__btn[data-state="error"] .cpy-01__l1 {
  opacity: 0;
}

.cpy-01__btn[data-state="error"] .cpy-01__l2 {
  opacity: 1;
  translate: 0 0;
}

.cpy-01__hint {
  margin: 0;
  font-size: .86rem;
  line-height: 1.55;
  color: var(--muted);
}

.cpy-01__kbd {
  font-family: var(--mono);
  font-size: .76rem;
  padding: .15rem .4rem;
  border: 1px solid var(--line);
  border-block-end-width: 2px;
  border-radius: .35rem;
  background: var(--panel2);
}

.cpy-01__live {
  margin: 0;
  min-block-size: 1.2rem;
  font-family: var(--mono);
  font-size: .78rem;
  color: var(--ok);
}

.cpy-01__btn[data-state="error"] ~ .cpy-01__live,
.cpy-01__card:has([data-state="error"]) .cpy-01__live {
  color: var(--err);
}

@media not (prefers-color-scheme: dark) {
  .cpy-01:has(#cpy-01-dark:checked) {
    --bg: #070910;
    --panel: #0d1119;
    --panel2: #151b26;
    --ink: #e9edf5;
    --muted: rgba(233,237,245,.62);
    --line: rgba(233,237,245,.14);
    --accent: #a5b4fc;
    --ok: #6ee7a8;
    --ok-bg: rgba(110,231,168,.14);
    --err: #fda4af;
    --err-bg: rgba(253,164,175,.14);
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__theme {
    background: rgba(255,255,255,.07);
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__sun {
    display: none;
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__moon {
    display: block;
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__card {
    box-shadow: 0 1px 2px rgba(0,0,0,.5),0 26px 50px -34px rgba(0,0,0,.8);
  }
}

@media (prefers-color-scheme: dark) {
  .cpy-01:not([data-theme="light"]):not(:has(#cpy-01-dark:checked)) {
    --bg: #070910;
    --panel: #0d1119;
    --panel2: #151b26;
    --ink: #e9edf5;
    --muted: rgba(233,237,245,.62);
    --line: rgba(233,237,245,.14);
    --accent: #a5b4fc;
    --ok: #6ee7a8;
    --ok-bg: rgba(110,231,168,.14);
    --err: #fda4af;
    --err-bg: rgba(253,164,175,.14);
  }

  .cpy-01__theme {
    background: rgba(255,255,255,.07);
  }

  .cpy-01__sun {
    display: none;
  }

  .cpy-01__moon {
    display: block;
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__sun {
    display: block;
  }

  .cpy-01:has(#cpy-01-dark:checked) .cpy-01__moon {
    display: none;
  }
}

.cpy-01[data-theme="dark"] {
  --bg: #070910;
  --panel: #0d1119;
  --panel2: #151b26;
  --ink: #e9edf5;
  --muted: rgba(233,237,245,.62);
  --line: rgba(233,237,245,.14);
  --accent: #a5b4fc;
  --ok: #6ee7a8;
  --ok-bg: rgba(110,231,168,.14);
  --err: #fda4af;
  --err-bg: rgba(253,164,175,.14);
}

@media (prefers-reduced-motion: reduce) {
  .cpy-01 *,
    .cpy-01 *::before,
    .cpy-01 *::after {
    transition: none !important;
    animation: none !important;
  }
}

@media (forced-colors: active) {
  .cpy-01__card,
    .cpy-01__row,
    .cpy-01__btn,
    .cpy-01__theme {
    border-color: CanvasText;
    background: Canvas;
    box-shadow: none;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }

  .cpy-01__btn[data-state="copied"] {
    color: CanvasText;
  }
}
```

## JavaScript
```js
const cpy01 = document.querySelector('.cpy-01');
if (cpy01) {
  const btn = cpy01.querySelector('.cpy-01__btn');
  const live = cpy01.querySelector('.cpy-01__live');
  const value = cpy01.querySelector('.cpy-01__value');
  let timer;
  const set = (state, message) => {
    btn.dataset.state = state;
    live.textContent = message;
    clearTimeout(timer);                       // never let two reverts race
    timer = setTimeout(() => { btn.dataset.state = 'idle'; live.textContent = ''; }, 2000);
  };
  btn.addEventListener('click', async () => {
    const text = value.textContent.trim();
    try {
      await navigator.clipboard.writeText(text); // rejects outside a secure context
      set('copied', 'Copied to clipboard');
    } catch {
      set('error', 'Copy blocked \u2014 select the text and press Ctrl C');
    }
  });
}
```

How this works

One attribute drives every visual. Rather than toggling three classes, the button carries data-state and CSS reacts to it. Adding a state later means adding a selector, not rewriting the script:

<button type="button" class="cpy-01__btn" data-state="idle">
  <span class="cpy-01__icons">…copy svg…checkmark svg…</span>
  <span class="cpy-01__labels">
    <span class="cpy-01__l1">Copy</span>
    <span class="cpy-01__l2">Copied!</span>
  </span>
</button>

Why both labels stay in the DOM. Swapping textContent from Copy to Copied! changes the button's width mid-animation, which reflows whatever sits beside it. Stacking both spans in the same grid cell makes the button as wide as the longer word forever, so the transition is pure fade and slide:

.cpy-01__labels{display:grid;}          /* one cell */
.cpy-01__labels span{grid-area:1/1;}    /* both children in it */
.cpy-01__l2{opacity:0; translate:0 .45rem;}
.cpy-01__btn[data-state="copied"] .cpy-01__l1{opacity:0; translate:0 -.45rem;}
.cpy-01__btn[data-state="copied"] .cpy-01__l2{opacity:1; translate:0 0;}

The write can fail, so treat it as fallible. writeText() rejects outside a secure context, when the document is not focused, or when a sandboxed iframe withholds permission. await inside try/catch means the same code path produces the error state instead of an uncaught rejection and a button stuck on Copy:

try {
  await navigator.clipboard.writeText(value.textContent.trim());
  set('copied', 'Copied to clipboard');
} catch {
  set('error', 'Copy blocked — select the text and press Ctrl C');
}

Clear the timer first. Every state change calls clearTimeout(timer) before scheduling the revert. Without it, three fast clicks schedule three reverts and the label flickers back to Copy while the third click is still fresh.

Make it yours

  • Change the revert window in one place: the 2000 in setTimeout. Under 1200 ms the success reads as a flicker; over 3000 ms it looks stuck.
  • Retune every transition with --dur on the root. Keep it inside 150-250 ms — a copy button that animates for half a second feels slow, not smooth.
  • Swap the success treatment from tinted to solid by moving --ok into background on [data-state="copied"] and setting color:#fff.
  • For an icon-only variant, delete the __labels span and add aria-label="Copy install command" plus padding:0 with a fixed 2.75rem square.
  • Copy from an attribute instead of the visible text by reading btn.dataset.copy — that is what demos 11, 15 and 20 do.
  • Add a subtle press response with .cpy-01__btn:active{scale:.97}; it is compositor-only, so it costs nothing.

Gotchas — read before shipping

  • navigator.clipboard is undefined on http:// and file://. Reading .writeText off it throws a TypeError before any promise exists, so the optional chain plus try/catch matters.
  • Never write the success state before the promise resolves. An optimistic Copied! lies to the user on every blocked write.
  • Skipping clearTimeout is the most common bug in this pattern: stacked timers make the label flicker on rapid clicks.
  • A <div> with a click handler is not a button — it is missing Enter, Space, focus order and the button role. Use <button type="button">.
  • Without type="button" a button inside a form submits it, which reloads the page and discards the copy.
  • Colour alone is not feedback. The aria-live region is what makes the state change perceivable to a screen reader user.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by :has(), oklch(), color-mix(), backdrop-filter, text-wrap as this demo is written. The core technique itself goes back further — Chrome 66+.

Async Clipboard writeText() ships in Chrome 66, Safari 13.1 and Firefox 63, and requires a secure context (https or localhost). oklch() (Chrome 111 / Safari 15.4 / Firefox 113) and :has() (Chrome 105 / Safari 15.4 / Firefox 121) sit behind @supports; the hex fallback and the theme toggle degrade cleanly.

Techniques used in this demo

Search CodeFronts

Loading…