02Character-count fill — a button that fills by the number of characters copied
The copied sentence's character count becomes the fill ratio of the button's inner progress mark; when the fill completes, the number briefly rises and settles with the icon.
Best forCopying long notices or license text, quoting blocks
setProperty--filltranslateY

<main class="stage">
<section class="panel">
<div class="card">
<p class="preview" id="preview">This is a sample caption that fills in automatically beneath the invite card.</p>
<button class="copyBtn" id="copyBtn" type="button" aria-label="Copy text">
<span class="fillTrack" aria-hidden="true"><span class="fillBar" id="fillBar"></span></span>
<span class="iconSlot">
<svg class="icon" id="icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
</span>
<span class="count" id="count">0</span>
</button>
</div>
<span class="sr" id="live" role="status" aria-live="polite"></span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.card {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
background: $subject-cream;
border-radius: $r-card;
padding: $sp-5 $sp-6;
box-shadow: $shadow-raised;
max-width: 380px;
}
.preview {
margin: 0;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 13px;
line-height: 1.5;
text-align: center;
}
.copyBtn {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-5;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 14px;
line-height: 1.3;
cursor: pointer;
overflow: hidden;
isolation: isolate;
}
.fillTrack {
position: absolute;
inset: 0;
z-index: 0;
}
.fillBar {
--fill: 0;
position: absolute;
inset: 0;
transform: scaleX(var(--fill));
transform-origin: left center;
background: $subject-blue-deep;
}
.iconSlot {
position: relative;
z-index: 1;
width: 18px;
height: 18px;
flex: none;
}
.icon {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
}
.count {
position: relative;
z-index: 1;
min-width: 1.4em;
text-align: right;
font-variant-numeric: tabular-nums;
}
.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) {
.card {
padding: $sp-4 $sp-5;
}
.preview {
font-size: 12px;
}
}
@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: #ffd23f;
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;
}
.card {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
background: #fff7e6;
border-radius: 16px;
padding: 20px 24px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
max-width: 380px;
}
.preview {
margin: 0;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 13px;
line-height: 1.5;
text-align: center;
}
.copyBtn {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 14px;
line-height: 1.3;
cursor: pointer;
overflow: hidden;
isolation: isolate;
}
.fillTrack {
position: absolute;
inset: 0;
z-index: 0;
}
.fillBar {
--fill: 0;
position: absolute;
inset: 0;
transform: scaleX(var(--fill));
transform-origin: left center;
background: #0a4fe8;
}
.iconSlot {
position: relative;
z-index: 1;
width: 18px;
height: 18px;
flex: none;
}
.icon {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
}
.count {
position: relative;
z-index: 1;
min-width: 1.4em;
text-align: right;
font-variant-numeric: tabular-nums;
}
.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) {
.card {
padding: 16px 20px;
}
.preview {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.copyBtn * {
transition: none;
}
}// 글자 수 채움 — 복사한 문장의 글자 수(preview 실측 length)를 채움 비율로 바꾸고,
// 채움이 끝나면 아이콘·숫자가 짧게 떠올랐다 정착한다(translateY 보간, 이차 감쇠).
// 계산 겹: 글자 수 실측 → 진행률 → 오프셋(--fill) 재계산. 실사용자 리스너 있음(버튼 클릭).
// 실 클립보드 API 호출 없음 — 상태는 전부 시뮬레이션.
(function () {
var preview = document.getElementById('preview');
var btn = document.getElementById('copyBtn');
var fillBar = document.getElementById('fillBar');
var icon = document.getElementById('icon');
var count = document.getElementById('count');
var live = document.getElementById('live');
if (!preview || !btn || !fillBar || !icon || !count || !live) return;
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;
}
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 + ' chars copied';
} else {
render(1, 0);
live.textContent = CHAR_COUNT + ' chars copied';
}
}
render(0, 0);
var CYCLE_MS = 2000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var autoStart = null;
var autoRaf = 0;
function autoLoop(ts) {
if (autoStart === null) autoStart = ts;
var t = (ts - autoStart) % CYCLE_MS;
if (t < IDLE_MS) { render(0, 0); live.textContent = ''; }
else sequence(t - IDLE_MS);
autoRaf = requestAnimationFrame(autoLoop);
}
if (!reduce) autoRaf = requestAnimationFrame(autoLoop);
btn.addEventListener('click', function () {
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
render(0, 0);
var clickStart = null;
function userLoop(ts) {
if (clickStart === null) clickStart = ts;
var t = ts - clickStart;
sequence(t);
if (t < FILL_MS + BOUNCE_MS + 20) requestAnimationFrame(userLoop);
}
requestAnimationFrame(userLoop);
});
})();import { useEffect, useRef } from "react";
import styles from "./CopyCharCountFill.module.scss";
// 글자 수 채움 — 복사한 문장의 글자 수(preview 실측 length)를 채움 비율로 바꾸고,
// 채움이 끝나면 아이콘·숫자가 짧게 떠올랐다 정착한다(translateY 보간, 이차 감쇠).
// 계산 겹: 글자 수 실측 → 진행률 → 오프셋(--fill) 재계산. 실사용자 리스너 있음(버튼 클릭).
// 실 클립보드 API 호출 없음 — 상태는 전부 시뮬레이션.
const FILL_MS_PER_CHAR = 18;
const BOUNCE_MS = 260;
const IDLE_MS = 140;
const CYCLE_MS = 2000;
const PREVIEW_TEXT = "이 문구는 초대장 카드 아래 자동으로 채워지는 안내용 예시 텍스트입니다.";
function bounceEase(p: number) {
if (p < 0.5) return p / 0.5;
const q = (p - 0.5) / 0.5;
return 1 - q * q;
}
export default function CopyCharCountFill() {
const fillBarRef = useRef<HTMLSpanElement>(null);
const iconRef = useRef<SVGSVGElement>(null);
const countRef = useRef<HTMLSpanElement>(null);
const liveRef = useRef<HTMLSpanElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const fillBar = fillBarRef.current;
const icon = iconRef.current;
const count = countRef.current;
const live = liveRef.current;
const btn = btnRef.current;
if (!fillBar || !icon || !count || !live || !btn) return;
const CHAR_COUNT = PREVIEW_TEXT.trim().length;
const FILL_MS = CHAR_COUNT * FILL_MS_PER_CHAR;
function render(ratio: number, lift: number) {
fillBar!.style.setProperty("--fill", ratio.toFixed(4));
count!.textContent = String(Math.round(ratio * CHAR_COUNT));
icon!.style.opacity = ratio >= 1 ? "1" : "0";
const y = -lift * 6;
const t = `translateY(${y.toFixed(2)}px)`;
icon!.style.transform = t;
count!.style.transform = t;
}
function sequence(t: number) {
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}자 복사됨`;
}
}
render(0, 0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let autoStart: number | null = null;
let autoRaf = 0;
function autoLoop(ts: number) {
if (autoStart === null) autoStart = ts;
const t = (ts - autoStart) % CYCLE_MS;
if (t < IDLE_MS) {
render(0, 0);
live!.textContent = "";
} else {
sequence(t - IDLE_MS);
}
autoRaf = requestAnimationFrame(autoLoop);
}
if (!reduce) autoRaf = requestAnimationFrame(autoLoop);
function onClick() {
if (autoRaf) {
cancelAnimationFrame(autoRaf);
autoRaf = 0;
}
render(0, 0);
let clickStart: number | null = null;
function userLoop(ts: number) {
if (clickStart === null) clickStart = ts;
const t = ts - clickStart;
sequence(t);
if (t < FILL_MS + BOUNCE_MS + 20) requestAnimationFrame(userLoop);
}
requestAnimationFrame(userLoop);
}
btn.addEventListener("click", onClick);
return () => {
if (autoRaf) cancelAnimationFrame(autoRaf);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.card}>
<p className={styles.preview}>{PREVIEW_TEXT}</p>
<button ref={btnRef} className={styles.copyBtn} type="button" aria-label="문구 복사">
<span className={styles.fillTrack} aria-hidden="true">
<span ref={fillBarRef} className={styles.fillBar} />
</span>
<span className={styles.iconSlot}>
<svg ref={iconRef} className={styles.icon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
</span>
<span ref={countRef} className={styles.count}>0</span>
</button>
</div>
<span ref={liveRef} className={styles.sr} role="status" aria-live="polite" />
</section>
</div>
);
}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 · 9 Copy Button CSS UI Patterns (Copy to Clipboard)
The post this demo lives in
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 co See all demos in this post


