CSS アイコン アニメーション 9選 — 一行でコピペOK
css アイコン アニメーションとは、再生ボタンや通知ベル、更新マークのような定番のインターフェースアイコンを、文字ではなく座標を持つSVGのpathとして描き、状態が変わるたびに動かす手法を指します。書体に頼らないぶん、フォントが変わってもアイコンの形は崩れません。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
並びは「css ボタン アイコン 画像」のような素材の種類ではなく、アイコンが画面で担う役割の順に揃えています。バッジやロゴのように置いておくだけで視線を引く装飾(01・02)から始まり、押すたびに状態が変わるアイコンボタン(03〜07)を経て、文字の隣にそっと添えるだけの装飾(08・09)で終わります。9つとも動かしているのはtransformとopacityだけですが、線を描く3つ(01・02・09)だけはstroke-dasharrayとdashoffsetも使います。この2つはtopやwidthと違ってレイアウトを再計算しないペイント段階のプロパティで、MDNのstroke-dasharrayにも説明があります。ここではアイコン自身が状態を伝えますが、同じ状態の反応を入力欄側に持たせた9つはCSS 入力フォーム フォーカスアニメーション9選に別途あります。平面のまま動かす代わりにz軸で回して奥行きを作る方法は、CSS 3D アニメーション9選で扱っています。
01ストローク描画
dashoffsetで描いて消す
星形の輪郭線(pathLength="100")のstroke-dashoffsetを100から0へ動かして手描きのように浮かび上がらせ、少し止まってから逆方向の-100まで動かして消します。JavaScriptなしで自動的に繰り返すだけの、新着・更新バッジの横に添える装飾アイコンなので、svgにはaria-hidden="true"だけを付け、意味は隣の文字が持ちます。
<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>
New
</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 · ストローク描画 — 計測 累積面積 0.287% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
隣の文字が意味を持つ
.badge__star {
fill: none; stroke: $color; stroke-width: 1.6;
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; }
}
02ロゴのリビール
「svg ロゴ アニメーション」の描き順
ひし形のロゴマークを線版と塗り版の2枚のpathとして重ね、線版のstroke-dashoffsetが100から0まで描き切ったところで塗り版がopacityで浮かび上がり、また線だけに戻って繰り返します。LPのヒーローでブランドロゴを登場させる想定で、ワードマーク「GODRICH」は隣に普通の文字として置くので、svg側はaria-hidden="true"で足ります。
<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 · ロゴのリビール — 計測 累積面積 0.881% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ワードマークは文字として置く
.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;
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; }
}
03アイコンモーフィング
同じグリッド位置に重ねる
再生の三角形と一時停止の2本の棒を別々の<svg>として同じグリッド位置(grid-area)に重ね、クリックのたびにopacityとscale(.85→1)で入れ替える、動画・音声プレーヤーの再生ボタンです。アイコンだけのボタンなので、状態が変わるとsvg内の<title>を「再生」「一時停止」に書き換え、buttonはaria-labelledbyでそのtitleを指します。
<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">再生</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 ? "再生" : "一時停止";
});
});
// 모프 한 겹 — 전환 순간(再生→一時停止, 一時停止→再生)에 링 궤적 + 입자 방출.
// 애니메이션 타이밍을 하드코딩하지 않고 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 · アイコンモーフィング — 計測 累積面積 0.135% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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); }
04ベルが揺れる
ベルの軸をどこに置くか
鐘を包む<g>をtransform-box: fill-boxで上端(50% 0%)を軸に左右へ揺らし、右上のバッジの丸も同じタイミングでscaleパルスする、自動ループの通知ボタンです。最初は揺れる区間をループ全体の15%に収めたところ、測定で動いたフレームが4枚しかなく基準の6枚に届かなかったので、30%まで広げて7枚まで増やしました。
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-label="通知3件">
<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 · ベルが揺れる — 計測 累積面積 0.233% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
測定でフレームが足りなかったとき
.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; }
@keyframes shake {
0%, 30%, 100% { transform: rotate(0); }
4% { transform: rotate(8deg); }
8% { transform: rotate(-8deg); }
16% { transform: rotate(-6deg); }
24% { transform: rotate(-3deg); }
}
05リフレッシュ回転
図形がビューボックスの中心にない
2本の矢印から成る更新アイコンは24×24のビューボックスの真ん中に収まっていないため、<g>にtransform-box: fill-boxを与えて図形自身の境界ボックスを軸にし、クリックのたびに600msで一回転させます。リストやフィードを読み込み直すボタンの想定で、aria-label="更新"をbuttonに付け、svg自体はaria-hiddenのままにします。
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-label="更新">
<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 · リフレッシュ回転 — 計測 累積面積 0.43% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
更新ボタンとしての名前
.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); } }
06メニューアイコンモーフ
回転をいっさい使わない入れ替え
点が3つ並んだメニューのpathとXのpathを同じグリッド位置に重ね、回転はいっさい使わずopacityだけで220msかけて入れ替えます。線が重なって回転しながらXになる一般的な作り方とは別で、開閉のたびにsvg内<title>を「メニューを開く」「メニューを閉じる」に更新し、buttonはaria-labelledbyでそれを参照します。
<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">メニューを開く</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 ? "メニューを開く" : "メニューを閉じる";
});
});
// ── 한 겹: 전환 순간 링 파동 + 스파크 입자 ─────────────────
// 점↔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 · メニューアイコンモーフ — 計測 累積面積 0.111% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
一般的なハンバーガーとの違い
.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; }
07ハートパルス
押すと現れる塗り側
輪郭だけのハートと塗りつぶしたハートを重ねておき、押すと塗り側がopacityで現れると同時に両方のscaleが1から1.3まで跳ねてから1に戻ります。投稿や商品のいいねボタンを想定し、buttonはaria-pressedを切り替えつつaria-label="いいね"で名前を持ちます。
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-pressed="false" aria-label="いいね">
<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 · ハートパルス — 計測 累積面積 0.338% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
aria-pressedとaria-label
.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; }
}
08矢印移動
2秒周期で往復する矢印
下向きの矢印ひとつが2秒周期でtranslateYを0から8pxまで往復し、ページの続きを示すスクロール誘導として自動ループします。隣にある「もっと見る」という見える文字が意味を持つ装飾なので、svgにはaria-hidden="true"だけを付けます。
<main class="stage">
<p class="cue">
もっと見る
<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 · 矢印移動 — 計測 累積面積 0.108% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
装飾なのでaria-hidden
.cue__arrow { color: $color; animation: bob $duration $easing infinite; }
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); } // $sp-2
}
@media (prefers-reduced-motion: reduce) {
.cue__arrow { animation: none; }
}
09チェック描画
左から描き切って消す
polylineで作ったチェックのstroke-dashoffsetを100から0まで動かして左から描き切り、少し止まってから-100まで動かして消す自動ループの確認アイコンです。最初はアイコンを26pxで作ったところ測定した変化面積が基準の0.08%を下回る0.04%だったため、64pxに広げ線の太さも3.2pxまで太くして0.31%まで伸ばしました。
<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>
保存しました
</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 · チェック描画 — 計測 累積面積 0.34% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
26pxが基準に届かなかったとき
.confirm__check {
stroke-dasharray: 100;
animation: draw $duration $easing infinite;
}
@keyframes draw {
0% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
回転の軸はどこで狂うのか — 落とし穴
軸はビューポート基準になる
一番よく踏む落とし穴は回転の軸です。<g>や<path>をそのままrotate()させると、軸はその要素が属するSVGビューポートの(0,0)〜(24,24)を基準に決まってしまい、04のベルや05のリフレッシュのように図形がビューボックスの片側に寄っていると、軸ごと変な場所へ飛びます。transform-box: fill-boxを足すと、図形自身の境界ボックスが軸になります。この値はChrome 64・Edge 79・Firefox 55、iOSを含むSafari 11以降で使え、caniuseの対応表を見てもまず引っかかりません。04と09は測定値が基準に届かず一度ずつ直しており、そのとき直した値のまま入ったzipの解凍パスワードがdatzbfgjで、下のカードから受け取れます。
グリフではなくpathで描く理由
もうひとつはグリフの選び方です。チェックや矢印を✓や→のような文字として置くと、閲覧する端末のフォントによって表示されなかったり、まったく違う形に化けたりします。9つすべてをSVGのpathで描いたのはこのためで、文字コードに運命を預けません。
pathで描いたアイコンの線の太さは、平らなカードの上に置く前提で決めています。内外の影でぷっくり膨らませた面に同じアイコンを載せると見え方が変わるので、その土台の作り方はクレイモーフィズムのLPパーツ9選にまとめています。
アクセシビリティ
アイコンだけのボタンに名前を
アイコンだけのボタン5つ(03・04・05・06・07)は名前がないとスクリーンリーダーに「ボタン」としか読まれず、何をする操作か伝わりません。03・06はsvg内の<title>とbuttonのaria-labelledbyで名前を持たせ、状態が変わるたびにそのtitleを書き換えます。04・05・07は状態によらずaria-labelで固定の名前を持たせます。
<button aria-labelledby="t3">
<svg><title id="t3">再生</title><path d="M8 5l11 7-11 7z"/></svg>
</button>
装飾アイコンは隠す
残る4つ(01・02・08・09)は隣に見える文字がすでに意味を伝える装飾なので、svgにはaria-hidden="true"だけを付けます。
<svg aria-hidden="true"><path class="badge__star" d="M12 2l2.9 6.9L22 9.3z"/></svg> 新着
動きを止めても結果は残す
prefers-reduced-motion: reduceでは9つとも動きを止め、完成した形(描き終えたチェック、塗りきったハート)だけを残します。動きだけを止めて結果まで消すと「保存できたか」のような情報そのものが失われるからです。アイコン以外のCSSアニメーションはこちらのカテゴリーに、この記事を書いた背景はサイトについてにまとめています。
FAQ
アイコンをテキストの絵文字(✓や→)で代用してはいけないのですか?
代用はできますが、避けたほうがいいです。絵文字や記号はOSやブラウザに載っているフォントに描画を頼るため、端末によって太さや形が変わったり、対応するグリフ自体がなく四角い枠だけが表示されたりします。SVGのpathならファイルの中に形そのものを持つので、どの端末を開いても同じ輪郭になります。
「css ローディング アイコン」でtransform-box: fill-boxを外すとどうなりますか?
rotate()の軸がSVGビューポートの左上を基準に戻ってしまいます。05のリフレッシュで試すと、矢印の図形がビューボックスの隅に寄ったまま回るので、軸が中心からずれてぐらついて見える回り方になります。08の矢印移動のように向きを示す動きだけが必要な場合は、矢印 CSS アニメーション9選に9種類がまとまっています。
アイコンボタン5つはReactでどう状態を持たせていますか?
zipのreact/フォルダの各コンポーネントは、playing・open・likedのような状態をひとつずつuseStateで持ち、クラスの付け外しではなくclassNameの分岐でopacityとaria属性を同時に切り替えます。









