GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

클립보드 복사 버튼 UI 9종 — 코드·링크·텍스트 복사 피드백까지

아홉 개 중 하나는 복사 결과를 버튼 두 개가 나눠 갖고, 하나는 복사된 글자 수를 세어 자릿수만큼 표시를 채우고, 또 하나는 실패한 복사를 되돌리는 시간을 스스로 잽니다.

실제 구현에서는 이 계산이 성공 상태에 이르는 지점에 진짜 클립보드 API 호출을 얹으면 됩니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 화면은 길이·개수를 비율로 바꾸는 계산, 경과 시간을 기준 삼는 계산, 값을 비교해 갈래를 나누는 계산 세 축으로 묶입니다. 아래 표는 그 계산이 실제로 화면을 얼마나 흔들었는지 잰 값이고, 순서는 아이콘·텍스트 피드백(01~04) → 목록·상태 유지(05~06) → 배경 대비·권한·서식(07~09) 순으로 실제 서비스에서 마주치는 배치와 가깝게 잡았습니다.

데모

아홉 화면은 requestAnimationFrame 루프가 흘려보내는 하나의 시간값을 각 화면의 계산 함수가 저마다 다른 공식으로 받아 화면에 다시 씁니다. 실제 클립보드 쓰기·읽기 호출은 하나도 없고, 그 자리에 어떤 값이 들어가면 버튼이 어떤 모습으로 바뀌는지만 자동 재생 또는 클릭으로 보여줍니다. 실제 서비스에 붙일 때는 이 계산이 끝나는 지점에 navigator.clipboard.writeText() 호출과 그 성공·실패 콜백을 얹으면 됩니다.

실측 한눈에

항목 초점 누적면적 % 강도 평균 움직인 프레임
01 아이콘 모핑 체크 선 길이 실측 → 그리기 진행 1.288 144.5 13/23
02 글자 수 채움 글자 수 → 채움 비율 2.329 161.2 16/23
03 폴백 선택 안내 선택 폭 실측 → 레이아웃 분기 15.438 87.9 14/23
04 두 버튼 분할 버튼 간 거리 → 시간차 3.328 73.0 11/23
05 대기줄 히스토리 경과 시간 → 상대 시각 0.401 39.7 18/23
06 실패 되돌리기 경과 시간 → 색 보간 4.012 116.7 21/23
07 대비 스왑 배경 밝기 → 아이콘 색 42.094 29.3 23/23
08 권한 게이트 카드 남은 대기 비율 → 농도 29.676 86.6 19/23
09 리치 텍스트 토스트 서식 종류 수 → 라벨 폭 10.36 159.7 10/23

01·05 는 아이콘 한 획이나 목록 라벨처럼 원래 차지하는 자리가 작아 누적면적이 1% 안팎으로 낮게 잡히는 구조이고, 07 은 배경 전체가 색을 바꾸는 화면이라 42%대로 가장 높습니다. 04·09 는 성공·완료 상태에 이른 뒤로는 값이 더 바뀌지 않아 움직인 프레임이 11/23·10/23 으로 다른 항목보다 적게 잡히는데, 이는 상태 전환이 끝난 뒤의 정지 구간이지 계산이 멈춘 결함이 아닙니다.

복사 버튼의 해부 — 아홉 화면이 계산하는 것

같은 "복사 버튼" 이라도 무엇을 실측해 무엇으로 바꾸는지에 따라 세 갈래로 나뉘고, 이 갈래를 알아 두면 복사 버튼 만들기를 처음 시작할 때 어떤 값부터 재야 할지 가늠하기 쉬워집니다.

길이·개수를 비율로 바꾸는 축

01·02·09 는 무언가의 크기를 실측해 그 값을 화면의 진행 비율로 바꿉니다. 01 은 체크 폴리라인의 실제 길이를 getTotalLength() 로 재 그 길이만큼 stroke-dashoffset 을 줄이고, 02 는 복사된 문장의 글자 수를 채움 비율(--fill)로 바꾸며, 09 는 서식 종류 수만큼 토스트 라벨의 최소 폭을 다시 계산합니다.

경과 시간을 기준 삼는 축

05·06·08 은 클릭 없이도 시간이 흐르는 동안 값을 계속 다시 구합니다. 05 는 지금으로부터 각 항목까지의 시간 차를 매 프레임 재어 라벨을 갱신하고, 06 은 실패 뒤 흐른 시간을 기준으로 버튼 농도를 원래 색까지 보간하며, 08 은 남은 대기 시간의 비율만큼 카드 농도와 진행바 폭을 함께 바꿉니다.

값을 비교해 갈래를 나누는 축

03·04·07 은 실측한 두 값을 서로 비교해 어느 쪽 모습을 보일지 고릅니다. 03 은 선택 영역의 폭과 카드 폭을 비교해 한 줄·두 줄 배치를 고르고, 04 는 두 버튼 사이의 실제 거리에서 시간차를 다시 구하며, 07 은 배경의 상대 휘도를 임계값과 비교해 아이콘 색을 가릅니다.

화면별 코드

01아이콘 모핑 체크 — 복사 성공을 선 하나로 그려 보여주는 버튼

체크 폴리라인의 실제 길이를 재어 그 길이만큼 stroke-dashoffset 을 0까지 줄이며 선을 그리듯 그립니다.

선 길이를 실측해 오프셋으로 되돌리는 계산

apply(t) 는 주기 시작 지점(TRIGGER_AT)부터 DRAW_MS 동안 오프셋을 실측한 전체 길이에서 0까지 줄이고, REVERT_AT 을 넘기면 다시 서류 아이콘으로 되돌립니다. 실사용자 리스너는 없고 자동 재생만 주기적으로 반복됩니다.

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 : '';
}
아이콘 모핑 체크 — 복사 성공을 선 하나로 그려 보여주는 버튼
새 창에서 열기

01 · 아이콘 모핑 체크 — 복사 성공을 선 하나로 그려 보여주는 버튼 — 측정 누적면적 1.288% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

strokeDashoffsetgetTotalLengthrequestAnimationFrame

02글자 수 채움 — 복사된 글자 수만큼 표시가 차오르는 버튼

복사한 문장의 글자 수를 세어 그 값을 진행 표시의 채움 비율로 바꾸고, 채움이 끝나면 숫자가 짧게 튕겨 오릅니다.

글자 수를 채움 시간으로 바꾸는 계산

render(ratio, lift) 는 채움 비율을 --fill 커스텀 속성과 숫자 표시에 함께 반영하고, sequence(t) 는 글자 수(CHAR_COUNT)에 글자당 속도(FILL_MS_PER_CHAR)를 곱한 시간 동안 채운 뒤 bounceEase 로 짧게 튕겨 오르는 구간을 이어 붙입니다. 버튼을 직접 눌러도 같은 계산이 처음부터 다시 실행됩니다.

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

function sequence(t) {
  if (t < FILL_MS) {
    render(t / FILL_MS, 0);
    live.textContent = '';
  } else if (t < FILL_MS + BOUNCE_MS) {
    render(1, bounceEase((t - FILL_MS) / BOUNCE_MS));
    live.textContent = CHAR_COUNT + '자 복사됨';
  } else {
    render(1, 0);
    live.textContent = CHAR_COUNT + '자 복사됨';
  }
}
글자 수 채움 — 복사된 글자 수만큼 표시가 차오르는 버튼
새 창에서 열기

02 · 글자 수 채움 — 복사된 글자 수만큼 표시가 차오르는 버튼 — 측정 누적면적 2.329% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

setProperty--filltranslateY

03폴백 선택 안내 — 되살림 명령을 손으로도 쓸 수 있게 남기는 카드

빠른 복사 길이 막힌 상태가 되면 안내 문구를 미리 범위로 잡아 두고, 그 폭을 카드 폭과 비교해 배치를 고릅니다.

선택 폭을 실측해 배치를 고르는 계산

selectRangeWidth() 는 안내 문구 전체를 Range 로 선택해 그 폭을 실측하고, layoutFallback() 은 이 폭과 안내 문구의 scrollWidth 를 카드 폭과 비교해 한 줄(inline)·두 줄(stack) 중 어느 배치가 맞는지 고릅니다. '다시 시도' 버튼을 누르면 확인 단계부터 같은 계산을 다시 밟습니다.

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;
}
폴백 선택 안내 — 되살림 명령을 손으로도 쓸 수 있게 남기는 카드
새 창에서 열기

03 · 폴백 선택 안내 — 되살림 명령을 손으로도 쓸 수 있게 남기는 카드 — 측정 누적면적 15.438% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getBoundingClientRectcreateRangescrollWidth

04두 버튼 분할 — 성공 상태를 주·보조가 시간차로 나눠 갖는 줄

복사가 성공하면 주 버튼은 체크로, 보조 버튼은 안내 문구로 시간차를 두고 어긋나며 바뀝니다.

버튼 간 거리를 시간차로 되구하는 계산

measureStagger() 는 주·보조 버튼 중심 사이의 실제 거리를 getBoundingClientRect() 로 재어 상수(STAGGER_MS_PER_PX)를 곱한 시간차를 구하고, apply(t, stagger) 는 그 시간차만큼 어긋나게 두 버튼의 상태를 바꿉니다. 주 버튼을 누르면 거리를 다시 재 시간차도 새로 계산됩니다.

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 = '';
  }
}
두 버튼 분할 — 성공 상태를 주·보조가 시간차로 나눠 갖는 줄
새 창에서 열기

04 · 두 버튼 분할 — 성공 상태를 주·보조가 시간차로 나눠 갖는 줄 — 측정 누적면적 3.328% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getBoundingClientRectaria-pressedclassList.toggle

05대기줄 히스토리 — 최근 복사의 시간 차를 상대 시각으로 다시 쓰는 목록

최근 복사들이 줄을 이루는 목록에서, 지금으로부터 각 항목까지의 시간 차를 계속 다시 재어 라벨이 저절로 갱신됩니다.

기준 경과시간에 실시간을 더해 라벨을 고르는 계산

bucket(ms) 은 기준 시간을 세 구간으로 나누고 labelFor(b) 가 그 구간을 라벨 문구로 바꾸며, apply(elapsedRealMs) 는 각 항목의 기준 경과시간(base)에 실제 흐른 시간을 배속(TIME_SCALE)만큼 더해 라벨과 점 상태를 매 프레임 갱신합니다.

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);
  });
}
대기줄 히스토리 — 최근 복사의 시간 차를 상대 시각으로 다시 쓰는 목록
새 창에서 열기

05 · 대기줄 히스토리 — 최근 복사의 시간 차를 상대 시각으로 다시 쓰는 목록 — 측정 누적면적 0.401% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

data-basedata-statesetAttribute

06실패 되돌리기 — 실패 후 흐른 시간을 세어 농도를 원래대로 보간하는 버튼

복사가 실패하면 버튼이 오류 빛으로 물들었다가, 흐른 시간을 기준으로 농도가 조금씩 원래 값으로 되돌아갑니다.

경과시간을 색 보간 비율로 바꾸는 계산

lerp(a, b, p) 는 두 색상 채널 값을 보간하는 함수이고, apply(t) 는 실패 시점 이후 흐른 시간을 RECOVER_MS 로 나눈 비율만큼 오류 색(ERROR_RGB)에서 원래 색(NORMAL_RGB)으로 배경색을 되돌리며 남은 시간을 안내 문구에 초 단위로 적습니다.

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 + 's';
    hint.classList.remove('is-ok');
  } else {
    hint.textContent = '다시 복사할 수 있어요';
    hint.classList.add('is-ok');
  }
}
실패 되돌리기 — 실패 후 흐른 시간을 세어 농도를 원래대로 보간하는 버튼
새 창에서 열기

06 · 실패 되돌리기 — 실패 후 흐른 시간을 세어 농도를 원래대로 보간하는 버튼 — 측정 누적면적 4.012% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

backgroundColorclassList.addclassList.remove

07대비 스왑 — 배경 밝기에 맞춰 아이콘 색을 다시 구하는 겹 버튼

버튼이 이미지나 영상 위에 겹쳐 있을 때, 배경 밝기를 읽어 아이콘을 밝은 쪽·어두운 쪽으로 가릅니다.

배경 색을 상대 휘도로 바꿔 임계값과 비교하는 계산

channelLin(c) 과 relativeLuminance(rgb) 는 배경 RGB 값을 WCAG 근사 공식으로 상대 휘도로 바꾸고, apply(t) 는 그 값을 밝기 임계값(BRIGHTNESS_THRESHOLD)과 비교해 아이콘을 밝은 쪽·어두운 쪽 중 하나로 가릅니다.

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 ? '밝은 배경' : '어두운 배경');
}
대비 스왑 — 배경 밝기에 맞춰 아이콘 색을 다시 구하는 겹 버튼
새 창에서 열기

07 · 대비 스왑 — 배경 밝기에 맞춰 아이콘 색을 다시 구하는 겹 버튼 — 측정 누적면적 42.094% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

relativeLuminancebackgroundColorclassList.toggle

08권한 게이트 카드 — 권한 상태와 남은 대기 시간을 농도에 얹는 안내판

읽기 권한을 얻지 못한 상태가 되면 안내판이 대기 카드로 바뀌어, 남은 대기 시간의 비율만큼 농도와 문구를 바꿉니다.

남은 대기 비율을 농도·진행바로 바꾸는 계산

applyChecking() 은 확인 중 상태를, applyWaiting(elapsed) 는 남은 대기 시간의 비율만큼 진행바 폭과 카드 농도(--tint)를 바꾸는 대기 상태를 그리고, setPhase(t) 가 경과 시간에 따라 두 함수 중 하나를 고릅니다.

function applyChecking() {
  card.dataset.state = 'checking';
  title.textContent = '붙여넣기 권한 확인 중';
  msg.textContent = '권한 상태를 확인하고 있어요';
  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 = '권한이 아직 없어요';
  msg.textContent = '거부됨 · 남은 대기 ' + (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));
}

function setPhase(t) {
  if (t < CHECK_MS) {
    applyChecking();
  } else {
    applyWaiting(t - CHECK_MS);
  }
}
권한 게이트 카드 — 권한 상태와 남은 대기 시간을 농도에 얹는 안내판
새 창에서 열기

08 · 권한 게이트 카드 — 권한 상태와 남은 대기 시간을 농도에 얹는 안내판 — 측정 누적면적 29.676% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

setProperty--tintdataset.state

09리치 텍스트 토스트 — 서식 종류 수만큼 라벨 폭을 다시 계산하는 알림

복사된 내용에 서식이 몇 종류 섞였는지 세어 그 수만큼 토스트의 라벨 폭을 늘리고, 종류 배지가 순서대로 붙습니다.

서식 종류 수를 라벨 폭으로 바꾸는 계산

countKinds() 는 복사 대상 안에 섞인 서식 태그 종류를 세고, measureAndSize(count) 는 라벨의 실측 폭에 종류 수만큼 배지 폭을 더해 토스트 최소 폭을 다시 계산하며, clearBadgeTimers() 는 이전 배지 예약을 정리해 다음 표본으로 넘어갈 때 배지가 겹치지 않게 합니다.

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

var badgeTimers = [];

function clearBadgeTimers() {
  for (var i = 0; i < badgeTimers.length; i++) clearTimeout(badgeTimers[i]);
  badgeTimers = [];
}
리치 텍스트 토스트 — 서식 종류 수만큼 라벨 폭을 다시 계산하는 알림
새 창에서 열기

09 · 리치 텍스트 토스트 — 서식 종류 수만큼 라벨 폭을 다시 계산하는 알림 — 측정 누적면적 10.36% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

querySelectorscrollWidthcreateElement

값이 화면을 바꾸는 곳 — 글자 수·경과 시간·대비

글자 수·경과 시간·대비라는 세 값이 아홉 화면 곳곳에서 되풀이되므로, 어느 화면에서 어떤 값으로 바뀌는지를 따로 정리해 둡니다.

글자 수 — 길이·개수를 실측해 진행률로 바꾸는 곳

01 은 체크 선의 길이를 실측해 그리기 진행률로 바꾸고, 02 는 클립보드 복사 대상 문장의 글자 수를 채움 비율로 바꾸며, 09 는 서식 종류 수를 라벨 폭으로 바꿉니다. 세 화면 모두 "실측한 개수 → 화면에 보일 비율" 이라는 같은 뼈대를 씁니다.

경과 시간 — 실패·대기·목록 항목의 기준 시각을 다시 재는 곳

05 는 각 항목의 기준 경과시간에 실제 흐른 시간을 더해 상대 시각 라벨을 다시 쓰고, 06 은 실패 시점 이후 흐른 시간으로 색 보간 비율을 구하며, 08 은 남은 대기 시간의 비율로 카드 농도를 바꿉니다.

대비 — 폭·거리·밝기를 비교해 갈래를 나누는 곳

03 은 선택 영역 폭과 카드 폭을 비교해 배치를 고르고, 04 는 두 버튼 사이 거리에서 시간차를 구하며, 07 은 배경의 상대 휘도를 임계값과 비교해 아이콘 색을 가릅니다. 셋 다 값 하나가 아니라 두 값을 비교한 결과로 화면 모습이 갈립니다.

어디서 깨지는가 — 권한 거부·연속 복사·포커스 이동

권한 거부 — 브라우저마다 흐름이 다르다

08 은 읽기 권한이 없는 상태를 대기 카드로 표현하지만, 실제 브라우저는 권한 요청을 보여주는 시점·재요청 가능 여부·거부 뒤 안내 방식이 서로 다르게 구현돼 있어 "이렇게 하면 항상 이렇게 뜬다" 고 단정할 수 없습니다. 데모의 CHECK_MS·WAIT_MS 는 흐름을 보여주기 위한 임의 값이지 실제 권한 API 의 응답 시간이 아닙니다.

연속 복사 — 사이클이 돌아오면 처음부터 다시 시작된다

02·04 는 버튼을 다시 눌러도 그 시점부터 채움·시간차 계산이 새로 시작될 뿐, fjdfnyzg 압축 파일 안의 원본 소스를 보면 알 수 있듯 진행 중이던 값을 이어받지 않고 매번 0에서 다시 잽니다. 자동 재생 화면(01·05·06·07)은 사용자 조작과 무관하게 같은 주기를 계속 돕니다.

포커스 이동 — 상태 전환 중에 다른 곳을 보고 있어도 안내는 남는다

01 은 aria-live="polite" 인 상태 문구를 따로 두어, 버튼에서 포커스가 다른 요소로 옮겨간 뒤에도 복사 완료 사실이 스크린 리더에 전달되도록 해 두었습니다. 03 은 안내 문구를 미리 선택 상태로 만들어 두어, 포커스가 버튼이 아니라 문서 어디에 있어도 Ctrl+C 로 그대로 이어받을 수 있게 합니다.

변형 세 가지와 접근성

변형 바꾸는 값 바뀌는 느낌
실패 되돌리기 시간 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 을 읽어, 켜져 있으면 자동 재생 루프를 시작하지 않고 정지 상태 값만 한 번 그립니다. 02·03·04·08·09 처럼 실제 클릭으로 상태가 바뀌는 화면은 버튼 요소를 그대로 써서 키보드 포커스와 Enter/Space 입력으로도 같은 전환을 볼 수 있게 해 두었고, 01·02·04 는 aria-live 영역으로 상태 변화를 텍스트로도 전달합니다.

FAQ

이 데모를 누르면 실제로 클립보드에 복사되나요?

아니요. 아홉 화면 모두 navigator.clipboard 호출 없이 시간·거리·서식 종류 같은 값을 계산해 화면만 바꾸는 상태 시뮬레이션입니다. 실제로 복사가 되게 하려면 이 계산이 성공 상태로 바뀌는 지점에 writeText() 호출과 그 성공·실패 분기를 얹어야 합니다.

복사 버튼 만들기를 처음 시작할 때 어떤 항목부터 보면 되나요?

코드 블록처럼 결과가 명확한 자리에는 01 의 체크 모핑이나 06 의 실패 되돌리기처럼 성공·실패를 분명히 보여주는 화면이 적당하고, 링크나 초대 코드처럼 여러 정보를 함께 주는 자리에는 04 의 두 버튼 분할이 더 잘 맞습니다.

폴백 선택 안내(03)와 권한 게이트 카드(08)는 언제 각각 필요한가요?

03 은 클립보드 쓰기 자체가 지원되지 않거나 실패했을 때 손으로 복사할 길을 남기는 화면이고, 08 은 쓰기는 되지만 읽기 권한처럼 별도 승인이 필요한 상황을 대기 상태로 보여주는 화면입니다. 두 화면은 막히는 지점이 다르므로 실제 구현에서도 실패 원인을 구분해 서로 다른 화면으로 갈라 쓰는 편이 안전합니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.