デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
コード欄の右下に、文字とアイコンだけの小さなボタンを置きます。文字は「コピー」「コピー」「コピーしました」の 3 行を縦に積み、升目を 1 行ぶんずつ下げて見せます。カーソルが重なると 1 行、押すと 2 行ぶん下がるので、同じ「コピー」がもう一度回って現れ、次に「コピーしました」に変わります。
アイコンは 2 枚の紙です。カーソルが重なると、奥の紙が左上へ、手前の紙が右下へ 1px ずつ離れます。押すと 2 枚は 0.18 秒で消え、入れ替わりにチェックが描かれます。チェックの線は pathLength を 100 にそろえてあるので、線の実際の長さに関わらず、破線の位置を 104 から 0 へ動かすだけで描き切れます。1 にそろえると値が小さすぎて、描かれる動きが段づきになります。
紙が消えるのを待ってから、0.12 秒後にチェックを描き始めます。同時に始めると、消えかけの紙とチェックが重なって、形が読み取れません。押した後の見た目は 1.6 秒ほど保ってから、ひとりでに戻します。
動きは CSS の切り替えだけで作れます。状態は is-copied を付け外しするだけなので、コピーの成否をそのまま見た目にできます。結果は見た目だけでは伝わらないので、別に置いた role="status" の要素にも文を入れて読み上げさせます。
このデモは、実寸(文字 13px・アイコン 14px)を 2 倍に拡大して見せています。アイコンだけを丸く塗って知らせるものと違い、文字で結果を伝えるので、コードや文章が並ぶ場所になじみます。ドキュメントのコード欄・API キー・招待リンク・クーポンコードのコピーに向いています。
コード
<figure class="code-block">
<figcaption class="code-block__lang">HTML</figcaption>
<pre><code><a class="button" href="/contact/">
<span class="button__label">お問い合わせ</span>
</a></code></pre>
<button class="copy" type="button" aria-label="HTML のコードをコピー">
<!-- 3 行とも読み上げの名前に混ざるので、ラベルは aria-hidden にして aria-label で名前を決める -->
<span class="copy__label" aria-hidden="true">
<span class="copy__rows">
<span>コピー</span>
<span>コピー</span>
<span class="copy__done">コピーしました</span>
</span>
</span>
<svg class="copy__icon" viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
<path class="copy__sheet copy__back" d="M3 9.5H2A1.5 1.5 0 0 1 .5 8V2A1.5 1.5 0 0 1 2 .5h6A1.5 1.5 0 0 1 9.5 2v1"/>
<rect class="copy__sheet copy__front" x="4.5" y="4.5" width="9" height="9" rx="1.5"/>
<!-- pathLength="100" にしておくと、線の実長に関わらず 104 → 0 で描き切れる -->
<path class="copy__tick" d="M2.5 7.5l3 3 6-7" pathLength="100"/>
</svg>
</button>
<!-- コピーの結果は、見た目だけでは伝わらないので読み上げにも流す -->
<span class="copy__status" role="status" aria-live="polite"></span>
</figure>
.code-block {
--ink: #eeece6; /* コードの文字と、押した後のボタンの色 */
--dim: rgba(238, 236, 230, 0.45); /* ふだんのボタンの色 */
position: relative; /* コピーのボタンは、この箱の右下に置く */
margin: 0;
border-radius: 6px;
background: #262626;
overflow: hidden;
}
.code-block__lang { position: absolute; right: 20px; top: 14px; color: var(--dim); font-size: 12px; letter-spacing: 0.14em; }
/* 下の余白を広めに取り、その中にボタンを置く(コードにかぶらない) */
.code-block pre { margin: 0; padding: 36px 24px 40px; overflow-x: auto; color: var(--ink); font: 14px/1.9 Inconsolata, ui-monospace, monospace; }
.copy {
position: absolute;
right: 20px;
bottom: 14px;
display: inline-flex;
align-items: center;
gap: 7px;
height: 20px;
margin: 0;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 13px; /* font: inherit の後に書く(先だと打ち消される) */
line-height: 18px;
color: var(--dim);
cursor: pointer;
transition: color 0.2s linear;
}
.copy:hover,
.copy.is-copied { color: var(--ink); }
/* 色だけだと手がかりが弱いので、キーボードのときは枠も出す */
.copy:focus-visible {
color: var(--ink);
outline: 2px solid currentColor;
outline-offset: 4px;
}
/* 文字は 3 行を縦に積み、升目をずらして見せる(ふだん → ホバー → コピー後) */
.copy__label { display: block; height: 18px; overflow: hidden; }
.copy__rows > span { height: 18px; white-space: nowrap; }
.copy__rows {
display: flex;
flex-direction: column;
align-items: flex-end;
transition: translate 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
.copy:hover .copy__rows,
.copy:focus-visible .copy__rows { translate: 0 -18px; }
.copy.is-copied .copy__rows { translate: 0 -36px; }
/* overflow: visible にしないと、離れた紙の角が切れる */
.copy__icon { flex: none; overflow: visible; fill: none; stroke: currentColor; stroke-width: 1; }
.copy__sheet { transition: translate 0.42s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.18s linear; }
.copy:hover .copy__back,
.copy:focus-visible .copy__back { translate: -1px -1px; }
.copy:hover .copy__front,
.copy:focus-visible .copy__front { translate: 1px 1px; }
.copy.is-copied .copy__sheet { opacity: 0; translate: 0 0; }
/* 実線 102・空き 200。pathLength="100" で長さをそろえてあるので、104 → 0 で描き切れる */
.copy__tick {
stroke-dasharray: 102 200;
stroke-dashoffset: 104;
transition: stroke-dashoffset 0.24s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 紙が消えきってから描き始める(0.12 秒待つ) */
.copy.is-copied .copy__tick {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.4s cubic-bezier(0.4, 0, 0.2, 1) 0.12s;
}
.copy__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media (prefers-reduced-motion: reduce) {
.copy,
.copy__rows,
.copy__sheet,
.copy__tick { transition: none; }
}
const COPIED_MS = 1600; // 「コピーしました」を見せておく時間
// https でない所(社内の検証環境など)では navigator.clipboard が無い。その場合の控え
function legacyCopy(text) {
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.cssText = "position:fixed;top:0;left:0;opacity:0;pointer-events:none";
document.body.append(area);
area.select();
area.setSelectionRange(0, text.length); // iOS は select() だけでは選ばれない
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
area.remove();
return ok;
}
async function copyText(text) {
if (!navigator.clipboard) return legacyCopy(text);
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return legacyCopy(text); // 許可が下りなかったときも控えを試す
}
}
document.querySelectorAll(".code-block").forEach((block) => {
const button = block.querySelector(".copy");
const code = block.querySelector("code");
const status = block.querySelector(".copy__status");
const done = button.querySelector(".copy__done");
let timer = 0;
button.addEventListener("click", async () => {
// 行番号や言語の札を擬似要素で出しておけば、textContent はコードだけになる
const ok = await copyText(code.textContent);
done.textContent = ok ? "コピーしました" : "コピーできません";
status.textContent = ok ? "コードをコピーしました" : "コピーできませんでした";
button.classList.add("is-copied");
clearTimeout(timer);
timer = setTimeout(() => {
button.classList.remove("is-copied");
status.textContent = "";
}, COPIED_MS);
});
});