GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

コピー ボタン html UI 9選 — コード・URL・テキストのフィードバックまで

「コピー ボタン html」9選 — 9つのうち1つはコピー結果を2つのボタンが分けて持ち、1つはコピーした文字数だけ表示を埋め、もう1つは失敗したコピーの復帰時間を自ら計ります。

「コピー ボタン html」で検索すると装飾だけを整えたボタン集がまず出てきますが、この記事が示すのはその装飾の手前で動く計算の層です。実際のnavigator.clipboard.writeText・readTextの呼び出しはどこにもなく、コピーの成功・失敗・権限の状態は名前付き定数で定めた仮のクリップボード状態機械だけで表現しています(実サービスに組み込む際はその状態遷移の場所に実際のwriteText呼び出しを入れるという案内コメントを残しています)。9項目のうち01・05・06・07はrequestAnimationFrameの固定周期による自動再生だけで実演し、02・03・04・08・09の5項目だけに実際のクリックリスナーが付いています。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

デモ

9項目とも「見た目の演出そのものではなく、線の実測長・コピーした文字数・選択範囲の実測幅・ボタン間の距離・基準経過時間・失敗後の経過時間・背景の相対輝度・残り待ち時間の比率・書式の種類数という値を毎フレーム求め直し、ストロークの描画量・充填比率・レイアウト・時間差・相対時刻ラベル・色の補間・アイコンの配色・カードの濃度・トーストの幅に反映する」という同じ前提を共有しています。実測値は次のとおりです。

番号 項目 累積面積 強度平均 動いたフレーム
01 アイコンモーフィックチェック 1.29% 144.5 13/23
02 文字数フィル 2.33% 161.2 16/23
03 フォールバック選択 15.44% 87.9 14/23
04 2ボタン分割 3.33% 73.0 11/23
05 キューヒストリー 0.40% 39.7 18/23
06 失敗の回復 4.01% 116.7 21/23
07 コントラストスワップ 42.09% 29.3 23/23
08 権限ゲート 29.68% 86.6 19/23
09 リッチテキストトースト 10.36% 159.7 10/23

累積面積は07コントラストスワップが42.09%でもっとも広く、08権限ゲート29.68%・03フォールバック選択15.44%・09リッチテキストトースト10.36%・06失敗の回復4.01%・04 2ボタン分割3.33%・02文字数フィル2.33%・01アイコンモーフィック1.29%・05キューヒストリー0.40%の順です。01アイコンモーフィックと05キューヒストリーの面積が9項目中もっとも小さいのは、チェックの線やヒストリーの点・ラベルのように画面上で実際に占めるピクセルの場所自体が小さい要素だからで、欠陥ではありません。24フレームキャプチャ(デルタ23個)のうち動いたフレームは10(09)〜23(07)の間にあり、強度平均は29.3(07、最も低い)〜161.2(02、最も高い)の間で、9項目とも画面が動き続ける下限値に十分な余裕を持って収まっています。

コピーボタンの解剖 — 9つの画面が計算するもの

コピーボタンは押された瞬間に成功・失敗のどちらかを表示するだけの部品に見えますが、この9項目はその表示に至るまでの値の求め直しを見せています。チェックの線の長さ・コピーした文字数・選択範囲の幅・ボタン間の距離・経過時間・背景の明るさ・待ち時間の比率・書式の種類数という8種類の値が、毎フレームあるいは押された瞬間に読み直され、描画・充填・レイアウト・時間差・色・濃度・幅という画面上の変化に変換されています。

入力と出力 — 何を計算して何に変えるか

入力はSVGポリラインの実測長(getTotalLength)、コピー対象の文字数、Range.getBoundingClientRectで測った選択範囲の幅、2つのボタンの中心間距離、基準経過時間に仮の時計倍速を掛けた値、失敗してからの経過時間、背景RGBのガンマ補正済み相対輝度、権限確認後の残り待ち時間の比率、DOMから数えた書式タグの種類数という9種類です。出力はSVGのstrokeDashoffset、CSSカスタムプロパティ--fill・--tint、classListの切り替え、aria-pressed・data-state・dataset.stateといった属性、要素のtextContentとインラインスタイルの幅で、キーフレームアニメーションは一つも使っていません。

01・05・06・07は自動再生、02・03・04・08・09に実際のリスナー

9項目のうち01(アイコンモーフィックチェック)・05(キューヒストリー)・06(失敗の回復)・07(コントラストスワップ)はrequestAnimationFrameの固定周期による自動再生だけで実演されており、実際のクリック・ドラッグ・ポインターのリスナーは一つも付いていません。02(文字数フィル)・03(フォールバック選択)・04(2ボタン分割)・08(権限ゲート)・09(リッチテキストトースト)の5項目だけに実際のクリックリスナーが付いていて、押された瞬間を基準に同じ計算式が最初から再生し直されます。

仮のクリップボード状態機械とは何か

このデモはどの項目もnavigator.clipboard.writeText・readText・Permissions APIを一切呼び出しません。コピーの成功・失敗・権限の状態は名前付き定数で定めた仮の状態機械だけで進み、実サービスに組み込む場合はその状態遷移が起きる場所に実際のwriteText呼び出しを1行差し込むという案内コメントだけをソースに残しています。すべての項目のデータ(文字列の長さ・基準時刻など)は固定値なので、再生のたびに同じ場面が再現され、ランダムな要素はありません。

画面別コード

「html コード コピー ボタン」を探す人の多くはコードブロック右上に置く1個のボタンを求めていますが、この9項目はコード・URL・テキスト・書式付き文章という異なるコピー対象ごとに、実測すべき値も変わることを示しています。項目名にある「モーフィック」「フォールバック」「トースト」という言葉は見た目の系統を指す説明的な参照で、特定のライブラリのコンポーネント名を指すものではありません。似た計算の考え方は、押した瞬間の状態遷移を扱うcssボタン 押したら変わる9選の状態管理にも共通しています。

01アイコンモーフィックチェック — 成功を1本の線で描いて見せるボタン

コピーが成功した状態になると、アイコンが書類からチェックへ切り替わります。このときgetTotalLengthでチェックのポリラインの実際の長さを測ってstrokeDasharrayとして固定し、経過比率だけstrokeDashoffsetを0まで求め直して線を描きます。コード・インストールコマンドの右上に置く定番の位置を想定しています。

アイコンモーフィックチェック — 成功を1本の線で描いて見せるボタン
新しいタブで開く

01 · アイコンモーフィックチェック — 成功を1本の線で描いて見せるボタン — 計測 累積面積 1.288% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

strokeDashoffsetgetTotalLengthrequestAnimationFrame

getTotalLengthで測った線の長さがstrokeDashoffsetを求め直す

CYCLE_MS2000msの周期のうちTRIGGER_AT0.08の位置で成功状態が始まり、DRAW_MS640msの間だけdrawTが0から1へ進んでoffset = length * (1 - drawT)が0へ向かい、REVERT_AT0.62の位置で書類アイコンへ戻ります。線の長さを実測せず固定のdasharray値を使うと、アイコンのサイズが変わったときに線がずれたり途中で切れて描かれたりします。

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

  var LABEL_IDLE = 'コードをコピー';
  var LABEL_DONE = 'コピー済み';
  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 : '';
  }

02文字数フィル — コピーした文字数だけ表示が満ちるボタン

コピーされた文の文字数を数え、その値をボタン内進行表示の充填比率に変え、充填が終わると数字がアイコンとともに一瞬浮かんで戻ります。長い案内文やライセンス全文のコピー、引用の持ち帰りのような、量そのものを伝えたい場面を想定しています。

文字数フィル — コピーした文字数だけ表示が満ちるボタン
新しいタブで開く

02 · 文字数フィル — コピーした文字数だけ表示が満ちるボタン — 計測 累積面積 2.329% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

setProperty--filltranslateY

文字数×FILL_MS_PER_CHARが充填の合計時間を求め直す

コピー対象の文字数(preview.textContent.trim().length)をCHAR_COUNTとして数え、1文字あたりの充填時間の定数FILL_MS_PER_CHAR18msを掛けたFILL_MSの中で進行比率t / FILL_MSを求め直し、--fillカスタムプロパティに反映します。文字数を数えず充填時間を固定値のままにすると、コピー量による体感差が消えてこのフィル型の存在意義がなくなります。

  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;
  }

03フォールバック選択 — 速い道が失敗した時に手動の道を残すカード

高速なコピー経路が開けない状態になると、カードが2段階に切り替わり、案内文をあらかじめ範囲選択して手動の道を残します。範囲の幅を測り、案内文が1行に収まるかを計算します。古いブラウザに備えた共有カードや、保険が必要な決済案内での利用を想定しています。

フォールバック選択 — 速い道が失敗した時に手動の道を残すカード
新しいタブで開く

03 · フォールバック選択 — 速い道が失敗した時に手動の道を残すカード — 計測 累積面積 15.438% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRectcreateRangescrollWidth

Rangeの実測幅とscrollWidthがinline・stackのレイアウトを選ぶ

Range.getBoundingClientRectで実際の選択範囲の幅を、notice.scrollWidthで案内文の幅をそれぞれ実測し、コンテナ幅(field.clientWidth)と比較したfits = rangeWidth <= containerWidth && noticeWidth <= containerWidthが真のときだけ1行(inline)のレイアウトを選び、偽なら2行(stack)に切り替えます。選択範囲・案内文の幅を実測せず常に1行のレイアウトにすると、狭い画面で案内文が切れたりボタンと重なったりします。

  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;
  }

042ボタン分割 — 成功状態を主と補助が時間差で分け持つ行

コピーが成功した状態になると、主ボタンはチェックに、補助ボタンは案内文に、決めた時間差だけずれて切り替わり、時間差の値は2つのボタン間の距離から改めて求められます。リンクとコードを並べて渡す共有行や、招待画面での利用を想定しています。

2ボタン分割 — 成功状態を主と補助が時間差で分け持つ行
新しいタブで開く

04 · 2ボタン分割 — 成功状態を主と補助が時間差で分け持つ行 — 計測 累積面積 3.328% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRectaria-pressedclassList.toggle

getBoundingClientRectで測った距離×比例定数が時間差を求め直す

2つのボタンの中心座標をgetBoundingClientRectでそれぞれ実測し、その距離(px)に比例定数STAGGER_MS_PER_PX0.9を掛けた値をstaggerとして、主ボタン→補助ボタンの順に状態遷移へ適用します。2つのボタン間の距離を測らず固定の遅延で状態を変えると、ボタンの配置が変わったときに時間差が不自然になり、分割の意味が失われます。

  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 = '';
    }
  }

05キューヒストリー — 最近のコピーの時間差を相対時刻で書き直すリスト

最近のコピーが並ぶリストで、今から各コピーまでの時間差を測り続け、「たった今・1分前・しばらく前」のラベルが時間の経過だけで自動更新されます。一時保存パネルや、複数スニペットを行き来する作業画面での利用を想定しています。

キューヒストリー — 最近のコピーの時間差を相対時刻で書き直すリスト
新しいタブで開く

05 · キューヒストリー — 最近のコピーの時間差を相対時刻で書き直すリスト — 計測 累積面積 0.401% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

data-basedata-statesetAttribute

基準経過時間(data-base)×時計倍速が相対時刻ラベルを求め直す

3つの項目はそれぞれdata-base属性に0・50000・170000msという基準経過時間を持ち、実時間の経過に仮の時計倍速TIME_SCALE60を掛けた値を足したfakeを毎フレーム求め直し、JUST_NOW_MS15000ms未満なら「たった今」、A_MINUTE_MS120000ms未満なら「1分前」、それ以上なら「しばらく前」というラベルと点の色に変換します。実時間の経過を測らず相対時刻ラベルを固定文字列のままにすると、時間が経ってもラベルが「たった今」のまま止まり、ヒストリーの意味が失われます。

  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 'たった今';
    if (b === 'minute') return '1分前';
    return 'しばらく前';
  }

  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);
    });
  }

06失敗の回復 — 失敗後の経過時間を数えて濃度を元へ補間するボタン

コピーが失敗した状態になるとボタンが失敗色に染まり、失敗後の経過時間を基準に濃度を元の値まで少しずつ補間し、時間とともに自ら回復します。セキュリティが厳しい管理画面のコピーボタンでの利用を想定しています。

失敗の回復 — 失敗後の経過時間を数えて濃度を元へ補間するボタン
新しいタブで開く

06 · 失敗の回復 — 失敗後の経過時間を数えて濃度を元へ補間するボタン — 計測 累積面積 4.012% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

backgroundColorclassList.addclassList.remove

RECOVER_MSで割った比率がエラー色から元の色へのRGB補間比率になる

失敗した時点から流れた時間を回復時間の定数RECOVER_MS2400msで割った比率(0〜1に丸め込み)を毎フレーム求め直し、その比率だけERROR_RGB(194,54,26)からNORMAL_RGB(10,79,232)までチャンネルごとにRGBを補間してbackgroundColorに反映します。失敗後の経過時間を測らずエラー色をすぐ元に戻すと、利用者が失敗そのものを見逃しやすくなります。

  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 = '送信失敗 · 復元中 ' + remain + '秒';
      hint.classList.remove('is-ok');
    } else {
      hint.textContent = 'もう一度コピーできます';
      hint.classList.add('is-ok');
    }
  }

07コントラストスワップ — 背景の明るさに合わせてアイコン色を求め直す重ねボタン

ボタンが画像や動画の上に重なる時、下の背景の明るさ値を読み、明るければ暗いアイコン・暗ければ明るいアイコンに分けて、輪郭のコントラストを一定以上に保ちます。ギャラリー画像上のスニペットコピーや、動画サムネイル上のリンクボタンでの利用を想定しています。

コントラストスワップ — 背景の明るさに合わせてアイコン色を求め直す重ねボタン
新しいタブで開く

07 · コントラストスワップ — 背景の明るさに合わせてアイコン色を求め直す重ねボタン — 計測 累積面積 42.094% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

relativeLuminancebackgroundColorclassList.toggle

relativeLuminanceの式とBRIGHTNESS_THRESHOLDがアイコンの明暗を分ける

背景のRGB値をチャンネルごとにガンマ補正した相対輝度の式0.2126*R + 0.7152*G + 0.0722*B(relativeLuminance)に変換し、その値が閾値BRIGHTNESS_THRESHOLD0.55を超えるかどうかでアイコンを明るい背景用・暗い背景用に分けます。背景の明るさを計算せずアイコンの色を固定すると、背景が変わったときにコントラストが崩れ、アイコンが見えなくなることがあります。

  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.toFixed(2) + ' · ' + (isLight ? '明るい背景' : '暗い背景');
  }

08権限ゲートカード — 権限の状態と残り待ち時間を濃度に乗せる案内板

読み取り権限を得ていない状態になると、案内板が待機カードに変わり、権限の状態値と残り待ち時間の比率だけ濃度と案内文を変えます。貼り付け欄のある入力画面や、セキュリティ案内が必要な設定ページでの利用を想定しています。

権限ゲートカード — 権限の状態と残り待ち時間を濃度に乗せる案内板
新しいタブで開く

08 · 権限ゲートカード — 権限の状態と残り待ち時間を濃度に乗せる案内板 — 計測 累積面積 29.676% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

setProperty--tintdataset.state

remain/WAIT_MSの比率がカードの--tintと進行表示の幅を一緒に動かす

権限確認後の残り待ち時間の比率remain / WAIT_MS(WAIT_MS1600ms)を毎フレーム求め直し、tint = 0.14 + (1 - ratio) * 0.34でカードの背景濃度(--tint)を、(1 - ratio) * 100で進行表示の幅を同時に変えます。権限が確認中のCHECK_MS500msの間はこの計算に入らず、待機カードの表示だけになります。

  function applyWaiting(elapsed) {
    card.dataset.state = 'waiting';
    var remain = Math.max(WAIT_MS - elapsed, 0);
    var ratio = remain / WAIT_MS;
    title.textContent = '権限がまだありません';
    msg.textContent = '拒否済み · 残り待機 ' + (remain / 1000).toFixed(1) + '秒';
    fill.style.width = ((1 - ratio) * 100).toFixed(1) + '%';
    card.style.setProperty('--tint', (0.14 + (1 - ratio) * 0.34).toFixed(3));
  }

  function setPhase(t) {
    if (t < CHECK_MS) {
      applyChecking();
    } else {
      applyWaiting(t - CHECK_MS);
    }
  }

09リッチテキストトースト — 書式の種類数だけラベル幅を計算し直す通知

コピーされた内容に混ざる書式の種類(太字・斜体・コードなど)を数え、その数だけトーストのラベル幅を計算し直して広げ、種類バッジが順に並びます。エディタの書式コピー確認や、メール下書きの持ち運びでの利用を想定しています。

リッチテキストトースト — 書式の種類数だけラベル幅を計算し直す通知
新しいタブで開く

09 · リッチテキストトースト — 書式の種類数だけラベル幅を計算し直す通知 — 計測 累積面積 10.36% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

querySelectorscrollWidthcreateElement

DOMから数えた書式の種類数がトーストのmin-widthを求め直す

コピー対象の中に実際に存在する書式タグの種類数(strong・em・code)をquerySelectorでDOMから数え、ラベルの実測幅(toastLabel.scrollWidth)と種類数×バッジ幅46pxを足したtotal = labelWidth + count * 46 + 40でトースト全体の幅を求め直します。書式の種類数を数えずトーストの幅を固定値のままにすると、書式が増えたときにバッジが切れたり重なったりします。

  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';
  }

数値が画面を変える場所 — 文字数・経過時間・コントラスト

9項目の計算は見た目こそ違いますが、値を実測して比率に変え、その比率をスタイルへ書き戻すという同じ形をしています。「テキスト コピー ボタン」を実装するときも、この3種類の値がどこで画面を動かしているかを押さえておくと調整がしやすくなります。

文字数が充填時間に変わる場所

02文字数フィルはCHAR_COUNT * FILL_MS_PER_CHAR(1文字18ms)で充填の合計時間FILL_MSを求め、進行比率t / FILL_MSを--fillに反映します。09リッチテキストトーストも同じ「数えた個数を幅や時間に変える」考え方で、書式の種類数をcount * 46のバッジ幅に変えています。

経過時間が色・濃度・ラベルに変わる場所

06失敗の回復は失敗後の経過時間をRECOVER_MS2400msで割った比率でRGBを補間し、08権限ゲートは経過時間をWAIT_MS1600msで割った比率でカードの--tintを、05キューヒストリーは経過時間にTIME_SCALE60を掛けた仮の時計で相対時刻ラベルを、それぞれ求め直しています。3項目とも実時間そのものではなく、実時間から求め直した比率や仮の経過時間をスタイルに渡している点が共通しています。

背景の明るさがアイコン色に変わる場所

07コントラストスワップは背景RGBをガンマ補正した相対輝度の式0.2126*R + 0.7152*G + 0.0722*Bに変換し、閾値BRIGHTNESS_THRESHOLD0.55と比較してアイコンの明暗を分けます。背景が画像や動画のように変わり続ける場面では、この式を毎フレーム呼び直すことでアイコンの色も自動で追従します。

どこで壊れるか — 権限拒否・連続コピー・フォーカス移動

「html url コピー ボタン」を共有リンクに使う場面では、権限が拒否されたままの状態や、連続してコピーされる操作、コピー中にフォーカスが移動する状況が実際に起こります。

権限が拒否されたままだとどうなるか

08権限ゲートは権限が拒否された状態が続く限り、remain / WAIT_MSの比率を毎フレーム求め直して待機カードの濃度と進行表示を動かし続けます。WAIT_MS1600msが経過して比率が0になった後は、実サービスであれば権限を再要求する操作へ案内文を切り替える設計です。

連続して複数回コピーするとどうなるか

01アイコンモーフィックチェックと06失敗の回復はどちらも状態が確定するまでのDRAW_MS640ms・RECOVER_MS2400msという幅を持っているので、その幅の途中でもう一度同じ状態が呼ばれると、tの基準点が置き直されて計算がその時点からやり直されます。04 2ボタン分割はstaggerをボタン間の距離から毎回測り直すため、レイアウトが変わった直後に連続でコピーしても時間差の値が古いままになりません。

フォーカスが移動した状態でコピーするとどうなるか

03フォールバック選択はRange.getBoundingClientRectで選択範囲の幅を実測してレイアウトを選ぶため、フォーカスが別の要素へ移動して選択が解除されると幅の実測値が0に近づき、fitsの判定が変わることがあります。実サービスに組み込む際は、選択が解除された状態も含めてlayoutFallbackを呼び直す設計にしておく必要があります。

3つのバリエーションとアクセシビリティ

9種のコピーボタンUIはすべてfjdfnyzgで開くzipにまとめられており、vanillaフォルダには9項目それぞれのindex.htmlが、reactフォルダには同じ計算をコンポーネント化したtsxファイルが入っています。

名前 値 感じ方
失敗の回復時間 06 RECOVER_MS 2400ms → 5200ms 2400msに短くすると、失敗色が消える前に元の色へ戻ってしまい失敗を見逃しやすく、5200msに延ばすと、すでに回復したボタンがまだ失敗中に見えます
文字数フィルの速度 02 FILL_MS_PER_CHAR 18ms → 42ms 18msだと進行表示が一瞬で満ちてしまい文字数が多いという感覚が伝わらず、42msに延ばすと充填自体が一つのリズムとして見え、コピー量が目に入ります
コントラストスワップの閾値 07 BRIGHTNESS_THRESHOLD 0.55 → 0.72 0.55だと中間の明るさの背景でも明るい側のアイコンが選ばれて灰色トーンの上でわずかに違和感が出て、0.72に上げると暗い側が多く選ばれ、明るい背景の上でも安定して見えます

prefers-reduced-motionをどう扱うか

9項目ともwindow.matchMedia('(prefers-reduced-motion: reduce)')の結果を検知し、01・05・06・07の繰り返し再生(自動ループ)だけを止めます。オフセット・充填比率・レイアウト・時間差・相対時刻・色の補間・アイコン色・カード濃度・トースト幅というその時点の計算結果はそのまま画面に残り、値の計算そのものを消すことはありません。

なぜ実際のclipboard APIを呼ばないのか

実際のnavigator.clipboard.writeText・readText・Permissions APIの呼び出しが一つでも入ると、このデモは特定のブラウザ・特定の権限状態への対応を検証する別の性質のものに変わってしまいます。9項目が見せたいのは状態遷移の計算そのものなので、コピーの成功・失敗・権限の状態は名前付き定数で定めた仮の状態機械だけで表現し、実際のAPI呼び出しを差し込む場所だけを案内コメントとして残しています。

FAQ

このデモは実際にクリップボードにコピーしていますか

いいえ。9項目とも実際のnavigator.clipboard.writeText・readTextの呼び出しはどこにもなく、コピーの成功・失敗は名前付き定数で定めた仮のクリップボード状態機械で代用しています。実サービスに組み込む際は、その状態遷移が起きる場所に実際のwriteText呼び出しを1行差し込むという案内コメントだけをソースに残しています。

09リッチテキストトーストの書式判定はどんな仕組みですか

コピー対象のDOMをquerySelectorで調べ、strong・em・codeという3種類のタグが実際に存在するかどうかを数えているだけで、任意の書式を解析する汎用パーサーではありません。見つかった種類数がそのままラベル幅の計算とバッジの数に反映されます。

html クリップボード コピー ボタンのような実装にこの9種をそのまま使えますか

この9種はクリップボードへの実際の書き込みそのものではなく、コピー前後で動く計算層(線の実測・文字数・選択幅・距離・経過時間・明るさ・待ち時間の比率・書式の種類数)を再現したものです。html・css・jsの3つのタブに分けたコードをそのまま自分のページに貼り付け、案内コメントの場所に実際のwriteText呼び出しを差し込む使い方に向いています。

もっと読む

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。