09Check draw
A polyline checkmark appears as if drawn left to right by animating only stroke-dashoffset.
Best forConfirmation icons for a completed save or send
stroke-dashoffsetpolyline 체크300ms
<main class="stage">
<p class="confirm">
<svg class="confirm__icon" viewBox="0 0 24 24" width="64" height="64" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity=".35"/>
<polyline class="confirm__trail" points="7 12.5 10.5 16 17 8.5" pathLength="100" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline class="confirm__check" points="7 12.5 10.5 16 17 8.5" pathLength="100" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle class="confirm__spark" r="1" cx="7" cy="12.5"/>
<circle class="confirm__bit" r=".6" cx="7" cy="12.5" style="--dx:-1.8px;--dy:1.4px"/>
<circle class="confirm__bit" r=".5" cx="7" cy="12.5" style="--dx:-1.2px;--dy:-1.8px"/>
<circle class="confirm__bit" r=".5" cx="7" cy="12.5" style="--dx:1.4px;--dy:-1.6px"/>
</svg>
Saved
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base; // 300ms — 한 바퀴(그리기→멈춤→지우기)
$easing: $ease-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.confirm {
margin: 0;
display: inline-flex; align-items: center; gap: clamp($sp-2, 3vw, $sp-3);
color: $color; font-weight: 600; font-size: clamp(14px, 4vw, 18px); line-height: 1; font-family: inherit;
}
.confirm__icon { color: $color; }
.confirm__check {
stroke-dasharray: 100; // pathLength="100"
animation: draw $duration $easing infinite;
}
@keyframes draw {
0% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
// ── 한 겹: 펜 끝 스파크 + 미광 잔상 ──────────────────────
.confirm__trail {
fill: none; stroke: currentColor; stroke-width: 3.2;
stroke-linecap: round; stroke-linejoin: round;
stroke-dasharray: 100;
animation: trail-fade $duration $easing infinite;
}
@keyframes trail-fade {
0% { stroke-dashoffset: 100; opacity: .45; }
55% { stroke-dashoffset: 0; opacity: .45; }
85% { stroke-dashoffset: 0; opacity: .45; }
100% { stroke-dashoffset: -100; opacity: 0; }
}
.confirm__spark {
fill: currentColor;
opacity: 0;
transform: scale(1.5); transform-origin: center; transform-box: fill-box;
filter: drop-shadow(0 0 1px currentColor);
}
.confirm__bit {
fill: currentColor;
opacity: 0;
animation: bit-fly 380ms $ease-out forwards;
}
@keyframes bit-fly {
0% { opacity: .9; transform: translate(0, 0) scale(1); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.3); }
}
// ── 접근성: 움직임 줄이기 설정이면 완성된 체크만 남긴다 ──────
@media (prefers-reduced-motion: reduce) {
.confirm__check { animation: none; stroke-dashoffset: 0; }
.confirm__trail { display: none; }
.confirm__spark { display: none; }
.confirm__bit { display: 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);
}
.confirm {
margin: 0;
display: inline-flex;
align-items: center;
gap: clamp(8px, 3vw, 12px);
color: #fff7e6;
font-weight: 600;
font-size: clamp(14px, 4vw, 18px);
line-height: 1;
font-family: inherit;
}
.confirm__icon {
color: #fff7e6;
}
.confirm__check {
stroke-dasharray: 100;
animation: draw 300ms ease-out infinite;
}
@keyframes draw {
0% {
stroke-dashoffset: 100;
}
55% {
stroke-dashoffset: 0;
}
80% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -100;
}
}
.confirm__trail {
fill: none;
stroke: currentColor;
stroke-width: 3.2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
animation: trail-fade 300ms ease-out infinite;
}
@keyframes trail-fade {
0% {
stroke-dashoffset: 100;
opacity: 0.45;
}
55% {
stroke-dashoffset: 0;
opacity: 0.45;
}
85% {
stroke-dashoffset: 0;
opacity: 0.45;
}
100% {
stroke-dashoffset: -100;
opacity: 0;
}
}
.confirm__spark {
fill: currentColor;
opacity: 0;
transform: scale(1.5);
transform-origin: center;
transform-box: fill-box;
filter: drop-shadow(0 0 1px currentColor);
}
.confirm__bit {
fill: currentColor;
opacity: 0;
animation: bit-fly 380ms ease-out forwards;
}
@keyframes bit-fly {
0% {
opacity: 0.9;
transform: translate(0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate(var(--dx), var(--dy)) scale(0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.confirm__check {
animation: none;
stroke-dashoffset: 0;
}
.confirm__trail {
display: none;
}
.confirm__spark {
display: none;
}
.confirm__bit {
display: none;
}
}// 체크 드로잉 한 겹 — 그려지는 동안 펜 끝을 추적해 스파크를 옮기고 찌꺼기를 뿌린다.
// getTotalLength 실측 + getPointAtLength 계산. 지워지는 구간(offset<0)은 시작점 쪽 경계를 따른다.
(function () {
var svg = document.querySelector('.confirm__icon');
if (!svg) return;
var check = svg.querySelector('.confirm__check');
var spark = svg.querySelector('.confirm__spark');
var bits = [].slice.call(svg.querySelectorAll('.confirm__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark || !check.getTotalLength) { if (spark) spark.style.display = 'none'; return; }
var L = check.getTotalLength();
var prev = null, lastEmit = 0, emitIdx = 0;
function frame(t) {
var off = parseFloat(getComputedStyle(check).strokeDashoffset) || 0;
var at = off >= 0 ? 100 - off : -off;
var p = check.getPointAtLength(Math.max(0, Math.min(100, at)) / 100 * L);
if (prev && (Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y)) > .05) {
spark.setAttribute('cx', p.x);
spark.setAttribute('cy', p.y);
spark.style.opacity = 1;
if (t - lastEmit > 40) { // 체크는 짧아서 방출 간격도 짧게
lastEmit = t;
var ang = Math.atan2(p.y - prev.y, p.x - prev.x) + Math.PI;
var sp = .4 + Math.random() * .5;
var b = bits[emitIdx++ % bits.length];
b.setAttribute('cx', p.x);
b.setAttribute('cy', p.y);
b.style.setProperty('--dx', (Math.cos(ang) * sp * 4).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp * 4).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBBox;
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
prev = p;
} else {
if (prev) spark.style.opacity = 0;
if (!prev) prev = p;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CheckDraw.module.scss";
export type CheckDrawProps = {
label?: string;
/** 한 바퀴(그리기→멈춤→지우기) 시간, ms */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const BIT_DIRS: Array<[number, number]> = [
[-1.8, 1.4],
[-1.2, -1.8],
[1.4, -1.6],
];
export default function CheckDraw({
label = "저장 완료",
duration = 300,
easing = "ease-out",
color = "#fff7e6",
}: CheckDrawProps) {
const checkRef = useRef<SVGPolylineElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
// 한 겹: 펜 끝 추적 — getTotalLength 실측 + getPointAtLength 로 스파크를 옮기고
// 진행 반대 방향으로 찌꺼기를 뿌린다. 지워지는 구간(offset<0)은 시작점 쪽 경계를 따른다
// (vanilla 데모와 동일 계산)
useEffect(() => {
const check = checkRef.current, spark = sparkRef.current;
if (!check || !spark || !check.getTotalLength) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const L = check.getTotalLength();
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
let prev: DOMPoint | null = null, lastEmit = 0, emitIdx = 0, raf = 0;
const frame = (t: number) => {
const off = parseFloat(getComputedStyle(check).strokeDashoffset) || 0;
const at = off >= 0 ? 100 - off : -off;
const p = check.getPointAtLength(Math.max(0, Math.min(100, at)) / 100 * L);
if (prev && Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y) > 0.05) {
spark.setAttribute("cx", String(p.x));
spark.setAttribute("cy", String(p.y));
spark.style.opacity = "1";
if (t - lastEmit > 40 && bits.length) { // 체크는 짧아서 방출 간격도 짧게
lastEmit = t;
const ang = Math.atan2(p.y - prev.y, p.x - prev.x) + Math.PI;
const sp = 0.4 + Math.random() * 0.5;
const b = bits[emitIdx++ % bits.length];
b.setAttribute("cx", String(p.x));
b.setAttribute("cy", String(p.y));
b.style.setProperty("--dx", (Math.cos(ang) * sp * 4).toFixed(2) + "px");
b.style.setProperty("--dy", (Math.sin(ang) * sp * 4).toFixed(2) + "px");
b.style.animation = "none";
void b.getBoundingClientRect();
b.style.animation = "";
}
prev = p;
} else {
if (prev) spark.style.opacity = "0";
if (!prev) prev = p;
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<p className={styles.confirm} style={vars}>
<svg className={styles.confirm__icon} viewBox="0 0 24 24" width="64" height="64" aria-hidden="true">
<circle cx="12" cy={12} r={10} fill="none" stroke="currentColor" strokeWidth={2} opacity={0.35} />
<polyline
className={styles.confirm__trail}
points="7 12.5 10.5 16 17 8.5"
pathLength={100}
fill="none" stroke="currentColor" strokeWidth={3.2} strokeLinecap="round" strokeLinejoin="round"
/>
<polyline
ref={checkRef}
className={styles.confirm__check}
points="7 12.5 10.5 16 17 8.5"
pathLength={100}
fill="none" stroke="currentColor" strokeWidth={3.2} strokeLinecap="round" strokeLinejoin="round"
/>
<circle ref={sparkRef} className={styles.confirm__spark} r={1} cx={7} cy={12.5} />
{BIT_DIRS.map(([dx, dy], i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles.confirm__bit}
r={i === 0 ? 0.6 : 0.5}
cx={7}
cy={12.5}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
{label}
</p>
);
}09 Check draw — Measured area 0.34% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 SVG Icon Animation CSS — One Line, Copy-Paste


