9 SVG Icon Animation CSS — dán 1 dòng là chạy
Svg icon animation css là cách dựng icon play, chuông, refresh bằng path SVG có toạ độ, tạo chuyển động bằng transform và opacity thay cho glyph chữ phụ thuộc
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Vẽ nét stroke
- 02 Logo reveal
- 03 Icon morph
- 04 Chuông rung
- 05 Xoay refresh
- 06 Menu icon morph
- 07 Heart pulse
- 08 Mũi tên di chuyển
- 09 Vẽ check
Thứ tự chín mục đi theo vai trò của icon trên màn hình: mở đầu là hai icon trang trí đứng yên rồi mới hút mắt (01, 02), tiếp đến năm nút bấm đổi trạng thái khi bạn nhấn (03 đến 07), và kết bằng hai icon trang trí lặng lẽ đứng cạnh chữ (08, 09). Cả chín chỉ động đến transform và opacity, riêng ba mục vẽ nét (01, 02, 09) còn dùng thêm stroke-dasharray và dashoffset — hai giá trị này thuộc bước paint chứ không kéo trình duyệt tính lại layout như top hay width, theo tài liệu stroke-dasharray của MDN.
01Vẽ nét stroke
Bạn thấy gì
Path ngôi sao mang pathLength="100" được vẽ ra bằng cách đổi stroke-dashoffset từ 100 về 0, dừng một nhịp, rồi xoá theo chiều ngược lại về -100, lặp tự động không cần JS trong $dur-enter 600ms.
<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 · Xem mật khẩu
Dùng ở đâu
Đây là icon trang trí đứng cạnh dòng chữ hiển thị kiểu "tính năng mới", nên svg chỉ cần aria-hidden="true".
.badge__star {
fill: none; stroke: $color; stroke-width: 1.6;
stroke-dasharray: 100; // pathLength="100" nên coi dasharray như %
animation: draw $duration $easing infinite;
}
@keyframes draw {
0% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
02Logo reveal
Chuyện gì diễn ra trên màn hình
Hai path hình thoi giống hệt nhau chồng lên nhau — một bản chỉ có nét, một bản tô đặc đang ẩn ở opacity 0 — nét vẽ xong thì bản tô đặc hiện ra, rồi quay lại chỉ còn nét.
<main class="stage">
<p class="mark">
<svg viewBox="0 0 24 24" width="56" height="56" aria-hidden="true">
<path class="mark__fill" d="M12 3L21 12L12 21L3 12Z"/>
<path class="mark__trail" pathLength="100" d="M12 3L21 12L12 21L3 12Z"/>
<path class="mark__line" pathLength="100" d="M12 3L21 12L12 21L3 12Z"/>
<circle class="mark__spark" r="1" cx="12" cy="3"/>
<circle class="mark__bit" r=".5" cx="12" cy="3" style="--dx:0px;--dy:-2.4px"/>
<circle class="mark__bit" r=".45" cx="12" cy="3" style="--dx:2px;--dy:0px"/>
<circle class="mark__bit" r=".45" cx="12" cy="3" style="--dx:-2px;--dy:0px"/>
</svg>
GODRICH
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter; // 600ms — 선이 그려지는 시간
$easing: $ease-out;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.mark {
margin: 0;
display: inline-flex; align-items: center; gap: clamp($sp-2, 3vw, $sp-3);
color: $color; font-weight: 800; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit; letter-spacing: .08em;
}
.mark svg { width: clamp(44px, 13vw, 56px); height: clamp(44px, 13vw, 56px); }
.mark__fill {
fill: $color; opacity: 0;
animation: fill-in $duration $easing infinite;
}
.mark__line {
fill: none; stroke: $color; stroke-width: 1.6;
stroke-dasharray: 100; // pathLength="100"
animation: line-draw $duration $easing infinite;
}
// 1단계: 선으로 그린다 → 2단계: 면을 채운다 → 다시 선만 남기고 반복
@keyframes line-draw {
0% { stroke-dashoffset: 100; opacity: 1; }
55% { stroke-dashoffset: 0; opacity: 1; }
70% { opacity: 1; }
85% { opacity: 0; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes fill-in {
0%, 55% { opacity: 0; }
75%, 85% { opacity: 1; }
100% { opacity: 0; }
}
// ── 한 겹: 펜 끝 스파크 + 미광 잔상 ──────────────────────
// 본선 뒤에 남는 잔상 — 지워질 때 본선보다 반박자 늦게 사라져 펜 자국이 빛난다
.mark__trail {
fill: none; stroke: $color; stroke-width: 1.6;
stroke-dasharray: 100;
animation: trail-fade $duration $easing infinite;
}
@keyframes trail-fade {
0% { stroke-dashoffset: 100; opacity: .4; }
55% { stroke-dashoffset: 0; opacity: .4; }
78% { stroke-dashoffset: 0; opacity: .4; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
// 펜 끝 스파크 — 그리기 단계에만 JS 가 좌표를 옮기고 켠다
.mark__spark {
fill: $color;
opacity: 0;
transform: scale(1.5); transform-origin: center; transform-box: fill-box;
filter: drop-shadow(0 0 1px $color);
}
// 펜이 지난 자리에서 진행 반대 방향으로 튀는 찌꺼기
.mark__bit {
fill: $color;
opacity: 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) {
.mark__line { animation: none; stroke-dashoffset: 0; }
.mark__fill { animation: none; opacity: 1; }
.mark__trail { display: none; }
.mark__spark { display: none; }
.mark__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mark {
margin: 0;
display: inline-flex;
align-items: center;
gap: clamp(8px, 3vw, 12px);
color: #17141a;
font-weight: 800;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.08em;
}
.mark svg {
width: clamp(44px, 13vw, 56px);
height: clamp(44px, 13vw, 56px);
}
.mark__fill {
fill: #17141a;
opacity: 0;
animation: fill-in 600ms ease-out infinite;
}
.mark__line {
fill: none;
stroke: #17141a;
stroke-width: 1.6;
stroke-dasharray: 100;
animation: line-draw 600ms ease-out infinite;
}
@keyframes line-draw {
0% {
stroke-dashoffset: 100;
opacity: 1;
}
55% {
stroke-dashoffset: 0;
opacity: 1;
}
70% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
stroke-dashoffset: 0;
opacity: 0;
}
}
@keyframes fill-in {
0%, 55% {
opacity: 0;
}
75%, 85% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.mark__trail {
fill: none;
stroke: #17141a;
stroke-width: 1.6;
stroke-dasharray: 100;
animation: trail-fade 600ms ease-out infinite;
}
@keyframes trail-fade {
0% {
stroke-dashoffset: 100;
opacity: 0.4;
}
55% {
stroke-dashoffset: 0;
opacity: 0.4;
}
78% {
stroke-dashoffset: 0;
opacity: 0.4;
}
100% {
stroke-dashoffset: 0;
opacity: 0;
}
}
.mark__spark {
fill: #17141a;
opacity: 0;
transform: scale(1.5);
transform-origin: center;
transform-box: fill-box;
filter: drop-shadow(0 0 1px #17141a);
}
.mark__bit {
fill: #17141a;
opacity: 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) {
.mark__line {
animation: none;
stroke-dashoffset: 0;
}
.mark__fill {
animation: none;
opacity: 1;
}
.mark__trail {
display: none;
}
.mark__spark {
display: none;
}
.mark__bit {
display: none;
}
}// 로고 리빌 한 겹 — 선 그리기 단계(0~55%)에서만 펜 끝을 추적해 스파크를 옮기고 찌꺼기를 뿌린다.
// 계산 방식은 01 과 같다: getTotalLength 로 실측한 경로 위를 getPointAtLength 로 훑는다.
(function () {
var svg = document.querySelector('.mark svg');
if (!svg) return;
var line = svg.querySelector('.mark__line');
var spark = svg.querySelector('.mark__spark');
var bits = [].slice.call(svg.querySelectorAll('.mark__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark || !line.getTotalLength) { if (spark) spark.style.display = 'none'; return; }
var L = line.getTotalLength();
var prev = null, lastEmit = 0, emitIdx = 0;
function frame(t) {
var off = parseFloat(getComputedStyle(line).strokeDashoffset) || 0;
var at = Math.max(0, Math.min(100, 100 - off));
var p = line.getPointAtLength(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 > 60) {
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 * 5).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp * 5).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 "./LogoReveal.module.scss";
export type LogoRevealProps = {
wordmark?: 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.4],
[2, 0],
[-2, 0],
];
export default function LogoReveal({
wordmark = "GODRICH",
duration = 600,
easing = "ease-out",
color = "#17141a",
}: LogoRevealProps) {
const lineRef = 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 line = lineRef.current, spark = sparkRef.current;
if (!line || !spark || !line.getTotalLength) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const L = line.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(line).strokeDashoffset) || 0;
const at = Math.max(0, Math.min(100, 100 - off));
const p = line.getPointAtLength(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 > 60 && 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 * 5).toFixed(2) + "px");
b.style.setProperty("--dy", (Math.sin(ang) * sp * 5).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.mark} style={vars}>
<svg viewBox="0 0 24 24" width="56" height="56" aria-hidden="true">
<path className={styles.mark__fill} d="M12 3L21 12L12 21L3 12Z" />
<path className={styles.mark__trail} pathLength={100} d="M12 3L21 12L12 21L3 12Z" />
<path ref={lineRef} className={styles.mark__line} pathLength={100} d="M12 3L21 12L12 21L3 12Z" />
<circle ref={sparkRef} className={styles.mark__spark} r={1} cx={12} cy={3} />
{BIT_DIRS.map(([dx, dy], i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles.mark__bit}
r={i === 0 ? 0.5 : 0.45}
cx={12}
cy={3}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
{wordmark}
</p>
);
}02 · Logo reveal — Diện tích đo được 0.881% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Toàn bộ vòng lặp này chạy trong $dur-enter 600ms bằng $ease-out, cạnh nó là chữ wordmark thương hiệu nên svg vẫn gắn aria-hidden="true".
.mark__line { stroke-dasharray: 100; animation: line-draw $duration $easing infinite; }
@keyframes line-draw {
0% { stroke-dashoffset: 100; opacity: 1; }
55% { stroke-dashoffset: 0; opacity: 1; }
85% { opacity: 0; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes fill-in {
0%, 55% { opacity: 0; }
75%, 85% { opacity: 1; }
}
03Icon morph
Chuyển động thế nào
Tam giác play và hai vạch pause nằm trong hai <svg> riêng, xếp chồng cùng một ô grid-area(1/1) rồi đổi chỗ cho nhau bằng opacity và scale(.85→1) trong $dur-quick 220ms bằng $ease-pop mỗi khi bạn click.
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-pressed="false" aria-labelledby="t3">
<span class="iconbtn__stack">
<svg class="ic ic--play" viewBox="0 0 24 24" width="26" height="26">
<title id="t3">Phát</title>
<path d="M8 5l11 7-11 7z"/>
</svg>
<svg class="ic ic--pause" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M7 5h4v14H7zM13 5h4v14h-4z"/>
</svg>
<svg class="morph" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle class="morph__halo" cx="12" cy="12" r="7"/>
<path class="morph__ghost" d="M8 5l11 7-11 7z"/>
<circle class="morph__spark" r=".9" cx="12" cy="12"/>
<circle class="morph__bit" r=".5" cx="12" cy="11" style="--dx:0px;--dy:-3.2px"/>
<circle class="morph__bit" r=".45" cx="12" cy="11" style="--dx:2.8px;--dy:1.6px"/>
<circle class="morph__bit" r=".45" cx="12" cy="11" style="--dx:-2.8px;--dy:1.6px"/>
<circle class="morph__bit" r=".45" cx="12" cy="11" style="--dx:-2.2px;--dy:-1.8px"/>
<circle class="morph__bit" r=".45" cx="12" cy="11" style="--dx:2.2px;--dy:1.8px"/>
<circle class="morph__bit" r=".45" cx="12" cy="11" style="--dx:2px;--dy:-2.2px"/>
</svg>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick; // 220ms — 실제 클릭 전환 시간
$easing: $ease-pop;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.iconbtn {
border: 0; border-radius: 50%; cursor: pointer;
width: clamp(52px, 14vw, 64px); height: clamp(52px, 14vw, 64px);
display: grid; place-items: center;
background: rgba(23, 20, 26, .18); color: $color;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
.iconbtn__stack { display: grid; place-items: center; width: 100%; height: 100%; }
// 두 아이콘을 같은 칸(grid-area)에 겹쳐 놓고 opacity·scale로 서로 바꾼다
.ic {
grid-area: 1 / 1; fill: currentColor;
transition: opacity $duration $easing, transform $duration $easing;
transform-box: fill-box; transform-origin: 50% 50%;
}
.ic--play { opacity: 1; }
.ic--pause { opacity: 0; transform: scale(.85); }
.iconbtn[aria-pressed="true"] .ic--play { opacity: 0; transform: scale(.85); }
.iconbtn[aria-pressed="true"] .ic--pause { opacity: 1; transform: scale(1); }
// ── 데모 루프: 클릭 전까지 재생↔일시정지를 스스로 오간다 ──────
.iconbtn.is-demo .ic--play { animation: play-loop $dur-loop $easing infinite; }
.iconbtn.is-demo .ic--pause { animation: pause-loop $dur-loop $easing infinite; }
@keyframes play-loop { 0%, 15% { opacity: 1; transform: scale(1); } 40%, 60% { opacity: 0; transform: scale(.85); } 85%, 100% { opacity: 1; transform: scale(1); } }
@keyframes pause-loop { 0%, 15% { opacity: 0; transform: scale(.85); } 40%, 60% { opacity: 1; transform: scale(1); } 85%, 100% { opacity: 0; transform: scale(.85); } }
// ── 한 겹: 모프 순간 고스트 링 + 입자 방출 ──────────────────────
// 아이콘이 서로 바뀌는 순간 잠깐 나타났다 지워지는 고스트 실루엣 — 잔상
.morph {
grid-area: 1 / 1; pointer-events: none; // 아이콘 격자와 같은 칸, 클릭 방해 안 함
fill: $color;
}
// 링 파동 — 모프 순간 중앙에서 퍼지며 옅어지는 미광
.morph__halo {
fill: none;
stroke: $color;
stroke-width: .8;
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
}
@keyframes halo-ring {
0% { opacity: .55; transform: scale(.4); }
100% { opacity: 0; transform: scale(1.7); }
}
// 중앙 스파크 — 모프가 터지는 순간 밝게 점멸
.morph__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px $color);
}
@keyframes spark-pop {
0% { opacity: .95; transform: scale(1.8); }
60% { opacity: .4; transform: scale(.8); }
100% { opacity: 0; transform: scale(.3); }
}
.morph__ghost {
fill: none; stroke: $color; stroke-width: 1.4;
transform-box: fill-box; transform-origin: 50% 50%;
opacity: 0; // JS 가 모프 순간에만 켠다
}
@keyframes ghost-ring {
0% { opacity: .5; stroke-width: 1.4; transform: scale(.7); }
100% { opacity: 0; stroke-width: .2; transform: scale(1.5); }
}
// 모프 순간 사방으로 흩어지는 입자
.morph__bit {
fill: $color;
opacity: 0;
transform-box: fill-box; transform-origin: 50% 50%;
animation: bit-fly 460ms $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(.25); }
}
@media (prefers-reduced-motion: reduce) {
.iconbtn.is-demo .ic--play, .iconbtn.is-demo .ic--pause { animation: none; }
.ic { transition: none; }
.morph { display: none; }
}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);
}
.iconbtn {
border: 0;
border-radius: 50%;
cursor: pointer;
width: clamp(52px, 14vw, 64px);
height: clamp(52px, 14vw, 64px);
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.18);
color: #fff7e6;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.iconbtn__stack {
display: grid;
place-items: center;
width: 100%;
height: 100%;
}
.ic {
grid-area: 1/1;
fill: currentColor;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
transform-box: fill-box;
transform-origin: 50% 50%;
}
.ic--play {
opacity: 1;
}
.ic--pause {
opacity: 0;
transform: scale(0.85);
}
.iconbtn[aria-pressed=true] .ic--play {
opacity: 0;
transform: scale(0.85);
}
.iconbtn[aria-pressed=true] .ic--pause {
opacity: 1;
transform: scale(1);
}
.iconbtn.is-demo .ic--play {
animation: play-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.iconbtn.is-demo .ic--pause {
animation: pause-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes play-loop {
0%, 15% {
opacity: 1;
transform: scale(1);
}
40%, 60% {
opacity: 0;
transform: scale(0.85);
}
85%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes pause-loop {
0%, 15% {
opacity: 0;
transform: scale(0.85);
}
40%, 60% {
opacity: 1;
transform: scale(1);
}
85%, 100% {
opacity: 0;
transform: scale(0.85);
}
}
.morph {
grid-area: 1/1;
pointer-events: none;
fill: #fff7e6;
}
.morph__halo {
fill: none;
stroke: #fff7e6;
stroke-width: 0.8;
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
}
@keyframes halo-ring {
0% {
opacity: 0.55;
transform: scale(0.4);
}
100% {
opacity: 0;
transform: scale(1.7);
}
}
.morph__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px #fff7e6);
}
@keyframes spark-pop {
0% {
opacity: 0.95;
transform: scale(1.8);
}
60% {
opacity: 0.4;
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(0.3);
}
}
.morph__ghost {
fill: none;
stroke: #fff7e6;
stroke-width: 1.4;
transform-box: fill-box;
transform-origin: 50% 50%;
opacity: 0;
}
@keyframes ghost-ring {
0% {
opacity: 0.5;
stroke-width: 1.4;
transform: scale(0.7);
}
100% {
opacity: 0;
stroke-width: 0.2;
transform: scale(1.5);
}
}
.morph__bit {
fill: #fff7e6;
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
animation: bit-fly 460ms 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.25);
}
}
@media (prefers-reduced-motion: reduce) {
.iconbtn.is-demo .ic--play, .iconbtn.is-demo .ic--pause {
animation: none;
}
.ic {
transition: none;
}
.morph {
display: none;
}
}// 의미 있는 아이콘 버튼: <title>이 접근 가능한 이름이고, 클릭마다 상태에 맞게 바꾼다
document.querySelectorAll(".iconbtn").forEach(function (b) {
var t = b.querySelector("title");
b.addEventListener("click", function () {
b.classList.remove("is-demo");
var on = b.getAttribute("aria-pressed") === "true";
b.setAttribute("aria-pressed", String(!on));
t.textContent = on ? "Phát" : "Tạm dừng";
});
});
// 모프 한 겹 — 전환 순간(Phát→Tạm dừng, Tạm dừng→Phát)에 링 궤적 + 입자 방출.
// 애니메이션 타이밍을 하드코딩하지 않고 getComputedStyle() 로 아이콘 opacity 실측값을
// 읽어 "페이드가 진행 중"인 순간을 감지해 그 순간만 발화한다 (01 스파크와 같은 원리).
(function () {
var btn = document.querySelector('.iconbtn');
if (!btn) return;
var ghost = btn.querySelector('.morph__ghost');
var halo = btn.querySelector('.morph__halo');
var spark = btn.querySelector('.morph__spark');
var bits = [].slice.call(btn.querySelectorAll('.morph__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !ghost || !bits.length) return;
var prevOp = null, armed = true;
var dPlay = btn.querySelector('.ic--play path');
var dPause = btn.querySelector('.ic--pause path');
// 모프 순간 고스트 — '나타나는' 아이콘의 실루엣이 링처럼 번지며 지워진다 (잔상)
function burst(d) {
ghost.setAttribute('d', d);
ghost.style.animation = 'none';
void ghost.getBoundingClientRect();
ghost.style.animation = '';
ghost.style.animationName = 'ghost-ring';
// 링 파동 — 중앙에서 퍼지며 옅어지는 미광 (06 halo 와 같은 원리)
if (halo) {
halo.style.animation = 'none';
void halo.getBoundingClientRect();
halo.style.animation = '';
halo.style.animationName = 'halo-ring';
}
// 중앙 스파크 점멸
if (spark) {
spark.style.animation = 'none';
void spark.getBoundingClientRect();
spark.style.animation = '';
spark.style.animationName = 'spark-pop';
}
bits.forEach(function (b) {
var ang = Math.random() * Math.PI * 2;
var sp = 2.4 + Math.random() * 1.4;
b.style.setProperty('--dx', (Math.cos(ang) * sp).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
});
}
function watch() {
// is-demo 루프 중엔 play 루프의 opacity 로 전환 순간을 잡는다
var target = btn.classList.contains('is-demo')
? btn.querySelector('.ic--play')
: (btn.getAttribute('aria-pressed') === 'true' ? btn.querySelector('.ic--pause') : btn.querySelector('.ic--play'));
if (target) {
var op = parseFloat(getComputedStyle(target).opacity);
var moving = op > 0.02 && op < 0.98;
// 전환당 한 번만: 페이드 시작 순간에 발화하고(opacity 가 완전히 멈출 때까지 재장전 안 함)
if (moving && armed) {
// 나타나는 아이콘: 루프 중엔 opacity 상승=play 복귀·하강=pause 진입,
// 클릭 후엔 target 자체가 나타나는 아이콘이다
var rising = prevOp === null || op > prevOp;
var incoming = btn.classList.contains('is-demo') ? (rising ? dPlay : dPause)
: (target.classList.contains('ic--play') ? dPlay : dPause);
burst(incoming.getAttribute('d'));
armed = false;
}
if (!moving && prevOp !== null && Math.abs(op - prevOp) < 0.002) armed = true;
prevOp = op;
}
requestAnimationFrame(watch);
}
requestAnimationFrame(watch);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PlayPauseIcon.module.scss";
export type PlayPauseIconProps = {
/** 재생↔일시정지 전환 시간, ms */
duration?: number;
easing?: string;
color?: string;
defaultPlaying?: boolean;
onChange?: (playing: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const D_PLAY = "M8 5l11 7-11 7z";
const D_PAUSE = "M7 5h4v14H7zM13 5h4v14h-4z";
export default function PlayPauseIcon({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
defaultPlaying = false,
onChange,
}: PlayPauseIconProps) {
const [playing, setPlaying] = useState(defaultPlaying);
// 모프 순간마다 n +1 → 링·입자 재시작. dirs 는 방출마다 다시 뽑는다 (vanilla 와 동일)
const [burst, setBurst] = useState<{ n: number; dirs: Array<[number, number]>; d: string }>({ n: 0, dirs: [], d: D_PLAY });
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const reduceRef = useRef(false);
// 모프 한 겹: prefers-reduced-motion 이면 발화하지 않는다 (CSS 층에서 display:none 처리)
useEffect(() => {
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}, []);
return (
<button
type="button"
className={styles.iconbtn}
style={vars}
aria-pressed={playing}
aria-label={playing ? "일시정지" : "재생"}
onClick={() => {
const next = !playing;
setPlaying(next);
if (!reduceRef.current) {
setBurst((b) => ({
n: b.n + 1,
d: next ? D_PAUSE : D_PLAY, // 나타나는 아이콘의 실루엣이 번진다
dirs: [0, 1, 2, 3, 4, 5].map(() => {
const ang = Math.random() * Math.PI * 2;
const sp = 2.4 + Math.random() * 1.4;
return [Math.cos(ang) * sp, Math.sin(ang) * sp] as [number, number];
}),
}));
}
onChange?.(next);
}}
>
<span className={styles.iconbtn__stack}>
<svg className={`${styles.ic} ${playing ? styles["ic--hidden"] : ""}`} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d={D_PLAY} />
</svg>
<svg className={`${styles.ic} ${playing ? "" : styles["ic--hidden"]}`} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d={D_PAUSE} />
</svg>
{/* 한 겹: 모프 순간 고스트 링 + 입자 — key 바뀌면 애니메이션이 재시작된다.
고스트는 '나타나는' 아이콘의 실루엣이 링처럼 번지며 지워진다 (잔상) */}
{burst.n > 0 && (
<svg key={burst.n} className={styles.morph} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle className={styles["morph__halo"]} cx={12} cy={12} r={7} />
<path className={styles["morph__ghost"]} d={burst.d} />
<circle className={styles["morph__spark"]} r={0.9} cx={12} cy={12} />
{burst.dirs.map(([dx, dy], i) => (
<circle
key={i}
className={styles["morph__bit"]}
r={i === 0 ? 0.5 : 0.45}
cx={12}
cy={11}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
)}
</span>
</button>
);
}03 · Icon morph — Diện tích đo được 0.135% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Vì nút chỉ có icon, <title> bên trong svg được cập nhật đúng trạng thái và nút gắn aria-labelledby trỏ tới title đó.
.ic {
grid-area: 1 / 1; fill: currentColor;
transition: opacity $duration $easing, transform $duration $easing;
transform-box: fill-box; transform-origin: 50% 50%;
}
.ic--play { opacity: 1; }
.ic--pause { opacity: 0; transform: scale(.85); }
.iconbtn[aria-pressed="true"] .ic--play { opacity: 0; transform: scale(.85); }
.iconbtn[aria-pressed="true"] .ic--pause { opacity: 1; transform: scale(1); }
04Chuông rung
Bạn thấy gì
<g> bọc icon chuông rung sang hai bên quanh điểm treo trên đỉnh, còn chấm badge phía trên phồng lên xẹp xuống theo đúng nhịp đó trong $dur-base 300ms.
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-label="3 thông báo">
<svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true">
<g class="bell">
<path d="M12 3a5 5 0 0 0-5 5v3.2c0 1-.4 2-1.1 2.7L4 16h16l-1.9-2.1c-.7-.7-1.1-1.7-1.1-2.7V8a5 5 0 0 0-5-5z"/>
<path class="bell__clap" d="M9.5 19a2.5 2.5 0 0 0 5 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</g>
<circle class="badge-dot" cx="18.5" cy="5.5" r="3"/>
<g class="ring">
<path class="ring__orbit" pathLength="100" fill="none" d="M4 16h16"/>
<circle class="ring__spark" r=".9" cx="4" cy="16"/>
<circle class="ring__bit" r=".4" cx="4" cy="16" style="--dx:0px;--dy:-2.2px"/>
<circle class="ring__bit" r=".35" cx="4" cy="16" style="--dx:2px;--dy:0px"/>
<circle class="ring__bit" r=".35" cx="4" cy="16" style="--dx:-2px;--dy:0px"/>
</g>
</svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base; // 300ms — 흔들리는 시간
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.iconbtn {
border: 0; border-radius: $r-pill; cursor: pointer;
width: clamp(52px, 14vw, 64px); height: clamp(52px, 14vw, 64px);
display: grid; place-items: center;
background: rgba(23, 20, 26, .06); color: $subject-ink;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
// transform-box: fill-box 가 없으면 회전축이 SVG 뷰포트(0,0)~(24,24) 기준이라
// 종 모양이 왼쪽 위로 쏠려 돈다. fill-box 로 종 자신의 경계 상자를 축으로 쓴다
.bell {
transform-box: fill-box;
transform-origin: 50% 0%; // 종의 꼭지점(위쪽 중앙)
}
.badge-dot {
fill: $color;
transform-box: fill-box;
transform-origin: 50% 50%;
}
// ── 데모 루프: 새 알림처럼 주기적으로 흔들리고 배지가 맥동한다 ──────
.iconbtn.is-demo .bell { animation: shake $dur-loop $easing infinite; }
.iconbtn.is-demo .badge-dot { animation: pulse $dur-loop $easing infinite; }
@keyframes shake {
0%, 30%, 100% { transform: rotate(0); }
4% { transform: rotate(8deg); }
8% { transform: rotate(-8deg); }
12% { transform: rotate(6deg); }
16% { transform: rotate(-6deg); }
20% { transform: rotate(3deg); }
24% { transform: rotate(-3deg); }
}
@keyframes pulse {
0%, 30%, 100% { transform: scale(1); }
10% { transform: scale(1.3); }
20% { transform: scale(1.1); }
}
// ── 한 겹: 종 밑단을 내닫는 스파크 + 미광 잔상 ──────────────────────
// 종이 부딪힐 때마다 받침 라인을 따라 에너지가 좌우로 흐른다 — JS 가 종의 실제
// 회전각을 읽어 스파크를 옮긴다. 잔상(orbit)은 JS 가 흔들림 구간에만 인라인으로 켠다.
.ring { pointer-events: none; }
.ring__orbit {
stroke: $color; stroke-width: 1.2; stroke-linecap: round;
stroke-dasharray: 100; // pathLength="100"
opacity: 0;
transition: opacity 240ms $easing; // JS 토글이 부드럽게
filter: drop-shadow(0 0 1px $color); // 미광
}
.ring__spark {
fill: $color;
opacity: 0;
transform: scale(1.5); transform-origin: center; transform-box: fill-box;
filter: drop-shadow(0 0 1px $color);
}
.ring__bit {
fill: $color;
opacity: 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) {
.iconbtn.is-demo .bell, .iconbtn.is-demo .badge-dot { animation: none; }
.ring { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.iconbtn {
border: 0;
border-radius: 999px;
cursor: pointer;
width: clamp(52px, 14vw, 64px);
height: clamp(52px, 14vw, 64px);
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.bell {
transform-box: fill-box;
transform-origin: 50% 0%;
}
.badge-dot {
fill: #2f6df6;
transform-box: fill-box;
transform-origin: 50% 50%;
}
.iconbtn.is-demo .bell {
animation: shake 2s ease-out infinite;
}
.iconbtn.is-demo .badge-dot {
animation: pulse 2s ease-out infinite;
}
@keyframes shake {
0%, 30%, 100% {
transform: rotate(0);
}
4% {
transform: rotate(8deg);
}
8% {
transform: rotate(-8deg);
}
12% {
transform: rotate(6deg);
}
16% {
transform: rotate(-6deg);
}
20% {
transform: rotate(3deg);
}
24% {
transform: rotate(-3deg);
}
}
@keyframes pulse {
0%, 30%, 100% {
transform: scale(1);
}
10% {
transform: scale(1.3);
}
20% {
transform: scale(1.1);
}
}
.ring {
pointer-events: none;
}
.ring__orbit {
stroke: #2f6df6;
stroke-width: 1.2;
stroke-linecap: round;
stroke-dasharray: 100;
opacity: 0;
transition: opacity 240ms ease-out;
filter: drop-shadow(0 0 1px #2f6df6);
}
.ring__spark {
fill: #2f6df6;
opacity: 0;
transform: scale(1.5);
transform-origin: center;
transform-box: fill-box;
filter: drop-shadow(0 0 1px #2f6df6);
}
.ring__bit {
fill: #2f6df6;
opacity: 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) {
.iconbtn.is-demo .bell, .iconbtn.is-demo .badge-dot {
animation: none;
}
.ring {
display: none;
}
}// 벨 흔들림 한 겹 — 종이 흔들리는 동안 밑단(받침 라인)을 따라 스파크가 좌우로 내닫는다.
// 01 과 같은 원리(getPointAtLength 좌표 계산)지만 타이밍은 하드코딩하지 않는다:
// 종의 실제 회전각(getComputedStyle transform 행렬)을 매 프레임 읽어
// 회전각 ±8° 를 받침 라인 0..1 진행도로 사상한다 — CSS 애니메이션과 물리적으로 동기.
(function () {
var btn = document.querySelector('.iconbtn');
if (!btn) return;
var bell = btn.querySelector('.bell');
var orbit = btn.querySelector('.ring__orbit');
var spark = btn.querySelector('.ring__spark');
var bits = [].slice.call(btn.querySelectorAll('.ring__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !bell || !orbit || !spark || !orbit.getTotalLength) return;
var L = orbit.getTotalLength();
var prevA = null, prevP = null, lastEmit = 0, emitIdx = 0;
function angle() { // 종의 현재 회전각(도) — transform 이 none 이면 0
var tr = getComputedStyle(bell).transform;
if (!tr || tr === 'none') return 0;
var m = tr.match(/matrix\(([^)]+)\)/);
if (!m) return 0;
var v = m[1].split(',').map(parseFloat);
return Math.atan2(v[1], v[0]) * 180 / Math.PI;
}
function frame(t) {
var a = angle();
if (prevA !== null) {
if (Math.abs(a - prevA) > .05) { // 종이 움직이는 동안만
orbit.style.opacity = '.4'; // 잔상 켬 (transition 이 부드럽게)
var u = Math.max(0, Math.min(1, .5 + a / 16)); // ±8° → 0..1
var p = orbit.getPointAtLength(u * L);
spark.setAttribute('cx', p.x);
spark.setAttribute('cy', p.y);
spark.style.opacity = 1;
if (prevP && (Math.abs(p.x - prevP.x) + Math.abs(p.y - prevP.y)) > .01 && t - lastEmit > 55) {
lastEmit = t;
var ang = Math.atan2(p.y - prevP.y, p.x - prevP.x) + Math.PI; // 진행 반대 = 잔상 쪽
var sp = .35 + Math.random() * .45;
var b = bits[emitIdx++ % bits.length];
b.setAttribute('cx', p.x);
b.setAttribute('cy', p.y);
b.style.setProperty('--dx', (Math.cos(ang) * sp * 5).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp * 5).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
prevP = p;
} else { // 멈춤 — 스파크·잔상 끔
spark.style.opacity = 0;
orbit.style.opacity = '';
prevP = null;
}
}
prevA = a;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./BellShake.module.scss";
export type BellShakeProps = {
count?: number;
/** 흔들리는 시간, ms */
duration?: number;
easing?: string;
color?: string;
onClick?: () => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const BIT_DIRS: Array<[number, number]> = [
[0, -2.2],
[2, 0],
[-2, 0],
];
export default function BellShake({
count = 3,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
onClick,
}: BellShakeProps) {
const orbitRef = useRef<SVGPathElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as Box<Array<SVGCircleElement | null>>;
const bellRef = useRef<SVGGElement>(null);
// 한 겹: 종 밑단을 내닫는 스파크 — 종의 실제 회전각(getComputedStyle transform 행렬)을
// 매 프레임 읽어 ±8° 를 받침 라인 0..1 진행도로 사상한다 (vanilla 데모와 동일 계산).
// CSS 애니메이션과 물리적으로 동기라 트리거 방식(data-shaking·데모 루프)과 무관하다.
useEffect(() => {
const bell = bellRef.current, orbit = orbitRef.current, spark = sparkRef.current;
if (!bell || !orbit || !spark || !orbit.getTotalLength) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const L = orbit.getTotalLength();
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
let prevA: number | null = null, prevP: DOMPoint | null = null, lastEmit = 0, emitIdx = 0, raf = 0;
const angle = () => { // 종의 현재 회전각(도) — transform 이 none 이면 0
const tr = getComputedStyle(bell).transform;
if (!tr || tr === "none") return 0;
const m = tr.match(/matrix\(([^)]+)\)/);
if (!m) return 0;
const v = m[1].split(",").map(parseFloat);
return Math.atan2(v[1], v[0]) * 180 / Math.PI;
};
const frame = (t: number) => {
const a = angle();
if (prevA !== null) {
if (Math.abs(a - prevA) > 0.05) { // 종이 움직이는 동안만
orbit.style.opacity = "0.4"; // 잔상 켬 (transition 이 부드럽게)
const u = Math.max(0, Math.min(1, 0.5 + a / 16)); // ±8° → 0..1
const p = orbit.getPointAtLength(u * L);
spark.setAttribute("cx", String(p.x));
spark.setAttribute("cy", String(p.y));
spark.style.opacity = "1";
if (prevP && Math.abs(p.x - prevP.x) + Math.abs(p.y - prevP.y) > 0.01 && t - lastEmit > 55 && bits.length) {
lastEmit = t;
const ang = Math.atan2(p.y - prevP.y, p.x - prevP.x) + Math.PI; // 진행 반대 = 잔상 쪽
const sp = 0.35 + Math.random() * 0.45;
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 * 5).toFixed(2) + "px");
b.style.setProperty("--dy", (Math.sin(ang) * sp * 5).toFixed(2) + "px");
b.style.animation = "none";
void b.getBoundingClientRect();
b.style.animation = "";
}
prevP = p;
} else { // 멈춤 — 스파크·잔상 끔
spark.style.opacity = "0";
orbit.style.opacity = "";
prevP = null;
}
}
prevA = a;
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.iconbtn} style={vars} aria-label={`알림 ${count}개`} onClick={onClick}>
<svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true">
<g ref={bellRef} className={styles.bell}>
<path d="M12 3a5 5 0 0 0-5 5v3.2c0 1-.4 2-1.1 2.7L4 16h16l-1.9-2.1c-.7-.7-1.1-1.7-1.1-2.7V8a5 5 0 0 0-5-5z" />
<path className={styles.bell__clap} d="M9.5 19a2.5 2.5 0 0 0 5 0" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" />
</g>
{count > 0 && <circle className={styles["badge-dot"]} cx="18.5" cy="5.5" r="3" />}
<g className={styles.ring}>
<path ref={orbitRef} className={styles["ring__orbit"]} pathLength={100} fill="none" d="M4 16h16" />
<circle ref={sparkRef} className={styles["ring__spark"]} r="0.9" cx="4" cy="16" />
{BIT_DIRS.map(([dx, dy], i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles["ring__bit"]}
r={i === 0 ? "0.4" : "0.35"}
cx="4"
cy="16"
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</g>
</svg>
</button>
);
}04 · Chuông rung — Diện tích đo được 0.233% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Bản đầu chỉ rung ở 15% vòng lặp nên số khung hình đổi ảnh đo được quá thấp, phải kéo dài đoạn rung lên 30% mới đạt ngưỡng.
.bell { transform-box: fill-box; transform-origin: 50% 0%; }
.iconbtn.is-demo .bell { animation: shake $dur-loop $easing infinite; }
@keyframes shake {
0%, 30%, 100% { transform: rotate(0); }
4% { transform: rotate(8deg); }
8% { transform: rotate(-8deg); }
16% { transform: rotate(-6deg); }
24% { transform: rotate(-3deg); }
}
05Xoay refresh
Chuyện gì diễn ra trên màn hình
Icon refresh gồm hai mũi tên không nằm giữa viewport 24×24, nên transform-box: fill-box đổi trục xoay sang đúng tâm hình dạng của chính nó thay vì góc trên trái SVG.
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-label="Làm mới">
<svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true">
<g class="refresh-ghost">
<path d="M4 12a8 8 0 0 1 13.9-5.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M20 12a8 8 0 0 1-13.9 5.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M17.5 3.2v4.4h-4.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 20.8v-4.4h4.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g class="refresh">
<path d="M4 12a8 8 0 0 1 13.9-5.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M20 12a8 8 0 0 1-13.9 5.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M17.5 3.2v4.4h-4.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 20.8v-4.4h4.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<circle class="refresh-spark" r="0.9" cx="17.9" cy="3.2"/>
<circle class="refresh-bit" r=".5" cx="17.9" cy="3.2" style="--dx:0px;--dy:-2.6px"/>
<circle class="refresh-bit" r=".45" cx="17.9" cy="3.2" style="--dx:2.1px;--dy:-1.4px"/>
<circle class="refresh-bit" r=".45" cx="6.1" cy="20.8" style="--dx:-2.1px;--dy:1.4px"/>
<circle class="refresh-bit" r=".5" cx="6.1" cy="20.8" style="--dx:0px;--dy:2.6px"/>
</svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter; // 600ms — 한 바퀴
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.iconbtn {
border: 0; border-radius: $r-pill; cursor: pointer;
width: clamp(52px, 14vw, 64px); height: clamp(52px, 14vw, 64px);
display: grid; place-items: center;
background: rgba(255, 247, 230, .1); color: $color;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
// 화살표 두 개가 만드는 도형은 24×24 뷰박스의 정중앙에 있지 않다.
// transform-box: fill-box 로 이 도형 자신의 경계 상자 중심을 축으로 돌린다
.refresh {
transform-box: fill-box;
transform-origin: 50% 50%;
}
.iconbtn.is-spinning .refresh { animation: spin-once $duration $easing; }
.iconbtn.is-demo .refresh { animation: spin-loop $dur-loop $easing infinite; }
@keyframes spin-once { to { transform: rotate(360deg); } }
@keyframes spin-loop {
0%, 10% { transform: rotate(0); }
55% { transform: rotate(360deg); }
100% { transform: rotate(360deg); }
}
// ── 한 겹: 화살촉 스파크 + 미광 잔상 ──────────────────────
// 잔상 — 본체와 같은 타임라인을 공유하되 각도를 뒤처지게 해, 도는 동안에만 뒤따라 빛난다
.refresh-ghost {
transform-box: fill-box;
transform-origin: 50% 50%;
opacity: 0; // 멈춰 있을 땐 보이지 않는다
filter: blur(.6px);
}
.iconbtn.is-spinning .refresh-ghost { animation: ghost-once $duration $ease-out; }
.iconbtn.is-demo .refresh-ghost { animation: ghost-loop $dur-loop $ease-out infinite; }
@keyframes ghost-once {
0% { transform: rotate(0); opacity: 0; }
60% { transform: rotate(260deg); opacity: .4; } // 본체보다 뒤처진 각도
100% { transform: rotate(360deg); opacity: 0; }
}
@keyframes ghost-loop {
0%, 10% { transform: rotate(0); opacity: 0; }
30% { transform: rotate(170deg); opacity: .4; }
55% { transform: rotate(330deg); opacity: .3; }
62%, 100% { transform: rotate(360deg); opacity: 0; }
}
// 화살촉 끝 스파크 — JS 가 실측 회전각으로 좌표를 옮긴다
.refresh-spark {
fill: $color;
opacity: 0;
pointer-events: none;
transform: scale(1.5); transform-origin: center; transform-box: fill-box;
filter: drop-shadow(0 0 1px $color); // 미광 — 끝이 살짝 발광한다
}
// 화살촉이 스치고 간 자리에 흩어지는 찌꺼기 — 접선 뒤쪽으로 튄다
.refresh-bit {
fill: $color;
opacity: 0;
transform: translate(0, 0);
pointer-events: none;
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) {
.iconbtn.is-spinning .refresh, .iconbtn.is-demo .refresh { animation: none; }
.refresh-ghost { display: none; }
.refresh-spark { display: none; }
.refresh-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);
}
.iconbtn {
border: 0;
border-radius: 999px;
cursor: pointer;
width: clamp(52px, 14vw, 64px);
height: clamp(52px, 14vw, 64px);
display: grid;
place-items: center;
background: rgba(255, 247, 230, 0.1);
color: #2f6df6;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 3px;
}
.refresh {
transform-box: fill-box;
transform-origin: 50% 50%;
}
.iconbtn.is-spinning .refresh {
animation: spin-once 600ms ease-out;
}
.iconbtn.is-demo .refresh {
animation: spin-loop 2s ease-out infinite;
}
@keyframes spin-once {
to {
transform: rotate(360deg);
}
}
@keyframes spin-loop {
0%, 10% {
transform: rotate(0);
}
55% {
transform: rotate(360deg);
}
100% {
transform: rotate(360deg);
}
}
.refresh-ghost {
transform-box: fill-box;
transform-origin: 50% 50%;
opacity: 0;
filter: blur(0.6px);
}
.iconbtn.is-spinning .refresh-ghost {
animation: ghost-once 600ms ease-out;
}
.iconbtn.is-demo .refresh-ghost {
animation: ghost-loop 2s ease-out infinite;
}
@keyframes ghost-once {
0% {
transform: rotate(0);
opacity: 0;
}
60% {
transform: rotate(260deg);
opacity: 0.4;
}
100% {
transform: rotate(360deg);
opacity: 0;
}
}
@keyframes ghost-loop {
0%, 10% {
transform: rotate(0);
opacity: 0;
}
30% {
transform: rotate(170deg);
opacity: 0.4;
}
55% {
transform: rotate(330deg);
opacity: 0.3;
}
62%, 100% {
transform: rotate(360deg);
opacity: 0;
}
}
.refresh-spark {
fill: #2f6df6;
opacity: 0;
pointer-events: none;
transform: scale(1.5);
transform-origin: center;
transform-box: fill-box;
filter: drop-shadow(0 0 1px #2f6df6);
}
.refresh-bit {
fill: #2f6df6;
opacity: 0;
transform: translate(0, 0);
pointer-events: none;
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) {
.iconbtn.is-spinning .refresh, .iconbtn.is-demo .refresh {
animation: none;
}
.refresh-ghost {
display: none;
}
.refresh-spark {
display: none;
}
.refresh-bit {
display: none;
}
}// 실제 클릭 한 번 = 한 바퀴. 데모(.is-demo)는 계속 돈다
document.querySelectorAll(".iconbtn").forEach(function (b) {
b.addEventListener("click", function () {
b.classList.remove("is-demo");
b.classList.remove("is-spinning");
void b.offsetWidth;
b.classList.add("is-spinning");
});
b.addEventListener("animationend", function (e) {
if (e.animationName === "spin-once") b.classList.remove("is-spinning");
});
});
// ── 한 겹: 화살촉 스파크 + 미광 잔상 ──────────────────────
// 회전은 stroke-dashoffset 이 아니라 transform 이다 — 펜 끝 좌표를 getPointAtLength 로 얻는
// 01 과 달리, 실측 transform 행렬에서 각도를 읽어 화살촉 두 개(뷰박스 좌표 17.9,3.2 · 6.1,20.8)를
// 도형 중심(12,12) 기준 회전으로 직접 계산한다. 잔상은 ghost 레이어가 지연 회전으로 흉내 낸다.
(function () {
var icon = document.querySelector('.iconbtn svg');
if (!icon) return;
var g = icon.querySelector('.refresh');
var spark = icon.querySelector('.refresh-spark');
var bits = [].slice.call(icon.querySelectorAll('.refresh-bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark || !g) { if (spark) spark.style.display = 'none'; return; }
var CX = 12, CY = 12; // 회전축 (fill-box 중심과 일치)
var TIP_A = { x: 17.9, y: 3.2 }; // 오른쪽 위 화살촉 끝
var TIP_B = { x: 6.1, y: 20.8 }; // 왼쪽 아래 화살촉 끝
function angle() {
// getComputedStyle transform 은 px 단위 행렬로 온다: matrix(a,b,c,d,e,f)
var tr = getComputedStyle(g).transform;
if (!tr || tr === 'none') return 0;
var m = new DOMMatrixReadOnly(tr);
return Math.atan2(m.b, m.a); // 라디안
}
function rot(p, rad) {
var cos = Math.cos(rad), sin = Math.sin(rad), dx = p.x - CX, dy = p.y - CY;
return { x: CX + dx * cos - dy * sin, y: CY + dx * sin + dy * cos };
}
var prev = null, lastEmit = 0, emitIdx = 0;
function frame(t) {
var rad = angle();
var moved = prev !== null && Math.abs(rad - prev) > .002;
var a = rot(TIP_A, rad);
// 스파크는 진행 앞쪽 화살촉에 붙인다 — 도는 동안만 켜진다
spark.setAttribute('cx', a.x);
spark.setAttribute('cy', a.y);
spark.style.opacity = moved ? 1 : 0;
if (moved) {
// 회전 접선의 뒤쪽으로 찌꺼기 방출 (초당 최대 ~14개)
if (t - lastEmit > 70) {
lastEmit = t;
var up = Math.random() < .7; // 위 화살촉이 더 자주
var p = up ? a : rot(TIP_B, rad);
var tang = rad + (up ? Math.PI / 2 : -Math.PI / 2); // 접선 방향
var back = tang + Math.PI + (Math.random() - .5) * 1.2;
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(back) * sp * 6).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(back) * sp * 6).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
}
prev = rad;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RefreshSpin.module.scss";
export type RefreshSpinProps = {
/** 한 바퀴 도는 시간, ms */
duration?: number;
easing?: string;
color?: string;
onRefresh?: () => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const PATHS = [
"M4 12a8 8 0 0 1 13.9-5.4",
"M20 12a8 8 0 0 1-13.9 5.4",
"M17.5 3.2v4.4h-4.4",
"M6.5 20.8v-4.4h4.4",
];
const BIT_DIRS: Array<[number, number]> = [
[0, -2.6],
[2.1, -1.4],
[-2.1, 1.4],
[0, 2.6],
];
// 화살촉 끝 (뷰박스 좌표) — 회전축은 도형 중심 (12,12)
const TIP_A = { x: 17.9, y: 3.2 };
const TIP_B = { x: 6.1, y: 20.8 };
export default function RefreshSpin({
duration = 600,
easing = "ease-out",
color = "#2f6df6",
onRefresh,
}: RefreshSpinProps) {
const [spinning, setSpinning] = useState(false);
const groupRef = useRef<SVGGElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 한 겹: 화살촉 스파크 + 미광 잔상 (vanilla 데모와 동일 계산) — 실측 회전각에서
// 화살촉 좌표를 직접 계산해 스파크를 옮기고 접선 뒤쪽으로 찌꺼기를 뿌린다
useEffect(() => {
const g = groupRef.current, spark = sparkRef.current;
if (!g || !spark) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
let prev: number | null = null, lastEmit = 0, emitIdx = 0, raf = 0;
const frame = (t: number) => {
const tr = getComputedStyle(g).transform;
const rad = tr && tr !== "none" ? Math.atan2(new DOMMatrixReadOnly(tr).b, new DOMMatrixReadOnly(tr).a) : 0;
const moved = prev !== null && Math.abs(rad - prev) > 0.002;
const cos = Math.cos(rad), sin = Math.sin(rad);
const ax = 12 + (TIP_A.x - 12) * cos - (TIP_A.y - 12) * sin;
const ay = 12 + (TIP_A.x - 12) * sin + (TIP_A.y - 12) * cos;
spark.setAttribute("cx", ax.toFixed(2));
spark.setAttribute("cy", ay.toFixed(2));
spark.style.opacity = moved ? "1" : "0";
if (moved && t - lastEmit > 70 && bits.length) {
lastEmit = t;
const up = Math.random() < 0.7;
let px = ax, py = ay;
if (!up) {
px = 12 + (TIP_B.x - 12) * cos - (TIP_B.y - 12) * sin;
py = 12 + (TIP_B.x - 12) * sin + (TIP_B.y - 12) * cos;
}
const back = rad + (up ? 0.5 * Math.PI : -0.5 * Math.PI) + Math.PI + (Math.random() - 0.5) * 1.2;
const sp = 0.4 + Math.random() * 0.5;
const b = bits[emitIdx++ % bits.length];
b.setAttribute("cx", px.toFixed(2));
b.setAttribute("cy", py.toFixed(2));
b.style.setProperty("--dx", (Math.cos(back) * sp * 6).toFixed(2) + "px");
b.style.setProperty("--dy", (Math.sin(back) * sp * 6).toFixed(2) + "px");
b.style.animation = "none";
void b.getBoundingClientRect();
b.style.animation = "";
}
prev = rad;
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
return (
<button
type="button"
className={styles.iconbtn}
style={vars}
aria-label="새로고침"
onClick={() => {
setSpinning(true);
onRefresh?.();
}}
>
<svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true">
<g className={styles["refresh-ghost"]}>
{PATHS.map((d) => (
<path key={d} d={d} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
))}
</g>
<g
ref={groupRef}
className={styles.refresh}
data-spinning={spinning || undefined}
onAnimationEnd={() => setSpinning(false)}
>
{PATHS.map((d) => (
<path key={d} d={d} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
))}
</g>
<circle ref={sparkRef} className={styles["refresh-spark"]} r={0.9} cx={17.9} cy={3.2} />
{BIT_DIRS.map(([dx, dy], i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles["refresh-bit"]}
r={i === 0 || i === 3 ? 0.5 : 0.45}
cx={i < 2 ? 17.9 : 6.1}
cy={i < 2 ? 3.2 : 20.8}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
</button>
);
}05 · Xoay refresh — Diện tích đo được 0.43% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Click một lần xoay trọn 360 độ trong $dur-enter 600ms bằng $ease-out, còn ở lưới xem trước nó tự xoay liên tục để bạn thấy chuyển động.
.refresh {
transform-box: fill-box;
transform-origin: 50% 50%;
}
.iconbtn.is-spinning .refresh { animation: spin-once $duration $easing; }
@keyframes spin-once { to { transform: rotate(360deg); } }
06Menu icon morph
Chuyển động thế nào
Icon ba chấm và icon chữ X nằm trong hai <svg> xếp chồng cùng ô grid, đổi chỗ cho nhau chỉ bằng opacity trong $dur-quick 220ms — khác hẳn kiểu hamburger ba vạch xoay chồng thành X, ở đây hoàn toàn không có phép xoay nào.
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-expanded="false" aria-labelledby="t6">
<span class="iconbtn__stack">
<svg class="ic ic--dots" viewBox="0 0 24 24" width="24" height="24">
<title id="t6">Mở menu</title>
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
<svg class="ic ic--x" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<svg class="fx" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle class="fx__halo" cx="12" cy="12" r="6"/>
<circle class="fx__spark" r=".9" cx="12" cy="12"/>
<circle class="fx__bit" r=".5" cx="5" cy="12" style="--dx:-2.6px;--dy:0px"/>
<circle class="fx__bit" r=".5" cx="12" cy="12" style="--dx:0px;--dy:-2.6px"/>
<circle class="fx__bit" r=".5" cx="19" cy="12" style="--dx:2.6px;--dy:0px"/>
<circle class="fx__bit" r=".45" cx="7" cy="7" style="--dx:-2px;--dy:-2px"/>
<circle class="fx__bit" r=".45" cx="17" cy="17" style="--dx:2px;--dy:2px"/>
<circle class="fx__bit" r=".45" cx="17" cy="7" style="--dx:2px;--dy:-2px"/>
</svg>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick; // 220ms — 실제 클릭 전환 시간
$easing: $ease-out;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.iconbtn {
border: 0; border-radius: $r-control; cursor: pointer;
width: clamp(52px, 14vw, 64px); height: clamp(52px, 14vw, 64px);
display: grid; place-items: center;
background: rgba(23, 20, 26, .08); color: $color;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
.iconbtn__stack { display: grid; place-items: center; width: 100%; height: 100%; }
// 점 3개 아이콘과 X 아이콘을 같은 칸에 겹쳐 놓고 opacity 로만 넘긴다
.ic { grid-area: 1 / 1; fill: currentColor; transition: opacity $duration $easing; }
.ic--dots { opacity: 1; }
.ic--x { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--dots { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--x { opacity: 1; }
.iconbtn.is-demo .ic--dots { animation: dots-loop $dur-loop $easing infinite; }
.iconbtn.is-demo .ic--x { animation: x-loop $dur-loop $easing infinite; }
@keyframes dots-loop { 0%, 15% { opacity: 1; } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; } }
@keyframes x-loop { 0%, 15% { opacity: 0; } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; } }
// ── 한 겹: 전환 순간 링 파동 + 스파크 입자 ─────────────────
// 아이콘과 같은 칸에 겹쳐 놓은 효과 레이어 — 레이아웃에는 영향이 없다
.fx {
grid-area: 1 / 1;
fill: currentColor;
pointer-events: none;
}
// 링 파동 — 전환 순간 중앙에서 퍼지며 옅어지는 미광
.fx__halo {
fill: none;
stroke: $color;
stroke-width: .8;
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
}
@keyframes halo-ring {
0% { opacity: .55; transform: scale(.4); }
100% { opacity: 0; transform: scale(1.7); }
}
// 중앙 스파크 — 터지는 순간 밝게 점멸
.fx__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px $color);
}
@keyframes spark-pop {
0% { opacity: .95; transform: scale(1.8); }
60% { opacity: .4; transform: scale(.8); }
100% { opacity: 0; transform: scale(.3); }
}
// 전환 순간 흩어지는 입자 — 각자 지정 방향(--dx,--dy)으로 튄다
.fx__bit {
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) {
.iconbtn.is-demo .ic--dots, .iconbtn.is-demo .ic--x { animation: none; }
.ic { transition: none; }
.fx { display: 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);
}
.iconbtn {
border: 0;
border-radius: 12px;
cursor: pointer;
width: clamp(52px, 14vw, 64px);
height: clamp(52px, 14vw, 64px);
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.iconbtn__stack {
display: grid;
place-items: center;
width: 100%;
height: 100%;
}
.ic {
grid-area: 1/1;
fill: currentColor;
transition: opacity 220ms ease-out;
}
.ic--dots {
opacity: 1;
}
.ic--x {
opacity: 0;
}
.iconbtn[aria-expanded=true] .ic--dots {
opacity: 0;
}
.iconbtn[aria-expanded=true] .ic--x {
opacity: 1;
}
.iconbtn.is-demo .ic--dots {
animation: dots-loop 2s ease-out infinite;
}
.iconbtn.is-demo .ic--x {
animation: x-loop 2s ease-out infinite;
}
@keyframes dots-loop {
0%, 15% {
opacity: 1;
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes x-loop {
0%, 15% {
opacity: 0;
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
.fx {
grid-area: 1/1;
fill: currentColor;
pointer-events: none;
}
.fx__halo {
fill: none;
stroke: #17141a;
stroke-width: 0.8;
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
}
@keyframes halo-ring {
0% {
opacity: 0.55;
transform: scale(0.4);
}
100% {
opacity: 0;
transform: scale(1.7);
}
}
.fx__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px #17141a);
}
@keyframes spark-pop {
0% {
opacity: 0.95;
transform: scale(1.8);
}
60% {
opacity: 0.4;
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(0.3);
}
}
.fx__bit {
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) {
.iconbtn.is-demo .ic--dots, .iconbtn.is-demo .ic--x {
animation: none;
}
.ic {
transition: none;
}
.fx {
display: none;
}
}document.querySelectorAll(".iconbtn").forEach(function (b) {
var t = b.querySelector("title");
b.addEventListener("click", function () {
b.classList.remove("is-demo");
var open = b.getAttribute("aria-expanded") === "true";
b.setAttribute("aria-expanded", String(!open));
t.textContent = open ? "Mở menu" : "Đóng menu";
});
});
// ── 한 겹: 전환 순간 링 파동 + 스파크 입자 ─────────────────
// 점↔X 크로스페이드는 짧은 사건(spike)이다 — 01 처럼 펜 끝을 쫓는 대신, 전환 프레임을
// getComputedStyle opacity 실측으로 감지해 그 순간 중앙에서 링(halo)과 입자를 터뜨린다.
(function () {
var btn = document.querySelector('.iconbtn');
if (!btn) return;
var fx = btn.querySelector('.fx');
if (!fx) return;
var dots = btn.querySelector('.ic--dots');
var x = btn.querySelector('.ic--x');
var halo = fx.querySelector('.fx__halo');
var spark = fx.querySelector('.fx__spark');
var bits = [].slice.call(fx.querySelectorAll('.fx__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { fx.style.display = 'none'; return; }
if (!halo || !spark || !dots || !x) return;
var prevO = null, prevX = null, lastBurst = 0;
function burst(t) {
// 링 파동 — 스타일 재시작으로 다시 퍼진다
halo.style.animation = 'none';
void halo.getBoundingClientRect();
halo.style.animation = '';
halo.style.animationName = 'halo-ring';
// 중앙 스파크 점멸
spark.style.animation = 'none';
void spark.getBoundingClientRect();
spark.style.animation = '';
spark.style.animationName = 'spark-pop';
// 입자 6개 — 각자 지정된 방향으로 흩어진다
bits.forEach(function (b, i) {
b.style.animationDelay = (i % 3) * 18 + 'ms';
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
});
lastBurst = t;
}
function frame(t) {
// 전환 = 두 아이콘 opacity 의 교차. 어느 한쪽이 움직이는 프레임을 실측으로 잡는다
// (데모 루프의 교차는 500ms 에 걸쳐 일어나 프레임당 Δ≈.03 — 문턱은 그 아래로)
// 데모 루프의 교차 페이드는 500ms — 문턱을 그보다 길게 잡아 한 전환에 한 번만 터진다
var od = parseFloat(getComputedStyle(dots).opacity);
var ox = parseFloat(getComputedStyle(x).opacity);
if (prevO !== null) {
var dD = Math.abs(od - prevO), dX = Math.abs(ox - prevX);
if ((dD > .02 || dX > .02) && t - lastBurst > 700) burst(t);
}
prevO = od; prevX = ox;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MenuMorph.module.scss";
export type MenuMorphProps = {
/** 점→X 전환 시간, ms */
duration?: number;
easing?: string;
color?: string;
onToggle?: (open: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// 입자 6개의 시작 좌표(뷰박스)와 흩어질 방향 — 점 3개→X 교차점 중앙 기준 방사형
const BITS: Array<{ x: number; y: number; dx: number; dy: number; r: number }> = [
{ x: 5, y: 12, dx: -2.6, dy: 0, r: 0.5 },
{ x: 12, y: 12, dx: 0, dy: -2.6, r: 0.5 },
{ x: 19, y: 12, dx: 2.6, dy: 0, r: 0.5 },
{ x: 7, y: 7, dx: -2, dy: -2, r: 0.45 },
{ x: 17, y: 17, dx: 2, dy: 2, r: 0.45 },
{ x: 17, y: 7, dx: 2, dy: -2, r: 0.45 },
];
export default function MenuMorph({
duration = 220,
easing = "ease-out",
color = "#17141a",
onToggle,
}: MenuMorphProps) {
const [open, setOpen] = useState(false);
const haloRef = useRef<SVGCircleElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
const burstRef = useRef<(() => void) | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 한 겹: 전환 순간 링 파동 + 스파크 입자 (vanilla 데모와 동일 연출).
// 애니메이션은 css-module keyframes 가 달려 있고, burst 는 재시작만 담당한다.
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
// 01 과 같은 재시작 법칙: none 으로 지웠다가 지운다 — stylesheet 의 모듈 keyframes 이름이 그대로 돌아온다
const restart = (el: SVGElement, delay?: string) => {
el.style.animation = "none";
void el.getBoundingClientRect();
el.style.animation = "";
if (delay !== undefined) el.style.animationDelay = delay;
};
burstRef.current = () => {
if (haloRef.current) restart(haloRef.current);
if (sparkRef.current) restart(sparkRef.current);
bits.forEach((b, i) => restart(b, (i % 3) * 18 + "ms"));
};
return () => { burstRef.current = null; };
}, []);
return (
<button
type="button"
className={styles.iconbtn}
style={vars}
aria-expanded={open}
aria-label={open ? "메뉴 닫기" : "메뉴 열기"}
onClick={() => {
setOpen((o) => !o);
burstRef.current?.();
onToggle?.(!open);
}}
>
<span className={styles.iconbtn__stack}>
<svg className={`${styles.ic} ${open ? styles["ic--hidden"] : ""}`} viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" />
</svg>
<svg className={`${styles.ic} ${open ? "" : styles["ic--hidden"]}`} viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
<svg className={styles.fx} viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle ref={haloRef} className={styles["fx__halo"]} cx={12} cy={12} r={6} />
<circle ref={sparkRef} className={styles["fx__spark"]} r={0.9} cx={12} cy={12} />
{BITS.map((b, i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles["fx__bit"]}
r={b.r}
cx={b.x}
cy={b.y}
style={{ "--dx": `${b.dx}px`, "--dy": `${b.dy}px` } as CSSProperties}
/>
))}
</svg>
</span>
</button>
);
}06 · Menu icon morph — Diện tích đo được 0.111% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Nút toggle aria-expanded và cập nhật <title> bên trong svg thành "mở menu" hoặc "đóng menu" cho screen reader.
.ic { grid-area: 1 / 1; fill: currentColor; transition: opacity $duration $easing; }
.ic--dots { opacity: 1; }
.ic--x { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--dots { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--x { opacity: 1; }
.iconbtn.is-demo .ic--x { animation: x-loop $dur-loop $easing infinite; }
@keyframes x-loop { 40%, 60% { opacity: 1; } }
07Heart pulse
Bạn thấy gì
Trái tim viền và trái tim tô đặc xếp chồng nhau, khi bạn nhấn thì bản tô đặc hiện ra còn cả hai cùng scale bật từ 1 lên 1.3 rồi lò xo về 1 trong $dur-quick 220ms bằng $ease-pop.
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-pressed="false" aria-label="Thích">
<span class="iconbtn__stack">
<svg class="ic ic--outline" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M12 20.2s-7.3-4.5-9.5-8.9C.9 7.9 2.6 4.6 5.9 4.1c2-.3 3.8.6 4.9 2.2 1-1.6 2.9-2.5 4.9-2.2 3.3.5 5 3.8 3.4 7.2-2.2 4.4-9.1 8.9-9.1 8.9z" fill="none" stroke="currentColor" stroke-width="1.7"/>
</svg>
<svg class="ic ic--fill" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M12 20.2s-7.3-4.5-9.5-8.9C.9 7.9 2.6 4.6 5.9 4.1c2-.3 3.8.6 4.9 2.2 1-1.6 2.9-2.5 4.9-2.2 3.3.5 5 3.8 3.4 7.2-2.2 4.4-9.1 8.9-9.1 8.9z"/>
</svg>
<svg class="fx" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle class="fx__spark" r=".9" cx="12" cy="12"/>
<circle class="fx__bit" r=".5" cx="12" cy="6" style="--dx:0px;--dy:-2.6px"/>
<circle class="fx__bit" r=".5" cx="12" cy="6" style="--dx:2.4px;--dy:-1.2px"/>
<circle class="fx__bit" r=".5" cx="12" cy="6" style="--dx:-2.4px;--dy:-1.2px"/>
<circle class="fx__bit" r=".45" cx="4" cy="8" style="--dx:-2.6px;--dy:-1px"/>
<circle class="fx__bit" r=".45" cx="20" cy="8" style="--dx:2.6px;--dy:-1px"/>
<circle class="fx__bit" r=".45" cx="12" cy="20" style="--dx:0px;--dy:2.6px"/>
</svg>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick; // 220ms — 튕기는 시간
$easing: $ease-pop;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.iconbtn {
border: 0; background: none; cursor: pointer; color: $color;
border-radius: $r-sm;
width: clamp(52px, 14vw, 64px); height: clamp(52px, 14vw, 64px);
display: grid; place-items: center;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
.iconbtn__stack { display: grid; place-items: center; width: 100%; height: 100%; }
.ic {
grid-area: 1 / 1;
transition: opacity $duration $easing, transform $duration $easing;
transform-box: fill-box; transform-origin: 50% 50%;
}
.ic--outline { opacity: 1; }
.ic--fill { fill: $color; opacity: 0; }
.iconbtn[aria-pressed="true"] .ic--outline { opacity: 0; }
.iconbtn[aria-pressed="true"] .ic--fill { opacity: 1; transform: scale(1); }
.iconbtn.is-demo .ic--outline { animation: outline-loop $dur-loop $easing infinite; }
.iconbtn.is-demo .ic--fill { animation: fill-loop $dur-loop $easing infinite; }
@keyframes outline-loop { 0%, 15% { opacity: 1; transform: scale(1); } 30% { transform: scale(1.3); } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; transform: scale(1); } }
@keyframes fill-loop { 0%, 15% { opacity: 0; transform: scale(1); } 30% { transform: scale(1.3); } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; transform: scale(1); } }
// ── 한 겹: 채워지는 순간 가장자리 입자 + 중앙 스파크 ─────────
// 아이콘과 같은 칸에 겹쳐 놓은 효과 레이어 — 레이아웃에는 영향이 없다
.fx {
grid-area: 1 / 1;
fill: currentColor;
pointer-events: none;
}
// 중앙 스파크 — 하트가 차오르는 순간 밝게 점멸
.fx__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px $color); // 미광
}
@keyframes spark-pop {
0% { opacity: .95; transform: scale(1.8); }
60% { opacity: .4; transform: scale(.8); }
100% { opacity: 0; transform: scale(.3); }
}
// 하트 가장자리에서 바깥으로 흩어지는 입자 — JS 가 좌표·방향을 계산해 넣는다
.fx__bit {
opacity: 0;
transform: translate(0, 0);
}
@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) {
.iconbtn.is-demo .ic--outline, .iconbtn.is-demo .ic--fill { animation: none; }
.ic { transition: none; }
.fx { display: none; }
}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);
}
.iconbtn {
border: 0;
background: none;
cursor: pointer;
color: #fff7e6;
border-radius: 8px;
width: clamp(52px, 14vw, 64px);
height: clamp(52px, 14vw, 64px);
display: grid;
place-items: center;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.iconbtn__stack {
display: grid;
place-items: center;
width: 100%;
height: 100%;
}
.ic {
grid-area: 1/1;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
transform-box: fill-box;
transform-origin: 50% 50%;
}
.ic--outline {
opacity: 1;
}
.ic--fill {
fill: #fff7e6;
opacity: 0;
}
.iconbtn[aria-pressed=true] .ic--outline {
opacity: 0;
}
.iconbtn[aria-pressed=true] .ic--fill {
opacity: 1;
transform: scale(1);
}
.iconbtn.is-demo .ic--outline {
animation: outline-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.iconbtn.is-demo .ic--fill {
animation: fill-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes outline-loop {
0%, 15% {
opacity: 1;
transform: scale(1);
}
30% {
transform: scale(1.3);
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fill-loop {
0%, 15% {
opacity: 0;
transform: scale(1);
}
30% {
transform: scale(1.3);
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
transform: scale(1);
}
}
.fx {
grid-area: 1/1;
fill: currentColor;
pointer-events: none;
}
.fx__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px #fff7e6);
}
@keyframes spark-pop {
0% {
opacity: 0.95;
transform: scale(1.8);
}
60% {
opacity: 0.4;
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(0.3);
}
}
.fx__bit {
opacity: 0;
transform: translate(0, 0);
}
@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) {
.iconbtn.is-demo .ic--outline, .iconbtn.is-demo .ic--fill {
animation: none;
}
.ic {
transition: none;
}
.fx {
display: none;
}
}document.querySelectorAll(".iconbtn").forEach(function (b) {
b.addEventListener("click", function () {
b.classList.remove("is-demo");
var on = b.getAttribute("aria-pressed") === "true";
b.setAttribute("aria-pressed", String(!on));
});
});
// ── 한 겹: 하트 채워지는 순간 가장자리 입자 폭발 + 중앙 스파크 ──────
// 펄스는 짧은 사건이다 — 01 처럼 펜 끝을 쫓는 대신, '채움 하트가 나타나는 프레임'을
// getComputedStyle opacity 실측으로 감지해 그 순간 하트 가장자리에서 입자를 터뜨린다.
// 입자 좌표는 fill 하트 path 를 getTotalLength/getPointAtLength 로 6 점 샘플링해 얻고,
// 방향은 도심(12,12)에서 바깥으로 — 하트가 '탁' 차오르는 물성을 흉내 낸다.
(function () {
var btn = document.querySelector('.iconbtn');
if (!btn) return;
var fx = btn.querySelector('.fx');
var fill = btn.querySelector('.ic--fill path');
if (!fx || !fill || !fill.getTotalLength) return;
var spark = fx.querySelector('.fx__spark');
var bits = [].slice.call(fx.querySelectorAll('.fx__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark) { fx.style.display = 'none'; return; }
var L = fill.getTotalLength();
var CX = 12, CY = 12;
// 입자 하나를 하트 가장자리 임의 점에 재배치하고 바깥 방향으로 흩어지게 한다
function seed(b) {
var p = fill.getPointAtLength(Math.random() * L);
b.setAttribute('cx', p.x);
b.setAttribute('cy', p.y);
var dx = p.x - CX, dy = p.y - CY;
var len = Math.sqrt(dx * dx + dy * dy) || 1;
var sp = .45 + Math.random() * .55;
b.style.setProperty('--dx', (dx / len * sp * 6.5).toFixed(2) + 'px');
b.style.setProperty('--dy', (dy / len * sp * 6.5).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
function burst() {
spark.style.animation = 'none';
void spark.getBoundingClientRect();
spark.style.animation = '';
spark.style.animationName = 'spark-pop';
bits.forEach(function (b) { seed(b); });
}
var prevO = null, lastBurst = 0;
function frame(t) {
// 채움 하트가 나타나는 프레임 = opacity 가 0→1 로 오르는 중.
// 데모 루프의 상승 구간은 500ms — 문턱을 그보다 길게 잡아 한 번만 터진다
var o = parseFloat(getComputedStyle(fill.closest('.ic--fill')).opacity);
if (prevO !== null && o - prevO > .02 && t - lastBurst > 600) {
lastBurst = t;
burst();
}
prevO = o;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HeartPulse.module.scss";
export type HeartPulseProps = {
/** 튕기는 시간, ms */
duration?: number;
easing?: string;
color?: string;
defaultLiked?: boolean;
onChange?: (liked: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HEART = "M12 20.2s-7.3-4.5-9.5-8.9C.9 7.9 2.6 4.6 5.9 4.1c2-.3 3.8.6 4.9 2.2 1-1.6 2.9-2.5 4.9-2.2 3.3.5 5 3.8 3.4 7.2-2.2 4.4-9.1 8.9-9.1 8.9z";
const BIT_COUNT = 6;
const CX = 12, CY = 12;
export default function HeartPulse({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
defaultLiked = false,
onChange,
}: HeartPulseProps) {
const [liked, setLiked] = useState(defaultLiked);
const pathRef = useRef<SVGPathElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
const burstRef = useRef<(() => void) | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 한 겹: 좋아요 순간 하트 가장자리 입자 폭발 + 중앙 스파크 (vanilla 데모와 동일 계산).
// 하트 path 를 getTotalLength/getPointAtLength 로 샘플링해 입자를 가장자리에 뿌리고
// 도심에서 바깥 방향으로 흩어지게 한다.
useEffect(() => {
const heart = pathRef.current, spark = sparkRef.current;
if (!heart || !spark || !heart.getTotalLength) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
const L = heart.getTotalLength();
const restart = (el: SVGElement) => {
el.style.animation = "none";
void el.getBoundingClientRect();
el.style.animation = "";
};
burstRef.current = () => {
restart(spark);
bits.forEach((b) => {
const p = heart.getPointAtLength(Math.random() * L);
b.setAttribute("cx", p.x.toFixed(2));
b.setAttribute("cy", p.y.toFixed(2));
const dx = p.x - CX, dy = p.y - CY;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const sp = 0.45 + Math.random() * 0.55;
b.style.setProperty("--dx", ((dx / len) * sp * 6.5).toFixed(2) + "px");
b.style.setProperty("--dy", ((dy / len) * sp * 6.5).toFixed(2) + "px");
restart(b);
});
};
return () => { burstRef.current = null; };
}, []);
return (
<button
type="button"
className={styles.iconbtn}
style={vars}
aria-pressed={liked}
aria-label="좋아요"
onClick={() => {
setLiked((v) => !v);
burstRef.current?.();
onChange?.(!liked);
}}
>
<span className={styles.iconbtn__stack}>
<svg className={`${styles.ic} ${liked ? styles["ic--hidden"] : ""}`} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d={HEART} fill="none" stroke="currentColor" strokeWidth={1.7} />
</svg>
<svg className={`${styles.ic} ${styles["ic--fill"]} ${liked ? "" : styles["ic--hidden"]}`} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
{/* pathRef — 입자 좌표 샘플링용 원본 경로. getTotalLength 는 경로 기하학만 보므로
스케일 애니메이션·투명도와 무관하게 항상 같은 하트 좌표를 돌려준다 */}
<path ref={pathRef} d={HEART} />
</svg>
<svg className={styles.fx} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle ref={sparkRef} className={styles["fx__spark"]} r={0.9} cx={12} cy={12} />
{Array.from({ length: BIT_COUNT }, (_, i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles["fx__bit"]}
r={i < 3 ? 0.5 : 0.45}
cx={12}
cy={6}
style={{ "--dx": "0px", "--dy": "-2.6px" } as CSSProperties}
/>
))}
</svg>
</span>
</button>
);
}07 · Heart pulse — Diện tích đo được 0.338% · khung hình chuyển động 13/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Nút giữ aria-pressed và aria-label="thích" để tên nút không đổi dù icon bên trong đang chuyển động.
.ic {
grid-area: 1 / 1;
transition: opacity $duration $easing, transform $duration $easing;
transform-box: fill-box; transform-origin: 50% 50%;
}
.ic--fill { fill: $color; opacity: 0; }
.iconbtn[aria-pressed="true"] .ic--fill { opacity: 1; transform: scale(1); }
@keyframes fill-loop { 30% { transform: scale(1.3); } 40%, 60% { opacity: 1; } }
08Mũi tên di chuyển
Chuyện gì diễn ra trên màn hình
Một path mũi tên trỏ xuống bồng bềnh lên xuống tại chỗ bằng translateY, lặp mỗi $dur-loop 2 giây với $ease-spring nên không dừng khựng ở hai đầu quãng đường.
<main class="stage">
<p class="cue">
Xem thêm
<span class="cue__wrap">
<svg class="cue__arrow" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg class="cue__fx" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path class="cue__trail" d="M6 9l6 6 6-6" pathLength="100" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle class="cue__spark" r="1" cx="18" cy="9"/>
<circle class="cue__bit" r=".5" cx="18" cy="9" style="--dx:2.4px;--dy:-1.4px"/>
<circle class="cue__bit" r=".45" cx="18" cy="9" style="--dx:1.8px;--dy:.8px"/>
<circle class="cue__bit" r=".45" cx="18" cy="9" style="--dx:2.6px;--dy:-.4px"/>
<circle class="cue__bit" r=".45" cx="18" cy="9" style="--dx:1.2px;--dy:1.8px"/>
</svg>
</span>
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-loop; // 2s — 한 번 오가는 시간
$easing: $ease-spring;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.cue {
margin: 0;
display: inline-flex; align-items: center; gap: clamp($sp-2, 2vw, $sp-3);
color: $color; font-weight: 600; font-size: clamp(14px, 4vw, 18px); line-height: 1; font-family: inherit;
}
// 화살표와 효과 레이어를 같은 칸에 겹쳐 놓는다 — 레이아웃은 그대로
.cue__wrap { position: relative; display: grid; }
.cue__arrow { grid-area: 1 / 1; color: $color; animation: bob $duration $easing infinite; }
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY($sp-2); } // 8px
}
// ── 한 겹: 화살표 끝 스파크 + 미광 잔상 ──────────────────────
// fx 레이어는 화살표와 같은 칸 — SVG 좌표계도 같아 스파크를 경로 위에 얹을 수 있다
.cue__fx {
grid-area: 1 / 1;
color: $color;
pointer-events: none;
overflow: visible; // 스파크·찌꺼기가 뷰밖으로 나가도 잘리지 않게
}
// 미광 잔상 — 화살표가 내려간 자리가 잠깐 빛났다 올라오며 지워진다 (JS 가 dashoffset 구동)
.cue__trail {
stroke: currentColor;
stroke-dasharray: 100; // pathLength="100"
stroke-dashoffset: 100;
opacity: 0;
}
// 화살표 끝점을 따라다니는 스파크 — JS 가 getComputedStyle transform 실측으로 옮긴다
.cue__spark {
fill: currentColor;
opacity: 0;
transform: scale(1.6); transform-origin: center; transform-box: fill-box;
filter: drop-shadow(0 0 1px currentColor); // 미광 — 끝점이 살짝 발광한다
}
// 화살표가 지나간 뒤 진행 반대 방향으로 튀는 찌꺼기
.cue__bit {
fill: currentColor;
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) {
.cue__arrow { animation: none; }
.cue__fx { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cue {
margin: 0;
display: inline-flex;
align-items: center;
gap: clamp(8px, 2vw, 12px);
color: #17141a;
font-weight: 600;
font-size: clamp(14px, 4vw, 18px);
line-height: 1;
font-family: inherit;
}
.cue__wrap {
position: relative;
display: grid;
}
.cue__arrow {
grid-area: 1/1;
color: #17141a;
animation: bob 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(8px);
}
}
.cue__fx {
grid-area: 1/1;
color: #17141a;
pointer-events: none;
overflow: visible;
}
.cue__trail {
stroke: currentColor;
stroke-dasharray: 100;
stroke-dashoffset: 100;
opacity: 0;
}
.cue__spark {
fill: currentColor;
opacity: 0;
transform: scale(1.6);
transform-origin: center;
transform-box: fill-box;
filter: drop-shadow(0 0 1px currentColor);
}
.cue__bit {
fill: currentColor;
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) {
.cue__arrow {
animation: none;
}
.cue__fx {
display: none;
}
}// 화살표 이동 한 겹 — bob 애니메이션의 현재 위치를 getComputedStyle transform 실측으로
// 읽어(01 과 같은 원리: 타이밍 하드코딩 없음) 화살표 끝점을 따라다니는 스파크 + 진행 반대
// 방향으로 튀는 찌꺼기 + 이동이 끝난 뒤 잠깐 남는 미광 잔상.
(function () {
var arrow = document.querySelector('.cue__arrow');
var fx = document.querySelector('.cue__fx');
if (!arrow || !fx) return;
var spark = fx.querySelector('.cue__spark');
var trail = fx.querySelector('.cue__trail');
var bits = [].slice.call(fx.querySelectorAll('.cue__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark) { fx.style.display = 'none'; return; }
// 화살표 경로 — 꼭짓점(12,15)이 아니라 '펜 끝'(18,9)을 추적한다
var END = { x: 18, y: 9 };
// bob 과 같은 리듬으로 잔상이 차오랐다 비운다 — 그려진 길이를 transform 위치에서 역산
var prev = null, lastEmit = 0, emitIdx = 0;
function arrowY() {
// translateY 실측(px) → 0..1 진행도. bob 은 8px ($sp-2) 오간다
var m = /matrix\(([^)]+)\)/.exec(getComputedStyle(arrow).transform);
if (!m) return 0;
var dy = parseFloat(m[1].split(', ')[5]) || 0;
return Math.max(0, Math.min(1, dy / 8));
}
function frame(t) {
var prog = arrowY();
// 잔상 — 아래로 이동 중(그리기) 차오르고 올라가는 동안 비운다. 진행도 그대로 dashoffset
if (prev !== null) {
var dirDown = prog >= prev;
var off = dirDown ? 100 - prog * 100 : -(prog * 100);
trail.style.strokeDashoffset = off.toFixed(2);
trail.style.opacity = dirDown ? .45 : (1 - prog) * .45;
}
// 스파크 — 화살표 끝점이 이동 중일 때만 켠다
var p = { x: END.x, y: END.y + prog * 8 };
if (prev !== null && Math.abs(prog - prev) > .004) {
spark.setAttribute('cx', p.x);
spark.setAttribute('cy', p.y);
spark.style.opacity = 1;
if (t - lastEmit > 60) {
lastEmit = t;
// 진행 반대(이동 방향 뒤쪽)로 찌꺼기 — 수직 이동이라 각도는 위/아래로 갈린다
var ang = (prog > prev ? Math.PI / 2 : -Math.PI / 2) + (Math.random() - .5) * 1.2 + 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 * 5).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp * 5).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
prev = prog;
} else {
if (prev !== null && Math.abs(prog - prev) < .0005) spark.style.opacity = 0;
if (prev === null) prev = prog;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ArrowCue.module.scss";
export type ArrowCueProps = {
label?: string;
/** 한 번 오가는 시간, ms */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const END = { x: 18, y: 9 }; // 화살표 '펜 끝' — 꼭짓점이 아니라 끝점을 추적한다
const BOB_PX = 8; // bob translateY 최대치 (vanilla $sp-2 와 동일)
const BIT_DIRS: Array<[number, number]> = [
[2.4, -1.4],
[1.8, 0.8],
[2.6, -0.4],
[1.2, 1.8],
];
export default function ArrowCue({
label = "더 보기",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: ArrowCueProps) {
const arrowRef = useRef<SVGSVGElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const trailRef = useRef<SVGPathElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
// 한 겹: bob 현재 위치를 getComputedStyle transform 실측으로 읽어(타이밍 하드코딩 없음)
// 화살표 끝점 스파크 + 진행 반대 방향 찌꺼기 + 미광 잔상 (vanilla 데모와 동일 계산)
useEffect(() => {
const arrow = arrowRef.current, spark = sparkRef.current, trail = trailRef.current;
if (!arrow || !spark || !trail) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
let prev: number | null = null, lastEmit = 0, emitIdx = 0, raf = 0;
const arrowY = () => {
// translateY 실측(px) → 0..1 진행도. bob 은 8px 오간다
const m = /matrix\(([^)]+)\)/.exec(getComputedStyle(arrow).transform);
if (!m) return 0;
const dy = parseFloat(m[1].split(", ")[5]) || 0;
return Math.max(0, Math.min(1, dy / BOB_PX));
};
const frame = (t: number) => {
const prog = arrowY();
// 잔상 — 아래로 이동 중(그리기) 차오르고 올라가는 동안 비운다. 진행도 그대로 dashoffset
if (prev !== null) {
const dirDown = prog >= prev;
const off = dirDown ? 100 - prog * 100 : -(prog * 100);
trail.style.strokeDashoffset = off.toFixed(2);
trail.style.opacity = dirDown ? "0.45" : String((1 - prog) * 0.45);
}
// 스파크 — 화살표 끝점이 이동 중일 때만 켠다
if (prev !== null && Math.abs(prog - prev) > 0.004) {
spark.setAttribute("cx", String(END.x));
spark.setAttribute("cy", String(END.y + prog * BOB_PX));
spark.style.opacity = "1";
if (t - lastEmit > 60 && bits.length) {
lastEmit = t;
// 진행 반대(이동 방향 뒤쪽)로 찌꺼기 — 수직 이동이라 각도는 위/아래로 갈린다
const ang =
(prog > prev ? Math.PI / 2 : -Math.PI / 2) + (Math.random() - 0.5) * 1.2 + Math.PI;
const sp = 0.4 + Math.random() * 0.5;
const b = bits[emitIdx++ % bits.length];
b.setAttribute("cx", String(END.x));
b.setAttribute("cy", String(END.y + prog * BOB_PX));
b.style.setProperty("--dx", (Math.cos(ang) * sp * 5).toFixed(2) + "px");
b.style.setProperty("--dy", (Math.sin(ang) * sp * 5).toFixed(2) + "px");
b.style.animation = "none";
void b.getBoundingClientRect();
b.style.animation = "";
}
prev = prog;
} else {
if (prev !== null && Math.abs(prog - prev) < 0.0005) spark.style.opacity = "0";
if (prev === null) prev = prog;
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<p className={styles.cue} style={vars}>
{label}
<span className={styles.cue__wrap}>
<svg ref={arrowRef} className={styles.cue__arrow} viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
<svg className={styles.cue__fx} viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
ref={trailRef}
className={styles.cue__trail}
d="M6 9l6 6 6-6"
pathLength={100}
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle ref={sparkRef} className={styles.cue__spark} r={1} cx={18} cy={9} />
{BIT_DIRS.map(([dx, dy], i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles.cue__bit}
r={i === 0 ? 0.5 : 0.45}
cx={18}
cy={9}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
</span>
</p>
);
}08 · Mũi tên di chuyển — Diện tích đo được 0.108% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Icon này chỉ trang trí cạnh chữ "xem thêm", nên svg mang aria-hidden="true" còn ý nghĩa nằm hết ở phần chữ.
$duration: $dur-loop; // 2s
$easing: $ease-spring;
.cue__arrow { animation: bob $duration $easing infinite; }
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY($sp-2); }
}
09Vẽ check
Chuyển động thế nào
Dấu check dạng polyline mang pathLength="100" được vẽ ra rồi xoá đi chỉ bằng cách đổi stroke-dashoffset, giống cách vẽ ở mục 01 nhưng dùng $dur-base 300ms ngắn hơn.
<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>
Đã lưu
</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 · Vẽ check — Diện tích đo được 0.34% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Bản đầu vẽ icon ở 26px khiến diện tích pixel đổi màu đo được quá thấp so với ngưỡng, nên phải phóng lên 64px và tăng nét từ 2.4px lên 3.2px mới qua được.
.confirm__check {
stroke-dasharray: 100;
animation: draw $duration $easing infinite;
}
@keyframes draw {
0% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
Chỗ dễ hỏng ở đâu? — cái bẫy
Trục xoay theo viewport SVG
Cái bẫy hay gặp nhất nằm ở trục xoay. Chỉ rotate() một <g> hay <path> mà không thêm gì khác, trục quay bị tính theo viewport (0,0) đến (24,24) của cả file SVG chứ không phải theo chính hình dạng đó, nên ở mục 04 và 05 icon từng bị lệch hẳn sang một góc khi quay. Thêm transform-box: fill-box thì trục chuyển về đúng khung bao quanh hình dạng, và thuộc tính này đã chạy ổn định từ Chrome 64, Edge 79, Firefox 55 và Safari 11 (gồm cả iOS) theo bảng caniuse cho transform-box, nên gần như không lo trình duyệt cũ chặn mất. Hai mục 04 và 09 từng đo dưới ngưỡng chuyển động rồi mới sửa như mô tả ở trên, và bộ file mang đúng các giá trị đã sửa đó có mật khẩu giải nén là datzbfgj.
Glyph chữ thay cho path
Bẫy thứ hai nằm ở glyph chữ. Gõ thẳng ký tự như ✓ hay → thay cho path SVG thì hình dạng phụ thuộc font hệ điều hành đang cài, có máy không hiện ra gì hoặc hiện sai hình — lý do cả chín mục ở đây đều vẽ bằng path SVG chứ không dùng ký tự font.
Khả năng tiếp cận
Tên cho nút icon
Năm nút icon (03, 04, 05, 06, 07) nếu không có tên thì trình đọc màn hình chỉ đọc ra "nút", không biết nút làm gì. Mục 03 và 06 dùng <title> bên trong svg cùng aria-labelledby trên nút, đổi tên ngay khi trạng thái đổi; còn 04, 05, 07 dùng thẳng aria-label cố định, tên không đổi theo trạng thái.
<button aria-labelledby="t3">
<svg><title id="t3">Phát</title><path d="M8 5l11 7-11 7z"/></svg>
</button>
Icon trang trí và giảm chuyển động
Bốn mục còn lại (01, 02, 08, 09) là icon trang trí đứng cạnh chữ đã mang sẵn nghĩa, nên svg chỉ cần aria-hidden="true" là đủ.
<svg aria-hidden="true"><path class="badge__star" d="M12 2l2.9 6.9L22 9.3z"/></svg> Tính năng mới
Ở prefers-reduced-motion: reduce, cả chín mục tắt animation và giữ lại hình đã hoàn tất — check đã vẽ xong, tim đã tô đặc — vì tắt chuyển động mà xoá luôn kết quả thì thông tin kiểu "đã lưu chưa" biến mất theo. Các hiệu ứng CSS khác của site nằm trong chuyên mục CSS, còn lý do trang này ra đời thì đọc ở trang giới thiệu.
FAQ
Có thể thay SVG bằng web font icon không?
Được, nhưng web font hay lộ ký tự vỡ hình trong lúc font chưa tải xong, có lúc còn không hiện gì cả. SVG nhúng thẳng trong HTML không cần tải thêm file nào nên mở file trong zip ra là thấy ngay.
Bỏ transform-box: fill-box thì icon xoay sẽ thế nào?
Trục xoay rơi về góc trên trái của viewport SVG thay vì tâm hình dạng, nên với icon không nằm giữa khung như refresh ở mục 05, cả hình sẽ văng lệch sang một bên khi quay thay vì đứng yên tại chỗ xoay tròn.
Năm nút icon quản lý trạng thái trong React ra sao?
Thư mục react/ trong zip dùng useState giữ từng trạng thái playing, open hoặc liked riêng cho mỗi nút, rồi rẽ nhánh className để đổi opacity và thuộc tính aria cùng lúc thay vì toggle class thủ công.









