06완료 모프 — 링이 체크 모양으로 바뀌는 순간
100% 가 되는 프레임에 링의 path 데이터를 체크 모양 path 로 즉시 스왑하고 짧은 draw-in 애니메이션을 걸어, 원이 사라지지 않고 체크로 이어져 그려진 것처럼 보이게 만듭니다.
쓰임폼 제출 확인, 저장 버튼 피드백
stroke-dasharraypathtransition

<main class="stage">
<div class="sm is-demo" id="smRoot" role="group" tabindex="0" aria-label="파일 업로드 완료 표시">
<svg class="sm__svg" viewBox="0 0 120 120" width="72" height="72" aria-hidden="true">
<circle class="sm__track" cx="60" cy="60" r="50"></circle>
<circle class="sm__ring" id="smRing" cx="60" cy="60" r="50"></circle>
<path class="sm__check" id="smCheck" d="M40 62 L54 76 L82 46"></path>
</svg>
<div class="sm__bar">
<span class="sm__label">업로드 완료</span>
<span class="sm__meta">stroke-dasharray · path · transition</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 완료 모프(06) — 링과 체크는 서로 다른 <circle>/<path> 두 개다. 100% 지점에서 opacity 를
// steps(1,end) 로 순간 스왑하고, 체크는 그 즉시 stroke-dashoffset 을 0 까지 그려 넣는다(draw-in).
@property --p {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@property --d {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@include stage($stage-yellow);
$sm-text: $subject-ink;
$sm-ring: $subject-blue-deep;
$sm-check: $subject-blue-deep;
$duration: $dur-loop;
$easing: $ease-out;
$color: $sm-ring;
.sm {
display: flex; flex-direction: column; align-items: center; gap: $sp-3;
width: 100%; height: 100%; min-height: 164px;
justify-content: center;
}
.sm:focus-visible { outline: 2px solid $sm-ring; outline-offset: 3px; border-radius: $r-control; }
.sm__svg { transform: rotate(-90deg); }
.sm__track { fill: none; stroke: rgba($sm-text, .16); stroke-width: 8; }
.sm__ring {
fill: none;
stroke: $sm-ring;
stroke-width: 8;
stroke-linecap: round;
--circ: 0; --p: 0;
stroke-dasharray: var(--circ);
stroke-dashoffset: calc(var(--circ) * (1 - var(--p) / 100));
transition: stroke-dashoffset $dur-quick linear;
}
.sm__check {
fill: none;
stroke: $sm-check;
stroke-width: 8;
stroke-linecap: round;
stroke-linejoin: round;
transform: rotate(90deg);
transform-origin: 60px 60px;
--checklen: 0; --d: 0;
stroke-dasharray: var(--checklen);
stroke-dashoffset: calc(var(--checklen) * (1 - var(--d) / 100));
opacity: 0;
}
.sm__ring { opacity: 1; }
.sm.is-done .sm__ring { opacity: 0; transition: opacity $dur-instant linear; }
.sm.is-done .sm__check { opacity: 1; transition: stroke-dashoffset $dur-enter $ease-out, opacity $dur-instant linear; }
.sm.is-done .sm__check { --d: 100; }
.sm__bar { display: flex; flex-direction: column; align-items: center; gap: $sp-1; }
.sm__label { font-weight: 600; font-size: 11px; color: rgba($sm-text, .82); }
.sm__meta { font-weight: 500; font-size: 10px; color: rgba($sm-text, .62); }
// ── 데모 루프 — 66% 지점에서 링→체크 순간 스왑, 곧바로 체크가 그려진다 ──
@keyframes smRingArc {
0% { --p: 0; }
66% { --p: 100; animation-timing-function: steps(1, end); }
67% { opacity: 0; }
88% { opacity: 0; }
92% { opacity: 0; --p: 0; }
94% { opacity: 1; }
100% { opacity: 1; --p: 0; }
}
@keyframes smCheckDraw {
0%, 66% { opacity: 0; --d: 0; }
67% { opacity: 1; animation-timing-function: $ease-out; }
80% { --d: 100; }
88% { --d: 100; opacity: 1; }
92% { opacity: 0; --d: 0; }
100% { opacity: 0; --d: 0; }
}
.is-demo .sm__ring {
animation-name: smRingArc;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.is-demo .sm__check {
animation-name: smCheckDraw;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.sm__svg { width: 60px; height: 60px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .sm__ring, .is-demo .sm__check { animation: none !important; }
.is-demo .sm__ring { opacity: 0 !important; }
.is-demo .sm__check { opacity: 1 !important; --d: 100 !important; }
}@property --p {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@property --d {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
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);
}
.sm {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
height: 100%;
min-height: 164px;
justify-content: center;
}
.sm:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 3px;
border-radius: 12px;
}
.sm__svg {
transform: rotate(-90deg);
}
.sm__track {
fill: none;
stroke: rgba(23, 20, 26, 0.16);
stroke-width: 8;
}
.sm__ring {
fill: none;
stroke: #0a4fe8;
stroke-width: 8;
stroke-linecap: round;
--circ: 0;
--p: 0;
stroke-dasharray: var(--circ);
stroke-dashoffset: calc(var(--circ) * (1 - var(--p) / 100));
transition: stroke-dashoffset 220ms linear;
}
.sm__check {
fill: none;
stroke: #0a4fe8;
stroke-width: 8;
stroke-linecap: round;
stroke-linejoin: round;
transform: rotate(90deg);
transform-origin: 60px 60px;
--checklen: 0;
--d: 0;
stroke-dasharray: var(--checklen);
stroke-dashoffset: calc(var(--checklen) * (1 - var(--d) / 100));
opacity: 0;
}
.sm__ring {
opacity: 1;
}
.sm.is-done .sm__ring {
opacity: 0;
transition: opacity 140ms linear;
}
.sm.is-done .sm__check {
opacity: 1;
transition: stroke-dashoffset 600ms ease-out, opacity 140ms linear;
}
.sm.is-done .sm__check {
--d: 100;
}
.sm__bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.sm__label {
font-weight: 600;
font-size: 11px;
color: rgba(23, 20, 26, 0.82);
}
.sm__meta {
font-weight: 500;
font-size: 10px;
color: rgba(23, 20, 26, 0.62);
}
@keyframes smRingArc {
0% {
--p: 0;
}
66% {
--p: 100;
animation-timing-function: steps(1, end);
}
67% {
opacity: 0;
}
88% {
opacity: 0;
}
92% {
opacity: 0;
--p: 0;
}
94% {
opacity: 1;
}
100% {
opacity: 1;
--p: 0;
}
}
@keyframes smCheckDraw {
0%, 66% {
opacity: 0;
--d: 0;
}
67% {
opacity: 1;
animation-timing-function: ease-out;
}
80% {
--d: 100;
}
88% {
--d: 100;
opacity: 1;
}
92% {
opacity: 0;
--d: 0;
}
100% {
opacity: 0;
--d: 0;
}
}
.is-demo .sm__ring {
animation-name: smRingArc;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.is-demo .sm__check {
animation-name: smCheckDraw;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.sm__svg {
width: 60px;
height: 60px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .sm__ring, .is-demo .sm__check {
animation: none !important;
}
.is-demo .sm__ring {
opacity: 0 !important;
}
.is-demo .sm__check {
opacity: 1 !important;
--d: 100 !important;
}
}// 완료 모프 — 100% 가 되는 프레임에 링을 즉시 숨기고 체크 path 를 즉시 보이게 스왑한 뒤
// 체크의 stroke-dashoffset 을 transition 으로 0 까지 되감아(draw-in) 원이 체크로 이어져
// 그려진 것처럼 보이게 만든다. d 속성 자체는 절대 보간되지 않으므로 스왑은 항상 순간적이다.
(function () {
var SEED = 0x9917cf;
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = mulberry32(SEED);
var root = document.getElementById('smRoot');
var ring = document.getElementById('smRing');
var check = document.getElementById('smCheck');
if (!root || !ring || !check) return;
var ringBox = ring.getBoundingClientRect();
var ringR = ringBox.width > 0 ? ringBox.width / 2 : ring.r.baseVal.value;
var circ = 2 * Math.PI * ringR;
ring.style.setProperty('--circ', String(circ));
var checkLen = check.getTotalLength();
check.style.setProperty('--checklen', String(checkLen));
var target = 0, shown = 0, raf = null, timer = null;
function paint() {
shown += (target - shown) * 0.2;
if (Math.abs(target - shown) < 0.1) shown = target;
ring.style.setProperty('--p', shown.toFixed(2));
if (shown >= 100) {
root.classList.add('is-done');
raf = null;
return;
}
raf = requestAnimationFrame(paint);
}
function tick() {
if (root.classList.contains('is-demo')) return;
target = Math.min(100, target + 6 + rand() * 10);
if (target >= 100) clearInterval(timer);
}
function live() {
if (!root.classList.contains('is-demo')) return;
root.classList.remove('is-demo');
timer = setInterval(tick, 220);
raf = requestAnimationFrame(paint);
}
['pointerdown', 'focusin'].forEach(function (ev) { root.addEventListener(ev, live); });
})();// UploadProgressSuccessMorph — vanilla 판(run/329/demo/06-upload-progress-success-morph/index.html) 의 React 포트.
// 100% 가 되는 프레임에 링을 즉시 숨기고 체크 path 를 즉시 보이게 스왑한 뒤 체크의 stroke-dashoffset 을
// transition 으로 0 까지 되감아(draw-in) 원이 체크로 이어져 그려진 것처럼 보이게 만든다.
// d 속성 자체는 절대 보간되지 않으므로 스왑은 항상 순간적이다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UploadProgressSuccessMorph.module.scss";
export type UploadProgressSuccessMorphProps = {
duration?: string;
easing?: string;
color?: string;
seed?: number; // mulberry32 시드
stepBase?: number; // 매 틱 기본 증가분(퍼센트 포인트)
stepJitter?: number; // 매 틱 랜덤 증가분 상한
tickMs?: number; // 진행 틱 주기(ms)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const LABEL = "업로드 완료";
const META = "stroke-dasharray · path · transition";
const SEED = 0x9917cf;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
export default function UploadProgressSuccessMorph({
duration = "2s",
easing = "ease-out",
color = "#0a4fe8",
seed = SEED,
stepBase = 6,
stepJitter = 10,
tickMs = 220
}: UploadProgressSuccessMorphProps) {
const [demo, setDemo] = useState(true);
const [done, setDone] = useState(false);
const ringRef = useRef<SVGCircleElement | null>(null);
const checkRef = useRef<SVGPathElement | null>(null);
const targetRef = useRef(0) as Box<number>;
const shownRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const timerRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
useEffect(() => {
return () => {
window.clearInterval(timerRef.current);
cancelAnimationFrame(rafRef.current);
};
}, []);
function live(): void {
if (!demo) return;
setDemo(false);
const ring = ringRef.current;
const check = checkRef.current;
if (!ring || !check) return;
const rand = mulberry32(seed);
const ringBox = ring.getBoundingClientRect();
const ringR = ringBox.width > 0 ? ringBox.width / 2 : ring.r.baseVal.value;
const circ = 2 * Math.PI * ringR;
ring.style.setProperty("--circ", String(circ));
const checkLen = check.getTotalLength();
check.style.setProperty("--checklen", String(checkLen));
function paint(): void {
shownRef.current += (targetRef.current - shownRef.current) * 0.2;
if (Math.abs(targetRef.current - shownRef.current) < 0.1) shownRef.current = targetRef.current;
ring!.style.setProperty("--p", shownRef.current.toFixed(2));
if (shownRef.current >= 100) {
setDone(true);
return;
}
rafRef.current = requestAnimationFrame(paint);
}
function tick(): void {
targetRef.current = Math.min(100, targetRef.current + stepBase + rand() * stepJitter);
if (targetRef.current >= 100) window.clearInterval(timerRef.current);
}
timerRef.current = window.setInterval(tick, tickMs);
rafRef.current = requestAnimationFrame(paint);
}
const rootCls = [styles.sm, demo ? styles.isDemo : "", done ? styles.isDone : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div
className={rootCls}
style={vars}
role="group"
tabIndex={0}
aria-label="파일 업로드 완료 표시"
onPointerDown={live}
onFocus={live}
>
<svg className={styles.svg} viewBox="0 0 120 120" width="72" height="72" aria-hidden="true">
<circle className={styles.track} cx="60" cy="60" r="50"></circle>
<circle className={styles.ring} cx="60" cy="60" r="50" ref={ringRef}></circle>
<path className={styles.check} d="M40 62 L54 76 L82 46" ref={checkRef}></path>
</svg>
<div className={styles.bar}>
<span className={styles.label}>{LABEL}</span>
<span className={styles.meta}>{META}</span>
</div>
</div>
</main>
);
}06 완료 모프 — 링이 체크 모양으로 바뀌는 순간 — 측정 누적면적 1.049% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 파일 업로드 진행률 로더 9종 — 드래그부터 체크까지 계산
이 데모가 속한 글
파일 업로드 진행률 로더 9종 — 드래그부터 체크까지 계산파일 업로드 진행률 UI는 계산으로 그리는 로더입니다. 파일을 끌어다 놓으면 바이트와 시간을 계산해 링이 돌고 속도가 표시되고 끝나면 체크로 바뀝니다. 9가지 상태를 라이브러리 없이 계산으로 만드는 로더 — 코드는 이 글의 데모 전부 보기

