01Vẽ nét stroke
Path ngôi sao (pathLength=100) được vẽ ra rồi xoá đi chỉ bằng cách đổi stroke-dasharray và stroke-dashoffset.
Dùng choIcon trang trí cạnh badge tính năng mới
stroke-dasharray 100600ms빠르게 300ms
<main class="stage">
<p class="badge">
<svg class="badge__icon" viewBox="0 0 24 24" aria-hidden="true">
<path class="badge__trail" pathLength="100" d="M12 2l2.9 6.9L22 9.3l-5.5 4.8 1.6 7.1L12 17.5 5.9 21.2l1.6-7.1L2 9.3l7.1-.4z"/>
<path class="badge__star" pathLength="100" d="M12 2l2.9 6.9L22 9.3l-5.5 4.8 1.6 7.1L12 17.5 5.9 21.2l1.6-7.1L2 9.3l7.1-.4z"/>
<circle class="badge__spark" r="1" cx="0" cy="0"/>
<circle class="badge__bit" r=".55" cx="12" cy="2" style="--dx:0px;--dy:-2.6px"/>
<circle class="badge__bit" r=".5" cx="12" cy="2" style="--dx:2.1px;--dy:-1.4px"/>
<circle class="badge__bit" r=".5" cx="12" cy="2" style="--dx:-2.1px;--dy:-1.4px"/>
</svg>
Mới
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter; // 600ms — 한 바퀴(그리기→멈춤→지우기)
$easing: $ease-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.badge {
margin: 0;
display: inline-flex; align-items: center; gap: clamp($sp-2, 3vw, $sp-3);
color: $subject-cream; font-weight: 600; font-size: clamp(14px, 4vw, 18px); line-height: 1; font-family: inherit;
}
.badge__icon { width: clamp(28px, 8vw, 36px); height: clamp(28px, 8vw, 36px); }
.badge__star {
fill: none; stroke: $color; stroke-width: 1.6;
stroke-linejoin: round; stroke-linecap: round;
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; } // 반대쪽으로 지워지며 한 바퀴
}
// ── 한 겹: 펜 끝 스파크 + 미광 잔상 ──────────────────────
// 선 뒤에 남는 잔상 — 본선보다 늦게 지워져 펜이 지나간 자리가 잠깐 빛난다
.badge__trail {
fill: none; stroke: $color; stroke-width: 1.6;
stroke-linejoin: round; stroke-linecap: 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; }
}
// 펜 끝에서 반짝이는 스파크 — JS 가 getPointAtLength 로 좌표를 옮긴다
.badge__spark {
fill: $color;
opacity: 0; // JS 가 펜이 움직일 때만 1 로 켠다
transform: scale(1.6); transform-origin: center; transform-box: fill-box;
filter: drop-shadow(0 0 1px $color); // 미광 — 펜 끝이 살짝 발광한다
}
// 펜이 지나간 뒤 흩어지는 찌꺼기 — 진행 반대 방향으로 튄다
.badge__bit {
fill: $color;
opacity: 0;
transform: translate(0, 0);
animation: bit-fly 420ms $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) {
.badge__star { animation: none; stroke-dashoffset: 0; }
.badge__trail { display: none; }
.badge__spark { display: none; }
.badge__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);
}
.badge {
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;
}
.badge__icon {
width: clamp(28px, 8vw, 36px);
height: clamp(28px, 8vw, 36px);
}
.badge__star {
fill: none;
stroke: #fff7e6;
stroke-width: 1.6;
stroke-linejoin: round;
stroke-linecap: round;
stroke-dasharray: 100;
animation: draw 600ms ease-out infinite;
}
@keyframes draw {
0% {
stroke-dashoffset: 100;
}
55% {
stroke-dashoffset: 0;
}
80% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -100;
}
}
.badge__trail {
fill: none;
stroke: #fff7e6;
stroke-width: 1.6;
stroke-linejoin: round;
stroke-linecap: round;
stroke-dasharray: 100;
animation: trail-fade 600ms 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;
}
}
.badge__spark {
fill: #fff7e6;
opacity: 0;
transform: scale(1.6);
transform-origin: center;
transform-box: fill-box;
filter: drop-shadow(0 0 1px #fff7e6);
}
.badge__bit {
fill: #fff7e6;
opacity: 0;
transform: translate(0, 0);
animation: bit-fly 420ms 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) {
.badge__star {
animation: none;
stroke-dashoffset: 0;
}
.badge__trail {
display: none;
}
.badge__spark {
display: none;
}
.badge__bit {
display: none;
}
}// 스로브 드로잉 선 끝 스파크 — 선이 아니라 '펜 끝'이 움직인다. path.getTotalLength() 로 경로의
// 실측 길이를 얻어 getPointAtLength() 로 펜 끝 좌표를 계산해 스파크 <circle> 을 옮긴다.
// 방출 각도는 이전 좌표와의 차분으로 계산 — 애니메이션 타이밍을 하드코딩하지 않고
// getComputedStyle() 로 현재 stroke-dashoffset 실측값을 읽어 그려진 길이를 구한다.
(function () {
var icon = document.querySelector('.badge__icon');
if (!icon) return;
var star = icon.querySelector('.badge__star');
var spark = icon.querySelector('.badge__spark');
var bits = [].slice.call(icon.querySelectorAll('.badge__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark || !star.getTotalLength) { if (spark) spark.style.display = 'none'; return; }
var L = star.getTotalLength();
// dasharray:100 + pathLength:100 → offset 0..-100 (그리기), 0..-100 (지우기: 시작점부터 지워진다).
// 그리기 중 펜 끝 = 그려진 길이(100-off), 지우기 중 움직이는 경계 = 시작점부터(-off). 둘 다 pen 위치.
function penPos() {
var off = parseFloat(getComputedStyle(star).strokeDashoffset) || 0;
var at = off >= 0 ? 100 - off : -off; // 지워지는 동안 경계는 시작점 쪽으로 되돌아온다
return star.getPointAtLength(Math.max(0, Math.min(100, at)) / 100 * L);
}
var prev = null, lastEmit = 0, emitIdx = 0;
function frame(t) {
var p = penPos();
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; // 펜이 움직이는 동안만 스파크가 켜진다
// 이동 방향의 뒤쪽으로 찌꺼기 스파크 방출 (초당 최대 20개)
if (t - lastEmit > 50) {
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 * 6).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp * 6).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; // 멈춤 구간(55~80%)엔 펜도 쉰다
if (!prev) prev = p;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./StrokeDrawBadge.module.scss";
export type StrokeDrawBadgeProps = {
label?: string;
/** 한 바퀴(그리기→멈춤→지우기) 시간, ms */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const BIT_DIRS: Array<[number, number]> = [
[0, -2.6],
[2.1, -1.4],
[-2.1, -1.4],
];
export default function StrokeDrawBadge({
label = "새 기능",
duration = 600,
easing = "ease-out",
color = "#fff7e6",
}: StrokeDrawBadgeProps) {
const starRef = useRef<SVGPathElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
// 한 겹: 펜 끝 추적 — getTotalLength 실측 + getPointAtLength 로 스파크를 옮기고
// 이동 방향 뒤쪽으로 찌꺼기를 방출한다 (vanilla 데모와 동일 계산)
useEffect(() => {
const star = starRef.current, spark = sparkRef.current;
if (!star || !spark || !star.getTotalLength) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const L = star.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(star).strokeDashoffset) || 0;
const at = off >= 0 ? 100 - off : -off;
const p = star.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 > 50 && 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 * 6).toFixed(2) + "px");
b.style.setProperty("--dy", (Math.sin(ang) * sp * 6).toFixed(2) + "px");
b.style.setProperty("--spin", String(Math.round(Math.random() * 360)));
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.badge} style={vars}>
<svg className={styles.badge__icon} viewBox="0 0 24 24" aria-hidden="true">
<path
className={styles.badge__trail}
pathLength={100}
d="M12 2l2.9 6.9L22 9.3l-5.5 4.8 1.6 7.1L12 17.5 5.9 21.2l1.6-7.1L2 9.3l7.1-.4z"
/>
<path
ref={starRef}
className={styles.badge__star}
pathLength={100}
d="M12 2l2.9 6.9L22 9.3l-5.5 4.8 1.6 7.1L12 17.5 5.9 21.2l1.6-7.1L2 9.3l7.1-.4z"
/>
<circle ref={sparkRef} className={styles.badge__spark} r={1} cx={0} cy={0} />
{BIT_DIRS.map(([dx, dy], i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles.badge__bit}
r={i === 0 ? 0.55 : 0.5}
cx={12}
cy={2}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
{label}
</p>
);
}01 Vẽ nét stroke — Diện tích đo được 0.287% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 SVG Icon Animation CSS — dán 1 dòng là chạy


