01アイコンモーフィックチェック — 成功を1本の線で描いて見せるボタン
コピーが成功した状態になると、アイコンが書類からチェックへ切り替わり、チェックの線を端から端まで長さで測って決めた時間内に描く計算で動き、時間が経つと成功状態が周期的に自ら再現されます。
使いどころコードブロック右上のコピーボタン、インストールコマンド枠
strokeDashoffsetgetTotalLengthrequestAnimationFrame
<main class="stage">
<section class="panel">
<button class="copyBtn" id="copyBtn" type="button" aria-pressed="false" aria-label="コードをコピー">
<span class="iconSlot" id="iconSlot">
<svg class="icon icon--sheet" id="sheetIcon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>
<svg class="icon icon--check" id="checkIcon" viewBox="0 0 24 24" aria-hidden="true">
<polyline id="checkStroke" points="4,13 9.5,18.5 20,6"></polyline>
</svg>
</span>
<span class="label" id="label">コードをコピー</span>
</button>
<span class="sr" id="live" role="status" aria-live="polite"></span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.copyBtn {
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-5;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 15px;
line-height: 1.3;
cursor: pointer;
box-shadow: $shadow-raised;
}
.iconSlot {
position: relative;
width: 22px;
height: 22px;
flex: none;
}
.icon {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.icon--sheet {
color: $subject-cream;
}
.icon--check {
opacity: 0;
fill: none;
stroke: $subject-cream;
stroke-width: 2.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.label {
white-space: nowrap;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.copyBtn {
padding: $sp-2 $sp-4;
font-size: 13px;
}
.iconSlot {
width: 18px;
height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.copyBtn * {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.copyBtn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 15px;
line-height: 1.3;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.iconSlot {
position: relative;
width: 22px;
height: 22px;
flex: none;
}
.icon {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.icon--sheet {
color: #fff7e6;
}
.icon--check {
opacity: 0;
fill: none;
stroke: #fff7e6;
stroke-width: 2.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.label {
white-space: nowrap;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.copyBtn {
padding: 8px 16px;
font-size: 13px;
}
.iconSlot {
width: 18px;
height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.copyBtn * {
transition: none;
}
}// 아이콘 모핑 체크 — 서류 아이콘이 사라지고 체크 폴리라인이 stroke-dashoffset 으로 그려진다.
// 체크 선의 실제 길이는 getTotalLength 로 재고, 그 길이만큼 오프셋을 0 까지 줄이며 그린다.
// 계산 겹: 선 길이 실측 → 경과 비율 → 오프셋 재계산. 자동 재생만, 실사용자 리스너 없음.
(function () {
var sheet = document.getElementById('sheetIcon');
var check = document.getElementById('checkIcon');
var stroke = document.getElementById('checkStroke');
var label = document.getElementById('label');
var live = document.getElementById('live');
if (!sheet || !check || !stroke || !label || !live) return;
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 : '';
}
apply(0);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./CopyIconMorphCheck.module.scss";
// 아이콘 모핑 체크 — 서류 아이콘이 사라지고 체크 폴리라인이 stroke-dashoffset 으로 그려진다.
// 체크 선의 실제 길이는 getTotalLength 로 재고, 그 길이만큼 오프셋을 0 까지 줄이며 그린다.
// 계산 겹: 선 길이 실측 → 경과 비율 → 오프셋 재계산. 자동 재생만, 실사용자 리스너 없음.
const LABEL_IDLE = "코드 복사";
const LABEL_DONE = "복사됨";
const CYCLE_MS = 2000;
const TRIGGER_AT = 0.08;
const DRAW_MS = 640;
const REVERT_AT = 0.62;
export default function CopyIconMorphCheck() {
const sheetRef = useRef<SVGSVGElement>(null);
const checkRef = useRef<SVGSVGElement>(null);
const strokeRef = useRef<SVGPolylineElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const liveRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const sheet = sheetRef.current;
const check = checkRef.current;
const stroke = strokeRef.current;
const label = labelRef.current;
const live = liveRef.current;
if (!sheet || !check || !stroke || !label || !live) return;
const length = stroke.getTotalLength();
stroke.style.strokeDasharray = length.toFixed(2);
function apply(t: number) {
const drawStart = TRIGGER_AT * CYCLE_MS;
const revertAt = REVERT_AT * CYCLE_MS;
const success = t >= drawStart && t < revertAt;
const drawT = Math.min(Math.max((t - drawStart) / DRAW_MS, 0), 1);
const offset = success ? length * (1 - drawT) : length;
stroke!.style.strokeDashoffset = offset.toFixed(2);
sheet!.style.opacity = success ? "0" : "1";
check!.style.opacity = success ? "1" : "0";
const done = success && drawT >= 1;
label!.textContent = done ? LABEL_DONE : LABEL_IDLE;
live!.textContent = done ? LABEL_DONE : "";
}
apply(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<button className={styles.copyBtn} type="button" aria-pressed="false" aria-label="코드 복사">
<span className={styles.iconSlot}>
<svg ref={sheetRef} className={`${styles.icon} ${styles.iconSheet}`} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M216 40v128h-48V88H88V40Z" opacity=".2" />
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
</g>
</svg>
<svg ref={checkRef} className={`${styles.icon} ${styles.iconCheck}`} viewBox="0 0 24 24" aria-hidden="true">
<polyline ref={strokeRef} points="4,13 9.5,18.5 20,6" />
</svg>
</span>
<span ref={labelRef} className={styles.label}>코드 복사</span>
</button>
<span ref={liveRef} className={styles.sr} role="status" aria-live="polite" />
</section>
</div>
);
}01 アイコンモーフィックチェック — 成功を1本の線で描いて見せるボタン — 計測 累積面積 1.288% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · コピー ボタン html UI 9選 — コード・URL・テキストのフィードバックまで
このデモが属する記事
コピー ボタン html UI 9選 — コード・URL・テキストのフィードバックまで「コピー ボタン html」9選 — 9つのうち1つはコピー結果を2つのボタンが分けて持ち、1つはコピーした文字数だけ表示を埋め、もう1つは失敗したコピーの復帰時間を自ら計ります。 この記事のデモをすべて見る


