GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 Copy Button CSS UI Patterns (Copy to Clipboard)

Nine copy-button UIs: one splits the result across two buttons, one fills its progress by the character count it just copied, and another times its own

Auto-plays · click a tile to jump to its section · all nine in one zip

None of these nine screens calls the real Clipboard API — copy success and failure are stood in for by a virtual clipboard state machine, with a single comment marking exactly where a real navigator.clipboard.writeText call would go in production. Items 01 (icon-morph check), 05 (queue history), 06 (fail-recovery), and 07 (contrast swap) run purely on requestAnimationFrame cycling on a fixed period with zero click, drag, or pointer listeners attached; only 02 (character-count fill), 03 (fallback selection), 04 (two-button split), 08 (permission gate), and 09 (rich-text toast) carry a real click listener that replays the same computation from the moment it fires. Every item's data — string length, baseline time, and so on — is a fixed value, so the same scene reproduces on every run.

Anatomy of a copy button — what nine screens compute

Nine ways to wire a copy to clipboard button without ever touching the real API, from a stroke drawn by path length to a toast sized by how many style tags survived the paste — the same handful of arithmetic moves keeps resurfacing under a different name.

Why a button measures the DOM before it decides anything

Item 01 measures the check polyline's real length via getTotalLength before it draws a single pixel, item 03 measures a selection range's width via Range.getBoundingClientRect and a notice's width via scrollWidth before choosing inline or stacked, and item 04 measures the live distance between two buttons via getBoundingClientRect before it computes a stagger — three different outputs, one shared habit of reading the layout before writing to it.

Why three cards run their state purely against a clock

Item 05 recomputes each row's fake elapsed time every frame from a baseline plus a real-time multiplier, item 06 recomputes the ratio of time since a failure divided by a recovery constant, and item 08 recomputes the ratio of wait time remaining after a permission check — none of the three waits for an event mid-cycle, they just keep reading a clock and mapping the result onto a label, a color, or a tint.

Why a count becomes a size or a color instead of staying just a number

Item 02 turns a copied string's character count into a fill duration and a fill ratio, item 07 turns a background's RGB into a relative-luminance number and compares it against a threshold to pick an icon variant, and item 09 turns a DOM query's style-tag count into a toast's minimum width — a count, in each case, becomes something you can see rather than something you'd have to read off a label.

Code per screen

Every one of these nine keeps its copy button css style inline, in one file, with no external stylesheet or class library to load first — the block below is pasted straight out of that item's own index.html, not retyped from memory.

01Icon-morph check — a button that draws its success as a single stroke

Once the copy succeeds, the icon swaps from sheet to check, and the check's stroke is measured end to end via getTotalLength, fixed as strokeDasharray, then drawn by recomputing strokeDashoffset down to zero across a set duration. It suits the copy button in a code block's top-right corner and install-command boxes.

Icon-morph check — a button that draws its success as a single stroke
Open in a new tab

01 · Icon-morph check — a button that draws its success as a single stroke — Measured area 1.288% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

strokeDashoffsetgetTotalLengthrequestAnimationFrame

What the numbers say

Cumulative area is 1.29% (max frame 1.265%, mean intensity 144.5) across 13 of 23 frame deltas — a check stroke is simply a small on-screen element, so a low area here is structural, not a shortfall against the site's motion floor.

  var length = stroke.getTotalLength();
  stroke.style.strokeDasharray = length.toFixed(2);

  var LABEL_IDLE = 'Copy code';
  var LABEL_DONE = 'Copied';
  var CYCLE_MS = 2000;
  var TRIGGER_AT = 0.08;
  var DRAW_MS = 640;
  var REVERT_AT = 0.62;

  function apply(t) {
    var drawStart = TRIGGER_AT * CYCLE_MS;
    var drawEnd = drawStart + DRAW_MS;
    var revertAt = REVERT_AT * CYCLE_MS;
    var success = t >= drawStart && t < revertAt;
    var drawT = Math.min(Math.max((t - drawStart) / DRAW_MS, 0), 1);
    var offset = success ? length * (1 - drawT) : length;
    stroke.style.strokeDashoffset = offset.toFixed(2);
    sheet.style.opacity = success ? '0' : '1';
    check.style.opacity = success ? '1' : '0';
    var done = success && drawT >= 1;
    label.textContent = done ? LABEL_DONE : LABEL_IDLE;
    live.textContent = done ? LABEL_DONE : '';
  }

02Character-count fill — a button that fills by the number of characters copied

The copied sentence's character count is measured once, multiplied by a per-character fill time to get a total fill duration, and that duration drives the progress ratio that fills the button's inner mark before the icon and number briefly rise and settle. It suits copying long notices or license text and quoting blocks.

Character-count fill — a button that fills by the number of characters copied
Open in a new tab

02 · Character-count fill — a button that fills by the number of characters copied — Measured area 2.329% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

setProperty--filltranslateY

What the numbers say

Cumulative area is 2.33% (max frame 2.319%, mean intensity 161.2) across 16 of 23 frame deltas, the highest average intensity of the nine — the fill and bounce both move fast pixel blocks rather than thin lines, so the per-frame delta reads strongly even though the button itself stays compact.

  var CHAR_COUNT = preview.textContent.trim().length;
  var FILL_MS_PER_CHAR = 18;
  var FILL_MS = CHAR_COUNT * FILL_MS_PER_CHAR;
  var BOUNCE_MS = 260;
  var IDLE_MS = 140;

  function bounceEase(p) {
    if (p < 0.5) return p / 0.5;
    var q = (p - 0.5) / 0.5;
    return 1 - q * q;
  }

  function render(ratio, lift) {
    fillBar.style.setProperty('--fill', ratio.toFixed(4));
    count.textContent = String(Math.round(ratio * CHAR_COUNT));
    icon.style.opacity = ratio >= 1 ? '1' : '0';
    var y = -lift * 6;
    var t = 'translateY(' + y.toFixed(2) + 'px)';
    icon.style.transform = t;
    count.style.transform = t;
  }

03Fallback selection — a card that leaves a manual path when the fast one fails

When the fast copy path is unavailable, the card switches to a two-step form: the notice text is pre-selected as a range so a manual route stays open, and the range's measured width is compared against the notice's scrollWidth and the container's width to decide whether the notice fits on one line or has to stack. It suits share cards that hedge old browsers and payment notices that need a safety net.

Fallback selection — a card that leaves a manual path when the fast one fails
Open in a new tab

03 · Fallback selection — a card that leaves a manual path when the fast one fails — Measured area 15.438% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRectcreateRangescrollWidth

What the numbers say

Cumulative area is 15.44% (max frame 14.966%, mean intensity 87.9) across 14 of 23 frame deltas — the spinner's sine phase and the checking-to-fallback state swap both move a meaningful share of the card, well above the site's motion floor.

  function selectRangeWidth() {
    var range = document.createRange();
    range.selectNodeContents(selText);
    var sel = window.getSelection();
    if (sel) {
      sel.removeAllRanges();
      sel.addRange(range);
    }
    var rect = range.getBoundingClientRect();
    return rect.width;
  }

  function layoutFallback() {
    var rangeWidth = selectRangeWidth();
    var noticeWidth = notice.scrollWidth;
    var containerWidth = field.clientWidth;
    var fits = rangeWidth <= containerWidth && noticeWidth <= containerWidth;
    card.classList.toggle('stack', !fits);
    card.classList.toggle('inline', fits);
    return fits;
  }

04Two-button split — a row where primary and secondary share the success state in stagger

On copy success the primary button turns to a check immediately while the secondary button's label change is offset by a stagger recomputed from the real distance between the two buttons, so the primary always transitions first. It suits a share row offering link and code together and invitation screens.

Two-button split — a row where primary and secondary share the success state in stagger
Open in a new tab

04 · Two-button split — a row where primary and secondary share the success state in stagger — Measured area 3.328% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRectaria-pressedclassList.toggle

What the numbers say

Cumulative area is 3.33% (max frame 3.322%, mean intensity 73.0) across 11 of 23 frame deltas — most of the motion sits in the two short icon and label swaps, so the deltas cluster tightly around the stagger boundary rather than spreading evenly across the cycle.

  function measureStagger() {
    var a = primaryBtn.getBoundingClientRect();
    var b = secondaryBtn.getBoundingClientRect();
    var ax = a.left + a.width / 2;
    var bx = b.left + b.width / 2;
    var distance = Math.abs(bx - ax);
    return distance * STAGGER_MS_PER_PX;
  }

  function apply(t, stagger) {
    var success = t >= 0;
    copyIcon.style.opacity = success ? '0' : '1';
    checkIcon.style.opacity = success ? '1' : '0';
    primaryBtn.setAttribute('aria-pressed', success ? 'true' : 'false');
    var secondaryOn = t >= stagger;
    secondaryLabel.textContent = secondaryOn ? SECONDARY_DONE : SECONDARY_TEXT;
    secondaryBtn.classList.toggle('is-done', secondaryOn);
    if (success && secondaryOn && live.textContent !== SECONDARY_DONE) {
      live.textContent = SECONDARY_DONE;
    } else if (!success) {
      live.textContent = '';
    }
  }

05Copy queue history — a list rewriting recent copies as relative time

In a queued list of recent copies, each row's baseline elapsed time plus real elapsed time times a fake clock multiplier is recomputed every frame, and the resulting value is sorted into a bucket that decides both the relative-time label and the dot's color. It suits a scratch panel and screens where several snippets are juggled.

Copy queue history — a list rewriting recent copies as relative time
Open in a new tab

05 · Copy queue history — a list rewriting recent copies as relative time — Measured area 0.401% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

data-basedata-statesetAttribute

What the numbers say

Cumulative area is 0.40% (max frame 0.39%, mean intensity 39.7) across 18 of 23 frame deltas — a history list's dots and labels are simply small on-screen elements, so the smallest cumulative area of the nine is expected, not a defect (for component-style posts the bar is sophistication, not area).

  var TIME_SCALE = 60;
  var JUST_NOW_MS = 15000;
  var A_MINUTE_MS = 120000;

  function bucket(ms) {
    if (ms < JUST_NOW_MS) return 'now';
    if (ms < A_MINUTE_MS) return 'minute';
    return 'old';
  }

  function labelFor(b) {
    if (b === 'now') return 'Just now';
    if (b === 'minute') return '1 minute ago';
    return 'A while ago';
  }

  function apply(elapsedRealMs) {
    items.forEach(function (item) {
      var fake = item.base + elapsedRealMs * TIME_SCALE;
      var b = bucket(fake);
      item.label.textContent = labelFor(b);
      item.dot.setAttribute('data-state', b);
    });
  }

06Fail-recovery — a button interpolating its tint back to normal by elapsed time since failure

On copy failure the button tints to an error shade, then the ratio of time elapsed since that failure divided by a recovery-duration constant is recomputed every frame and used to interpolate the button's RGB back toward the normal color, with the remaining seconds shown alongside. It suits copy buttons in strict admin screens.

Fail-recovery — a button interpolating its tint back to normal by elapsed time since failure
Open in a new tab

06 · Fail-recovery — a button interpolating its tint back to normal by elapsed time since failure — Measured area 4.012% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

backgroundColorclassList.addclassList.remove

What the numbers say

Cumulative area is 4.01% (max frame 3.99%, mean intensity 116.7) across 21 of 23 frame deltas — the RGB interpolation touches the whole button surface on almost every tick, which is why it moves in more of the 23 deltas than all but one of the other eight items.

  var RECOVER_MS = 2400;
  var ERROR_RGB = [194, 54, 26];
  var NORMAL_RGB = [10, 79, 232];

  function lerp(a, b, p) {
    return Math.round(a + (b - a) * p);
  }

  function apply(t) {
    var ratio = Math.min(Math.max(t / RECOVER_MS, 0), 1);
    var r = lerp(ERROR_RGB[0], NORMAL_RGB[0], ratio);
    var g = lerp(ERROR_RGB[1], NORMAL_RGB[1], ratio);
    var b = lerp(ERROR_RGB[2], NORMAL_RGB[2], ratio);
    btn.style.backgroundColor = 'rgb(' + r + ',' + g + ',' + b + ')';
    if (ratio < 1) {
      var remain = ((1 - ratio) * RECOVER_MS / 1000).toFixed(1);
      hint.textContent = 'Send failed · reverting ' + remain + 's';
      hint.classList.remove('is-ok');
    } else {
      hint.textContent = 'You can copy again';
      hint.classList.add('is-ok');
    }
  }

07Contrast swap — an overlaid button recomputing icon color from background brightness

Where the button overlays a changing backdrop, the background's RGB is converted into relative luminance via the gamma-corrected formula 0.2126R + 0.7152G + 0.0722B, and the icon splits into a light-background or dark-background variant depending on whether that value crosses a set threshold. It suits snippet copies over gallery images and link buttons on video thumbnails.

Contrast swap — an overlaid button recomputing icon color from background brightness
Open in a new tab

07 · Contrast swap — an overlaid button recomputing icon color from background brightness — Measured area 42.094% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

relativeLuminancebackgroundColorclassList.toggle

What the numbers say

Cumulative area is 42.09% (max frame 42.06%, mean intensity 29.3, both the widest and lowest-intensity of the nine) across all 23 of 23 frame deltas — the backdrop spans the whole card, so every scene transition registers as pixel movement across nearly the entire frame on every tick.

  function channelLin(c) {
    var v = c / 255;
    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
  }

  function relativeLuminance(rgb) {
    var r = channelLin(rgb[0]);
    var g = channelLin(rgb[1]);
    var b = channelLin(rgb[2]);
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
  }

  function apply(t) {
    var rgb = sceneRgb(t);
    var bg = 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')';
    backdrop.style.backgroundColor = bg;
    var brightness = relativeLuminance(rgb);
    var isLight = brightness >= BRIGHTNESS_THRESHOLD;
    backdrop.classList.toggle('is-light', isLight);
    backdrop.classList.toggle('is-dark', !isLight);
    hint.textContent = 'Brightness ' + brightness.toFixed(2) + ' · ' + (isLight ? 'Light background' : 'Dark background');
  }

08Permission-gate card — a notice mapping permission state and wait time to its tint

When read permission hasn't been granted, the notice becomes a waiting card whose background tint and progress-fill width both change together, recomputed every frame from the ratio of remaining wait time after the permission check. It suits input screens with a paste field and settings pages that need a security notice.

Permission-gate card — a notice mapping permission state and wait time to its tint
Open in a new tab

08 · Permission-gate card — a notice mapping permission state and wait time to its tint — Measured area 29.676% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

setProperty--tintdataset.state

What the numbers say

Cumulative area is 29.68% (max frame 29.331%, mean intensity 86.6) across 19 of 23 frame deltas — the tint and fill share one ratio, so the card's whole surface and its progress bar move on the same ticks rather than drifting out of sync.

  var CHECK_MS = 500;
  var WAIT_MS = 1600;
  var CYCLE_MS = CHECK_MS + WAIT_MS;

  function applyChecking() {
    card.dataset.state = 'checking';
    title.textContent = 'Checking paste permission';
    msg.textContent = 'Checking the permission status';
    fill.style.width = '0%';
    card.style.setProperty('--tint', '0.14');
  }

  function applyWaiting(elapsed) {
    card.dataset.state = 'waiting';
    var remain = Math.max(WAIT_MS - elapsed, 0);
    var ratio = remain / WAIT_MS;
    title.textContent = 'Permission not granted yet';
    msg.textContent = 'Denied · wait remaining ' + (remain / 1000).toFixed(1) + 's';
    fill.style.width = ((1 - ratio) * 100).toFixed(1) + '%';
    card.style.setProperty('--tint', (0.14 + (1 - ratio) * 0.34).toFixed(3));
  }

09Rich-text toast — a notice whose label width recomputes by style-kind count

The style kinds mixed into the copied content — bold, italic, code — are counted straight from the DOM, and that count feeds into the toast's minimum width alongside the label's own measured width, with kind badges attaching one after another on a stagger. It suits editor copy confirmations and carrying email drafts over.

Rich-text toast — a notice whose label width recomputes by style-kind count
Open in a new tab

09 · Rich-text toast — a notice whose label width recomputes by style-kind count — Measured area 10.36% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

querySelectorscrollWidthcreateElement

What the numbers say

Cumulative area is 10.36% (max frame 4.801%, mean intensity 159.7) across only 10 of 23 frame deltas, the fewest of the nine — the badges attach in short bursts and then sit still until the next sample swaps in, so the quiet stretches between bursts are naturally quiet rather than under-animated.

  var SAMPLE_MS = 660;
  var CYCLE_MS = SAMPLE_MS * SAMPLES.length;

  function countKinds() {
    var found = [];
    var tags = ['strong', 'em', 'code'];
    for (var i = 0; i < tags.length; i++) {
      if (preview.querySelector(tags[i])) found.push(tags[i]);
    }
    return found;
  }

  function measureAndSize(count) {
    var labelWidth = toastLabel.scrollWidth;
    var badgeWidth = count * 46;
    var total = labelWidth + badgeWidth + 40;
    toast.style.minWidth = total + 'px';
  }

Where values reshape the screen — character count, elapsed time, contrast

Why the fill duration is computed from the string, not fixed

Item 02's FILL_MS is CHAR_COUNT * FILL_MS_PER_CHAR, so a two-word snippet and a full paragraph fill at the same per-character rate but over a different total duration — hard-coding the duration instead would make every copy feel identical regardless of how much text actually moved.

Why the relative-time label needs a fake clock multiplier instead of true elapsed time

A demo capture only runs for a few seconds, so item 05 multiplies real elapsed time by TIME_SCALE (60) on top of each row's own baseline (0, 50000, or 170000ms) to compress what would be minutes of real waiting into a watchable cycle — the bucket logic itself (JUST_NOW_MS at 15000, A_MINUTE_MS at 120000) is exactly what a real clipboard history would use against true elapsed time.

Why contrast swap reads brightness instead of hard-coding two icon colors

Item 07's BRIGHTNESS_THRESHOLD sits at 0.55 against a relative luminance computed from gamma-corrected channels, so the same icon logic keeps working no matter what backdrop scrolls underneath it — a fixed icon color would only be correct for whichever single background someone happened to test against.

Where it breaks — permission denied, rapid copies, focus moves away

What a second click does before the character-count fill finishes

Clicking item 02's button mid-fill cancels the running autoplay loop, calls render(0, 0) to snap the fill back to empty, and starts a fresh userLoop from that instant — the fill always restarts from zero on a new click rather than adding on top of whatever ratio was already showing.

What clicking retry actually changes in the permission-gate card

Item 08's retry button cancels the autoplay loop and replays the exact same CHECK_MS plus WAIT_MS timeline from t = 0 via setPhase(t), then calls setPhase(0) once the cycle completes — the click restarts the same fixed sequence, it doesn't grant permission or shorten the wait, which is the honest way to represent a permission that's still denied. The full nine-screen implementation, both vanilla and React, sits behind fjdfnyzg in this post's zip.

What happens when the rich-text toast is clicked mid-autoplay

Item 09's click handler resets lastSample and calls showToast(0) immediately, but it never cancels the running autoLoop — so a click forces sample 0 onto the screen right away, and the autoplay's own schedule can still swap in a different sample shortly after, rather than the click permanently taking over.

Three variants and accessibility

Variant What changes What it trades off
Fail-recovery duration Item 06's RECOVER_MS from 2400ms to 5200ms At 2400ms the error color fades back before it fully registers, so the failure is easy to miss; at 5200ms the error state lingers so long an already-recovered button still reads as failed
Character-count fill speed Item 02's FILL_MS_PER_CHAR from 18ms to 42ms At 18ms per character the fill completes in the blink of an eye, so the sense of how much got copied doesn't land; at 42ms the fill itself reads as a rhythm
Contrast-swap threshold Item 07's BRIGHTNESS_THRESHOLD from 0.55 to 0.72 At 0.55 mid-brightness backgrounds still pick the light-icon variant, which can feel slightly off over gray tones; raising it to 0.72 picks the dark variant more often, staying stable even over light backgrounds

This zip ships the 2400ms, 18ms, and 0.55 versions.

What reduced motion turns off, item by item

prefers-reduced-motion: reduce is checked once via matchMedia in every one of the nine files, and only the repeating auto-loop is skipped — the computed result at that moment still paints once. Item 01's stroke offset, item 02's fill ratio, item 03's layout choice, item 04's stagger, item 05's relative-time buckets, item 06's color interpolation, item 07's icon-color split, item 08's card tint, and item 09's toast width all still run their arithmetic and still show its result; only the cycling stops.

FAQ

Do any of these buttons call the real Clipboard API?

No — none of these nine calls the real Clipboard API; look for navigator.clipboard.writeText in any of them and you won't find a working copy to clipboard button, only a virtual state machine standing in for one, with a comment marking exactly where the real call would go in production.

Which of the nine respond to an actual click, rather than just autoplaying?

Only 02, 03, 04, 08, and 09 carry a real click listener; 01, 05, 06, and 07 run purely on requestAnimationFrame cycling on a fixed period with zero click, drag, or pointer listeners attached, so their success or failure states cycle on their own on a timer.

Is any of this a copy button code generator I can paste a real string into?

No — every item's data (string length, baseline time, RGB values, and so on) is a fixed value written into named constants, so the same scene reproduces on every run; none of the nine acts as a generator that outputs a working copy routine for whatever text you hand it.

Sources: MDN — Clipboard.writeText · MDN — aria-live · MDN — prefers-reduced-motion

Where this set differs from a button that folds a single copy-confirmation state into a longer loading-to-done range is scope: that post treats copy as one state among many a button cycles through, while this one is nine screens built entirely around the act of copying itself. The one real navigator.clipboard.writeText call this set only leaves a comment for is exactly what fires in a share row's own link-copy toast, which confirms success with a plain timed toast instead of a second button's label swap.

Enter the archive password

The password is inside this article. You will find it as you read.