03Phản hồi sao chép xong
Bấm icon copy, icon đổi thành tick và một tooltip nhỏ nổi lên rồi tự tắt sau 1.2 giây.
Dùng choNút copy khối code, nút copy API key
아이콘 스왑툴팁 페이드1.2초 유지

<main class="stage">
<button class="copy is-demo" type="button">
<span class="copy__icon" aria-hidden="true">
<span class="copy__ring"></span>
<span class="copy__sheet copy__sheet--back"></span>
<span class="copy__sheet copy__sheet--front"></span>
<span class="copy__check">
<span class="copy__check-bar copy__check-bar--short"></span>
<span class="copy__check-bar copy__check-bar--long"></span>
</span>
</span>
<span class="copy__label">Sao chép mã</span>
<span class="copy__tip" role="status">Đã sao chép</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 아이콘 전환 시간
$easing: $ease-pop; // 배지가 팝업하는 이징
$color: $subject-cream; // 아이콘·툴팁 글자색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.copy {
position: relative;
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
border: 0;
border-radius: $r-control;
padding: clamp(12px, 4vw, 16px) clamp(18px, 5.6vw, 24px);
font-weight: 700;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
color: $color;
background: $stage-ink;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__icon { position: relative; width: 22px; height: 22px; flex: none; }
// 완료 배지: 체크가 도드라지도록 원형이 팝업한다
&__ring {
position: absolute; inset: -3px;
border-radius: 50%;
background: $subject-blue;
opacity: 0;
transform: scale(.4);
}
// 복사 아이콘: 겹친 두 사각형
&__sheet {
position: absolute;
width: 14px; height: 16px;
border: 2px solid currentColor;
border-radius: 3px;
transition: opacity $duration $easing, transform $duration $easing;
}
&__sheet--back { left: 0; top: 0; opacity: .55; }
&__sheet--front { left: 5px; top: 4px; background: $stage-ink; }
// 체크 아이콘: 두 획이 scaleX 로 그어진다
&__check { position: absolute; inset: 0; opacity: 0; }
&__check-bar {
position: absolute;
height: 3px;
border-radius: 2px;
background: currentColor;
transform-origin: left center; // 규칙8: 체크 막대 같은 꼭짓점
}
&__check-bar--short { width: 6px; left: 7px; top: 13px; transform: rotate(225deg) scaleX(0); } // 같은 꼭짓점(7,13)에서 출발
&__check-bar--long { width: 12px; left: 7px; top: 13px; transform: rotate(-52deg) scaleX(0); }
// 툴팁: 위로 살짝 떠올랐다가 사라진다
&__tip {
position: absolute;
left: 50%; bottom: 100%;
transform: translate(-50%, 4px);
margin-bottom: 8px;
padding: 5px 10px;
border-radius: $r-pill;
background: $stage-ink;
color: $color;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 1;
font-family: inherit;
white-space: nowrap;
opacity: 0;
pointer-events: none;
}
&.is-copied &__ring { animation: ring-pop $duration $easing forwards; }
&.is-copied &__sheet--front { opacity: 0; transform: scale(.6); transition-delay: 0s; }
&.is-copied &__sheet--back { opacity: 0; transform: scale(.6); }
&.is-copied &__check { animation: check-in $duration $easing forwards; }
&.is-copied &__check-bar--short { animation: bar-short $duration $easing forwards; animation-delay: $duration * .3; }
&.is-copied &__check-bar--long { animation: bar-long $duration $easing forwards; animation-delay: $duration * .45; }
&.is-copied &__tip { animation: tip-float 1.4s $easing forwards; }
&.is-demo &__ring { animation: ring-pop-loop $dur-loop $easing infinite; }
&.is-demo &__sheet { animation: sheet-loop $dur-loop $easing infinite; }
&.is-demo &__check { animation: check-in-loop $dur-loop $easing infinite; }
&.is-demo &__check-bar--short { animation: bar-short-loop $dur-loop $easing infinite; }
&.is-demo &__check-bar--long { animation: bar-long-loop $dur-loop $easing infinite; }
&.is-demo &__tip { animation: tip-float-loop $dur-loop $easing infinite; }
}
@keyframes ring-pop { from { opacity: 0; transform: scale(.4); } to { opacity: 1; transform: scale(1); } }
@keyframes ring-pop-loop {
0%, 32% { opacity: 0; transform: scale(.4); }
40%, 78% { opacity: 1; transform: scale(1); }
92%, 100% { opacity: 0; transform: scale(.4); }
}
@keyframes check-in { to { opacity: 1; } }
@keyframes bar-short { to { transform: rotate(225deg) scaleX(1); } }
@keyframes bar-long { to { transform: rotate(-52deg) scaleX(1); } }
@keyframes tip-float {
0% { opacity: 0; transform: translate(-50%, 4px); }
15% { opacity: 1; transform: translate(-50%, -2px); }
85% { opacity: 1; transform: translate(-50%, -2px); }
100% { opacity: 0; transform: translate(-50%, -10px); }
}
@keyframes sheet-loop {
0%, 18% { opacity: 1; transform: scale(1); }
35%, 82% { opacity: 0; transform: scale(.6); }
95%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes check-in-loop {
0%, 32% { opacity: 0; }
40%, 78% { opacity: 1; }
92%, 100% { opacity: 0; }
}
@keyframes bar-short-loop {
0%, 35% { transform: rotate(225deg) scaleX(0); }
45%, 75% { transform: rotate(225deg) scaleX(1); }
92%, 100% { transform: rotate(225deg) scaleX(0); }
}
@keyframes bar-long-loop {
0%, 40% { transform: rotate(-52deg) scaleX(0); }
50%, 75% { transform: rotate(-52deg) scaleX(1); }
92%, 100% { transform: rotate(-52deg) scaleX(0); }
}
@keyframes tip-float-loop {
0%, 15% { opacity: 0; transform: translate(-50%, 4px); }
32% { opacity: 1; transform: translate(-50%, -2px); }
75% { opacity: 1; transform: translate(-50%, -2px); }
90%, 100% { opacity: 0; transform: translate(-50%, -10px); }
}
// ── 접근성: 움직임 줄이기 설정이면 상태만 바뀌고 과정은 생략 ────
@media (prefers-reduced-motion: reduce) {
.copy__ring, .copy__sheet, .copy__check, .copy__check-bar, .copy__tip { animation: none !important; transition: none !important; }
.copy.is-copied .copy__ring { opacity: 1; transform: scale(1); }
.copy.is-copied .copy__sheet { opacity: 0; }
.copy.is-copied .copy__check { opacity: 1; }
.copy.is-copied .copy__check-bar--short { transform: rotate(225deg) scaleX(1); }
.copy.is-copied .copy__check-bar--long { transform: rotate(-52deg) scaleX(1); }
.copy.is-copied .copy__tip { opacity: 1; transform: translate(-50%, -2px); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.copy {
position: relative;
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
border: 0;
border-radius: 12px;
padding: clamp(12px, 4vw, 16px) clamp(18px, 5.6vw, 24px);
font-weight: 700;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.copy__icon {
position: relative;
width: 22px;
height: 22px;
flex: none;
}
.copy__ring {
position: absolute;
inset: -3px;
border-radius: 50%;
background: #2f6df6;
opacity: 0;
transform: scale(0.4);
}
.copy__sheet {
position: absolute;
width: 14px;
height: 16px;
border: 2px solid currentColor;
border-radius: 3px;
transition: opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.copy__sheet--back {
left: 0;
top: 0;
opacity: 0.55;
}
.copy__sheet--front {
left: 5px;
top: 4px;
background: #17141a;
}
.copy__check {
position: absolute;
inset: 0;
opacity: 0;
}
.copy__check-bar {
position: absolute;
height: 3px;
border-radius: 2px;
background: currentColor;
transform-origin: left center;
}
.copy__check-bar--short {
width: 6px;
left: 7px;
top: 13px;
transform: rotate(225deg) scaleX(0);
}
.copy__check-bar--long {
width: 12px;
left: 7px;
top: 13px;
transform: rotate(-52deg) scaleX(0);
}
.copy__tip {
position: absolute;
left: 50%;
bottom: 100%;
transform: translate(-50%, 4px);
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 1;
font-family: inherit;
white-space: nowrap;
opacity: 0;
pointer-events: none;
}
.copy.is-copied .copy__ring {
animation: ring-pop 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
}
.copy.is-copied .copy__sheet--front {
opacity: 0;
transform: scale(0.6);
transition-delay: 0s;
}
.copy.is-copied .copy__sheet--back {
opacity: 0;
transform: scale(0.6);
}
.copy.is-copied .copy__check {
animation: check-in 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
}
.copy.is-copied .copy__check-bar--short {
animation: bar-short 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
animation-delay: 90ms;
}
.copy.is-copied .copy__check-bar--long {
animation: bar-long 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
animation-delay: 135ms;
}
.copy.is-copied .copy__tip {
animation: tip-float 1.4s cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
}
.copy.is-demo .copy__ring {
animation: ring-pop-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.copy.is-demo .copy__sheet {
animation: sheet-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.copy.is-demo .copy__check {
animation: check-in-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.copy.is-demo .copy__check-bar--short {
animation: bar-short-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.copy.is-demo .copy__check-bar--long {
animation: bar-long-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.copy.is-demo .copy__tip {
animation: tip-float-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes ring-pop {
from {
opacity: 0;
transform: scale(0.4);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes ring-pop-loop {
0%, 32% {
opacity: 0;
transform: scale(0.4);
}
40%, 78% {
opacity: 1;
transform: scale(1);
}
92%, 100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes check-in {
to {
opacity: 1;
}
}
@keyframes bar-short {
to {
transform: rotate(225deg) scaleX(1);
}
}
@keyframes bar-long {
to {
transform: rotate(-52deg) scaleX(1);
}
}
@keyframes tip-float {
0% {
opacity: 0;
transform: translate(-50%, 4px);
}
15% {
opacity: 1;
transform: translate(-50%, -2px);
}
85% {
opacity: 1;
transform: translate(-50%, -2px);
}
100% {
opacity: 0;
transform: translate(-50%, -10px);
}
}
@keyframes sheet-loop {
0%, 18% {
opacity: 1;
transform: scale(1);
}
35%, 82% {
opacity: 0;
transform: scale(0.6);
}
95%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes check-in-loop {
0%, 32% {
opacity: 0;
}
40%, 78% {
opacity: 1;
}
92%, 100% {
opacity: 0;
}
}
@keyframes bar-short-loop {
0%, 35% {
transform: rotate(225deg) scaleX(0);
}
45%, 75% {
transform: rotate(225deg) scaleX(1);
}
92%, 100% {
transform: rotate(225deg) scaleX(0);
}
}
@keyframes bar-long-loop {
0%, 40% {
transform: rotate(-52deg) scaleX(0);
}
50%, 75% {
transform: rotate(-52deg) scaleX(1);
}
92%, 100% {
transform: rotate(-52deg) scaleX(0);
}
}
@keyframes tip-float-loop {
0%, 15% {
opacity: 0;
transform: translate(-50%, 4px);
}
32% {
opacity: 1;
transform: translate(-50%, -2px);
}
75% {
opacity: 1;
transform: translate(-50%, -2px);
}
90%, 100% {
opacity: 0;
transform: translate(-50%, -10px);
}
}
@media (prefers-reduced-motion: reduce) {
.copy__ring, .copy__sheet, .copy__check, .copy__check-bar, .copy__tip {
animation: none !important;
transition: none !important;
}
.copy.is-copied .copy__ring {
opacity: 1;
transform: scale(1);
}
.copy.is-copied .copy__sheet {
opacity: 0;
}
.copy.is-copied .copy__check {
opacity: 1;
}
.copy.is-copied .copy__check-bar--short {
transform: rotate(225deg) scaleX(1);
}
.copy.is-copied .copy__check-bar--long {
transform: rotate(-52deg) scaleX(1);
}
.copy.is-copied .copy__tip {
opacity: 1;
transform: translate(-50%, -2px);
}
}// 실제 클릭에도 같은 상태(.is-copied)로 반응한다. 데모(.is-demo)는 손을 대면 멈춘다.
document.querySelector('.copy').addEventListener('click', function (e) {
var b = e.currentTarget;
b.classList.remove('is-demo');
b.classList.remove('is-copied');
void b.offsetWidth;
b.classList.add('is-copied');
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CopyDoneButton.module.scss";
export type CopyDoneButtonProps = {
label: string;
tipLabel: string;
value: string;
/** 아이콘 전환 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CopyDoneButton({
label,
tipLabel,
value,
duration = 300,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
}: CopyDoneButtonProps) {
const [copied, setCopied] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 1400);
return () => clearTimeout(t);
}, [copied]);
const handle = async () => {
try {
await navigator.clipboard.writeText(value);
} catch {
/* 클립보드 권한이 없어도 UI 는 그대로 반응한다 */
}
setCopied(true);
};
return (
<button
type="button"
className={copied ? `${styles.copy} ${styles.copied}` : styles.copy}
style={vars}
onClick={handle}
>
<span className={styles.icon} aria-hidden="true">
<span className={styles.ring} />
<span className={`${styles.sheet} ${styles.sheetBack}`} />
<span className={`${styles.sheet} ${styles.sheetFront}`} />
<span className={styles.check}>
<span className={`${styles.bar} ${styles.barShort}`} />
<span className={`${styles.bar} ${styles.barLong}`} />
</span>
</span>
<span>{label}</span>
<span className={styles.tip} role="status">{tipLabel}</span>
</button>
);
}03 Phản hồi sao chép xong — Diện tích đo được 1.494% · khung hình chuyển động 14/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 micro interactions css — dán 1 dòng chạy ngay
Bài viết chứa demo này
9 micro interactions css — dán 1 dòng chạy ngayMicro interactions css là những phản hồi cực nhỏ trên giao diện — nút lún xuống, icon đổi hình, toast xếp chồng — báo ch Xem tất cả demo trong bài này


