9 SVG Icon Animation CSS — One Line, Copy-Paste
These nine svg icon animation css patterns swap a static interface icon for a live one, redrawing a bell, a refresh arrow, or a play button as an inline SVG
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Stroke draw
- 02 Logo reveal
- 03 Icon morph
- 04 Bell shake
- 05 Refresh spin
- 06 Menu icon morph
- 07 Heart pulse
- 08 Arrow move
- 09 Check draw
The order below follows the role each icon plays on screen. It opens with two icons that just sit still until they catch the eye — a badge star and a logo mark (01, 02). It moves through five icon buttons that switch state on a click (03 through 07), and closes with two icons that quietly support a line of text (08, 09). All nine touch only transform and opacity, and the three built by drawing a line — 01, 02, and 09 — also animate stroke-dasharray and stroke-dashoffset. Those two live in the paint step rather than layout, so unlike a property such as width they never force a reflow; the MDN stroke-dasharray reference covers how a browser treats them. An icon here carries the state on its own; the same state reactions wired into a form field instead are collected in 9 CSS input focus animations. Turning the same shapes on the z axis, so depth rather than a stroke does the work, is what the 9 CSS 3D cube effects are for.
01Stroke draw
Drawing the outline with dashoffset
A star icon's path, given pathLength="100", animates stroke-dashoffset from 100 down to 0 so the outline appears to draw itself, holds a beat, then unwinds the other way back to hidden.
<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 · Stroke draw — Measured area 0.287% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Beside visible text, aria-hidden
It sits beside visible text such as "New feature," so the SVG only needs aria-hidden="true" and never competes with the label for a screen reader's attention.
.badge__star {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-dasharray: 100; // pathLength="100" lets dasharray act like a percent
animation: draw $duration $easing infinite;
}
@keyframes draw {
0% { stroke-dashoffset: 100; } // hidden
55% { stroke-dashoffset: 0; } // fully drawn
80% { stroke-dashoffset: 0; } // brief hold
100% { stroke-dashoffset: -100; } // erases toward the other end
}
@media (prefers-reduced-motion: reduce) {
.badge__star { animation: none; stroke-dashoffset: 0; }
}
02Logo reveal
Two copies of the same mark
Two copies of the same diamond mark sit stacked — one stroke-only, one filled — and the stroke draws itself first before the fill fades in over it via opacity, then both reset.
<main class="stage">
<p class="mark">
<svg viewBox="0 0 24 24" width="56" height="56" aria-hidden="true">
<path class="mark__fill" d="M12 3L21 12L12 21L3 12Z"/>
<path class="mark__trail" pathLength="100" d="M12 3L21 12L12 21L3 12Z"/>
<path class="mark__line" pathLength="100" d="M12 3L21 12L12 21L3 12Z"/>
<circle class="mark__spark" r="1" cx="12" cy="3"/>
<circle class="mark__bit" r=".5" cx="12" cy="3" style="--dx:0px;--dy:-2.4px"/>
<circle class="mark__bit" r=".45" cx="12" cy="3" style="--dx:2px;--dy:0px"/>
<circle class="mark__bit" r=".45" cx="12" cy="3" style="--dx:-2px;--dy:0px"/>
</svg>
GODRICH
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter; // 600ms — 선이 그려지는 시간
$easing: $ease-out;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.mark {
margin: 0;
display: inline-flex; align-items: center; gap: clamp($sp-2, 3vw, $sp-3);
color: $color; font-weight: 800; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit; letter-spacing: .08em;
}
.mark svg { width: clamp(44px, 13vw, 56px); height: clamp(44px, 13vw, 56px); }
.mark__fill {
fill: $color; opacity: 0;
animation: fill-in $duration $easing infinite;
}
.mark__line {
fill: none; stroke: $color; stroke-width: 1.6;
stroke-dasharray: 100; // pathLength="100"
animation: line-draw $duration $easing infinite;
}
// 1단계: 선으로 그린다 → 2단계: 면을 채운다 → 다시 선만 남기고 반복
@keyframes line-draw {
0% { stroke-dashoffset: 100; opacity: 1; }
55% { stroke-dashoffset: 0; opacity: 1; }
70% { opacity: 1; }
85% { opacity: 0; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes fill-in {
0%, 55% { opacity: 0; }
75%, 85% { opacity: 1; }
100% { opacity: 0; }
}
// ── 한 겹: 펜 끝 스파크 + 미광 잔상 ──────────────────────
// 본선 뒤에 남는 잔상 — 지워질 때 본선보다 반박자 늦게 사라져 펜 자국이 빛난다
.mark__trail {
fill: none; stroke: $color; stroke-width: 1.6;
stroke-dasharray: 100;
animation: trail-fade $duration $easing infinite;
}
@keyframes trail-fade {
0% { stroke-dashoffset: 100; opacity: .4; }
55% { stroke-dashoffset: 0; opacity: .4; }
78% { stroke-dashoffset: 0; opacity: .4; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
// 펜 끝 스파크 — 그리기 단계에만 JS 가 좌표를 옮기고 켠다
.mark__spark {
fill: $color;
opacity: 0;
transform: scale(1.5); transform-origin: center; transform-box: fill-box;
filter: drop-shadow(0 0 1px $color);
}
// 펜이 지난 자리에서 진행 반대 방향으로 튀는 찌꺼기
.mark__bit {
fill: $color;
opacity: 0;
animation: bit-fly 420ms $ease-out forwards;
}
@keyframes bit-fly {
0% { opacity: .9; transform: translate(0, 0) scale(1); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.3); }
}
// ── 접근성: 움직임 줄이기 설정이면 완성된 마크만 남긴다 ──────
@media (prefers-reduced-motion: reduce) {
.mark__line { animation: none; stroke-dashoffset: 0; }
.mark__fill { animation: none; opacity: 1; }
.mark__trail { display: none; }
.mark__spark { display: none; }
.mark__bit { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mark {
margin: 0;
display: inline-flex;
align-items: center;
gap: clamp(8px, 3vw, 12px);
color: #17141a;
font-weight: 800;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.08em;
}
.mark svg {
width: clamp(44px, 13vw, 56px);
height: clamp(44px, 13vw, 56px);
}
.mark__fill {
fill: #17141a;
opacity: 0;
animation: fill-in 600ms ease-out infinite;
}
.mark__line {
fill: none;
stroke: #17141a;
stroke-width: 1.6;
stroke-dasharray: 100;
animation: line-draw 600ms ease-out infinite;
}
@keyframes line-draw {
0% {
stroke-dashoffset: 100;
opacity: 1;
}
55% {
stroke-dashoffset: 0;
opacity: 1;
}
70% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
stroke-dashoffset: 0;
opacity: 0;
}
}
@keyframes fill-in {
0%, 55% {
opacity: 0;
}
75%, 85% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.mark__trail {
fill: none;
stroke: #17141a;
stroke-width: 1.6;
stroke-dasharray: 100;
animation: trail-fade 600ms ease-out infinite;
}
@keyframes trail-fade {
0% {
stroke-dashoffset: 100;
opacity: 0.4;
}
55% {
stroke-dashoffset: 0;
opacity: 0.4;
}
78% {
stroke-dashoffset: 0;
opacity: 0.4;
}
100% {
stroke-dashoffset: 0;
opacity: 0;
}
}
.mark__spark {
fill: #17141a;
opacity: 0;
transform: scale(1.5);
transform-origin: center;
transform-box: fill-box;
filter: drop-shadow(0 0 1px #17141a);
}
.mark__bit {
fill: #17141a;
opacity: 0;
animation: bit-fly 420ms ease-out forwards;
}
@keyframes bit-fly {
0% {
opacity: 0.9;
transform: translate(0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate(var(--dx), var(--dy)) scale(0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.mark__line {
animation: none;
stroke-dashoffset: 0;
}
.mark__fill {
animation: none;
opacity: 1;
}
.mark__trail {
display: none;
}
.mark__spark {
display: none;
}
.mark__bit {
display: none;
}
}// 로고 리빌 한 겹 — 선 그리기 단계(0~55%)에서만 펜 끝을 추적해 스파크를 옮기고 찌꺼기를 뿌린다.
// 계산 방식은 01 과 같다: getTotalLength 로 실측한 경로 위를 getPointAtLength 로 훑는다.
(function () {
var svg = document.querySelector('.mark svg');
if (!svg) return;
var line = svg.querySelector('.mark__line');
var spark = svg.querySelector('.mark__spark');
var bits = [].slice.call(svg.querySelectorAll('.mark__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark || !line.getTotalLength) { if (spark) spark.style.display = 'none'; return; }
var L = line.getTotalLength();
var prev = null, lastEmit = 0, emitIdx = 0;
function frame(t) {
var off = parseFloat(getComputedStyle(line).strokeDashoffset) || 0;
var at = Math.max(0, Math.min(100, 100 - off));
var p = line.getPointAtLength(at / 100 * L);
if (prev && (Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y)) > .05) {
spark.setAttribute('cx', p.x);
spark.setAttribute('cy', p.y);
spark.style.opacity = 1;
if (t - lastEmit > 60) {
lastEmit = t;
var ang = Math.atan2(p.y - prev.y, p.x - prev.x) + Math.PI;
var sp = .4 + Math.random() * .5;
var b = bits[emitIdx++ % bits.length];
b.setAttribute('cx', p.x);
b.setAttribute('cy', p.y);
b.style.setProperty('--dx', (Math.cos(ang) * sp * 5).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp * 5).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBBox;
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
prev = p;
} else {
if (prev) spark.style.opacity = 0;
if (!prev) prev = p;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./LogoReveal.module.scss";
export type LogoRevealProps = {
wordmark?: string;
/** 선이 그려지는 시간, ms */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const BIT_DIRS: Array<[number, number]> = [
[0, -2.4],
[2, 0],
[-2, 0],
];
export default function LogoReveal({
wordmark = "GODRICH",
duration = 600,
easing = "ease-out",
color = "#17141a",
}: LogoRevealProps) {
const lineRef = useRef<SVGPathElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
// 한 겹: 펜 끝 추적 — getTotalLength 실측 + getPointAtLength 로 스파크를 옮기고
// 이동 방향 뒤쪽으로 찌꺼기를 방출한다 (vanilla 데모와 동일 계산)
useEffect(() => {
const line = lineRef.current, spark = sparkRef.current;
if (!line || !spark || !line.getTotalLength) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const L = line.getTotalLength();
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
let prev: DOMPoint | null = null, lastEmit = 0, emitIdx = 0, raf = 0;
const frame = (t: number) => {
const off = parseFloat(getComputedStyle(line).strokeDashoffset) || 0;
const at = Math.max(0, Math.min(100, 100 - off));
const p = line.getPointAtLength(at / 100 * L);
if (prev && Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y) > 0.05) {
spark.setAttribute("cx", String(p.x));
spark.setAttribute("cy", String(p.y));
spark.style.opacity = "1";
if (t - lastEmit > 60 && bits.length) {
lastEmit = t;
const ang = Math.atan2(p.y - prev.y, p.x - prev.x) + Math.PI;
const sp = 0.4 + Math.random() * 0.5;
const b = bits[emitIdx++ % bits.length];
b.setAttribute("cx", String(p.x));
b.setAttribute("cy", String(p.y));
b.style.setProperty("--dx", (Math.cos(ang) * sp * 5).toFixed(2) + "px");
b.style.setProperty("--dy", (Math.sin(ang) * sp * 5).toFixed(2) + "px");
b.style.animation = "none";
void b.getBoundingClientRect();
b.style.animation = "";
}
prev = p;
} else {
if (prev) spark.style.opacity = "0";
if (!prev) prev = p;
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<p className={styles.mark} style={vars}>
<svg viewBox="0 0 24 24" width="56" height="56" aria-hidden="true">
<path className={styles.mark__fill} d="M12 3L21 12L12 21L3 12Z" />
<path className={styles.mark__trail} pathLength={100} d="M12 3L21 12L12 21L3 12Z" />
<path ref={lineRef} className={styles.mark__line} pathLength={100} d="M12 3L21 12L12 21L3 12Z" />
<circle ref={sparkRef} className={styles.mark__spark} r={1} cx={12} cy={3} />
{BIT_DIRS.map(([dx, dy], i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles.mark__bit}
r={i === 0 ? 0.5 : 0.45}
cx={12}
cy={3}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
{wordmark}
</p>
);
}02 · Logo reveal — Measured area 0.881% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a retracing logo fits
This svg logo animation css suits a hero section where a hand-drawn brand mark keeps retracing itself on a loop, rather than sitting there static like a plain logo.
.mark__line {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-dasharray: 100;
animation: line-draw $duration $easing infinite;
}
.mark__fill {
fill: $color;
opacity: 0;
animation: fill-in $duration $easing infinite;
}
@keyframes line-draw {
0% { stroke-dashoffset: 100; opacity: 1; }
55% { stroke-dashoffset: 0; opacity: 1; }
85% { opacity: 0; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes fill-in {
0%, 55% { opacity: 0; }
75%, 85% { opacity: 1; }
}
03Icon morph
Two glyphs in one grid cell
A play triangle and a pause glyph sit in the same grid cell and cross over on click, fading opacity while scaling between .85 and 1 rather than one shape rebuilding into the other.
<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">Play</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 ? "Play" : "Pause";
});
});
// 모프 한 겹 — 전환 순간(Play→Pause, Pause→Play)에 링 궤적 + 입자 방출.
// 애니메이션 타이밍을 하드코딩하지 않고 getComputedStyle() 로 아이콘 opacity 실측값을
// 읽어 "페이드가 진행 중"인 순간을 감지해 그 순간만 발화한다 (01 스파크와 같은 원리).
(function () {
var btn = document.querySelector('.iconbtn');
if (!btn) return;
var ghost = btn.querySelector('.morph__ghost');
var halo = btn.querySelector('.morph__halo');
var spark = btn.querySelector('.morph__spark');
var bits = [].slice.call(btn.querySelectorAll('.morph__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !ghost || !bits.length) return;
var prevOp = null, armed = true;
var dPlay = btn.querySelector('.ic--play path');
var dPause = btn.querySelector('.ic--pause path');
// 모프 순간 고스트 — '나타나는' 아이콘의 실루엣이 링처럼 번지며 지워진다 (잔상)
function burst(d) {
ghost.setAttribute('d', d);
ghost.style.animation = 'none';
void ghost.getBoundingClientRect();
ghost.style.animation = '';
ghost.style.animationName = 'ghost-ring';
// 링 파동 — 중앙에서 퍼지며 옅어지는 미광 (06 halo 와 같은 원리)
if (halo) {
halo.style.animation = 'none';
void halo.getBoundingClientRect();
halo.style.animation = '';
halo.style.animationName = 'halo-ring';
}
// 중앙 스파크 점멸
if (spark) {
spark.style.animation = 'none';
void spark.getBoundingClientRect();
spark.style.animation = '';
spark.style.animationName = 'spark-pop';
}
bits.forEach(function (b) {
var ang = Math.random() * Math.PI * 2;
var sp = 2.4 + Math.random() * 1.4;
b.style.setProperty('--dx', (Math.cos(ang) * sp).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
});
}
function watch() {
// is-demo 루프 중엔 play 루프의 opacity 로 전환 순간을 잡는다
var target = btn.classList.contains('is-demo')
? btn.querySelector('.ic--play')
: (btn.getAttribute('aria-pressed') === 'true' ? btn.querySelector('.ic--pause') : btn.querySelector('.ic--play'));
if (target) {
var op = parseFloat(getComputedStyle(target).opacity);
var moving = op > 0.02 && op < 0.98;
// 전환당 한 번만: 페이드 시작 순간에 발화하고(opacity 가 완전히 멈출 때까지 재장전 안 함)
if (moving && armed) {
// 나타나는 아이콘: 루프 중엔 opacity 상승=play 복귀·하강=pause 진입,
// 클릭 후엔 target 자체가 나타나는 아이콘이다
var rising = prevOp === null || op > prevOp;
var incoming = btn.classList.contains('is-demo') ? (rising ? dPlay : dPause)
: (target.classList.contains('ic--play') ? dPlay : dPause);
burst(incoming.getAttribute('d'));
armed = false;
}
if (!moving && prevOp !== null && Math.abs(op - prevOp) < 0.002) armed = true;
prevOp = op;
}
requestAnimationFrame(watch);
}
requestAnimationFrame(watch);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PlayPauseIcon.module.scss";
export type PlayPauseIconProps = {
/** 재생↔일시정지 전환 시간, ms */
duration?: number;
easing?: string;
color?: string;
defaultPlaying?: boolean;
onChange?: (playing: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const D_PLAY = "M8 5l11 7-11 7z";
const D_PAUSE = "M7 5h4v14H7zM13 5h4v14h-4z";
export default function PlayPauseIcon({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
defaultPlaying = false,
onChange,
}: PlayPauseIconProps) {
const [playing, setPlaying] = useState(defaultPlaying);
// 모프 순간마다 n +1 → 링·입자 재시작. dirs 는 방출마다 다시 뽑는다 (vanilla 와 동일)
const [burst, setBurst] = useState<{ n: number; dirs: Array<[number, number]>; d: string }>({ n: 0, dirs: [], d: D_PLAY });
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const reduceRef = useRef(false);
// 모프 한 겹: prefers-reduced-motion 이면 발화하지 않는다 (CSS 층에서 display:none 처리)
useEffect(() => {
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}, []);
return (
<button
type="button"
className={styles.iconbtn}
style={vars}
aria-pressed={playing}
aria-label={playing ? "일시정지" : "재생"}
onClick={() => {
const next = !playing;
setPlaying(next);
if (!reduceRef.current) {
setBurst((b) => ({
n: b.n + 1,
d: next ? D_PAUSE : D_PLAY, // 나타나는 아이콘의 실루엣이 번진다
dirs: [0, 1, 2, 3, 4, 5].map(() => {
const ang = Math.random() * Math.PI * 2;
const sp = 2.4 + Math.random() * 1.4;
return [Math.cos(ang) * sp, Math.sin(ang) * sp] as [number, number];
}),
}));
}
onChange?.(next);
}}
>
<span className={styles.iconbtn__stack}>
<svg className={`${styles.ic} ${playing ? styles["ic--hidden"] : ""}`} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d={D_PLAY} />
</svg>
<svg className={`${styles.ic} ${playing ? "" : styles["ic--hidden"]}`} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d={D_PAUSE} />
</svg>
{/* 한 겹: 모프 순간 고스트 링 + 입자 — key 바뀌면 애니메이션이 재시작된다.
고스트는 '나타나는' 아이콘의 실루엣이 링처럼 번지며 지워진다 (잔상) */}
{burst.n > 0 && (
<svg key={burst.n} className={styles.morph} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle className={styles["morph__halo"]} cx={12} cy={12} r={7} />
<path className={styles["morph__ghost"]} d={burst.d} />
<circle className={styles["morph__spark"]} r={0.9} cx={12} cy={12} />
{burst.dirs.map(([dx, dy], i) => (
<circle
key={i}
className={styles["morph__bit"]}
r={i === 0 ? 0.5 : 0.45}
cx={12}
cy={11}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
)}
</span>
</button>
);
}03 · Icon morph — Measured area 0.135% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Swapping the title text
Because the button carries no other label, an <title> inside each SVG is what a screen reader actually reads, so the markup swaps that title text — "Play" to "Pause" — the instant aria-pressed flips.
.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); }
@media (prefers-reduced-motion: reduce) {
.ic { transition: none; }
}
04Bell shake
Rocking around the mount point
A bell rocks left and right around its mount point while a badge dot above it swells and settles on the same beat, both pivoting on transform-box: fill-box rather than the SVG viewport's corner.
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-label="3 notifications">
<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 · Bell shake — Measured area 0.233% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
What actually reaches a screen reader
A bare aria-label="3 notifications" on the button is what actually tells a screen reader anything happened, since the shaking svg itself stays aria-hidden.
// transform-box: fill-box makes the bell's own bounding box the rotation
// axis, instead of the SVG viewport's (0,0)-(24,24) corner
.bell {
transform-box: fill-box;
transform-origin: 50% 0%; // the bell's mount point, top center
}
.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); }
16% { transform: rotate(-6deg); }
24% { transform: rotate(-3deg); }
}
05Refresh spin
Why a css icon spin animation needs fill-box
The two-arrow refresh shape isn't centered in its 24x24 viewBox, so rotating the <g> on transform-box: fill-box pivots it around its own bounding box rather than the viewport corner, letting one click spin it a clean 360 degrees.
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-label="Refresh">
<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 · Refresh spin — Measured area 0.43% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Naming the button
The button leans on aria-label="Refresh" for its name while the arrows stay aria-hidden.
// the two-arrow shape isn't centered in the 24x24 viewBox, so
// transform-box: fill-box pivots on the shape's own bounding box instead
.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); }
}
06Menu icon morph
Cross-dissolve, no rotation
This css menu icon animation cross-dissolves a three-dot glyph and an X in the same cell on opacity alone when the button opens or closes — no rotation, unlike a hamburger built from three bars spinning into an X.
<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">Open menu</title>
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
<svg class="ic ic--x" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<svg class="fx" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle class="fx__halo" cx="12" cy="12" r="6"/>
<circle class="fx__spark" r=".9" cx="12" cy="12"/>
<circle class="fx__bit" r=".5" cx="5" cy="12" style="--dx:-2.6px;--dy:0px"/>
<circle class="fx__bit" r=".5" cx="12" cy="12" style="--dx:0px;--dy:-2.6px"/>
<circle class="fx__bit" r=".5" cx="19" cy="12" style="--dx:2.6px;--dy:0px"/>
<circle class="fx__bit" r=".45" cx="7" cy="7" style="--dx:-2px;--dy:-2px"/>
<circle class="fx__bit" r=".45" cx="17" cy="17" style="--dx:2px;--dy:2px"/>
<circle class="fx__bit" r=".45" cx="17" cy="7" style="--dx:2px;--dy:-2px"/>
</svg>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick; // 220ms — 실제 클릭 전환 시간
$easing: $ease-out;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.iconbtn {
border: 0; border-radius: $r-control; cursor: pointer;
width: clamp(52px, 14vw, 64px); height: clamp(52px, 14vw, 64px);
display: grid; place-items: center;
background: rgba(23, 20, 26, .08); color: $color;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
.iconbtn__stack { display: grid; place-items: center; width: 100%; height: 100%; }
// 점 3개 아이콘과 X 아이콘을 같은 칸에 겹쳐 놓고 opacity 로만 넘긴다
.ic { grid-area: 1 / 1; fill: currentColor; transition: opacity $duration $easing; }
.ic--dots { opacity: 1; }
.ic--x { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--dots { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--x { opacity: 1; }
.iconbtn.is-demo .ic--dots { animation: dots-loop $dur-loop $easing infinite; }
.iconbtn.is-demo .ic--x { animation: x-loop $dur-loop $easing infinite; }
@keyframes dots-loop { 0%, 15% { opacity: 1; } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; } }
@keyframes x-loop { 0%, 15% { opacity: 0; } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; } }
// ── 한 겹: 전환 순간 링 파동 + 스파크 입자 ─────────────────
// 아이콘과 같은 칸에 겹쳐 놓은 효과 레이어 — 레이아웃에는 영향이 없다
.fx {
grid-area: 1 / 1;
fill: currentColor;
pointer-events: none;
}
// 링 파동 — 전환 순간 중앙에서 퍼지며 옅어지는 미광
.fx__halo {
fill: none;
stroke: $color;
stroke-width: .8;
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
}
@keyframes halo-ring {
0% { opacity: .55; transform: scale(.4); }
100% { opacity: 0; transform: scale(1.7); }
}
// 중앙 스파크 — 터지는 순간 밝게 점멸
.fx__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px $color);
}
@keyframes spark-pop {
0% { opacity: .95; transform: scale(1.8); }
60% { opacity: .4; transform: scale(.8); }
100% { opacity: 0; transform: scale(.3); }
}
// 전환 순간 흩어지는 입자 — 각자 지정 방향(--dx,--dy)으로 튄다
.fx__bit {
opacity: 0;
transform: translate(0, 0);
animation: bit-fly 420ms $ease-out forwards;
}
@keyframes bit-fly {
0% { opacity: .9; transform: translate(0, 0) scale(1); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.3); }
}
@media (prefers-reduced-motion: reduce) {
.iconbtn.is-demo .ic--dots, .iconbtn.is-demo .ic--x { animation: none; }
.ic { transition: none; }
.fx { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.iconbtn {
border: 0;
border-radius: 12px;
cursor: pointer;
width: clamp(52px, 14vw, 64px);
height: clamp(52px, 14vw, 64px);
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.iconbtn__stack {
display: grid;
place-items: center;
width: 100%;
height: 100%;
}
.ic {
grid-area: 1/1;
fill: currentColor;
transition: opacity 220ms ease-out;
}
.ic--dots {
opacity: 1;
}
.ic--x {
opacity: 0;
}
.iconbtn[aria-expanded=true] .ic--dots {
opacity: 0;
}
.iconbtn[aria-expanded=true] .ic--x {
opacity: 1;
}
.iconbtn.is-demo .ic--dots {
animation: dots-loop 2s ease-out infinite;
}
.iconbtn.is-demo .ic--x {
animation: x-loop 2s ease-out infinite;
}
@keyframes dots-loop {
0%, 15% {
opacity: 1;
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes x-loop {
0%, 15% {
opacity: 0;
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
.fx {
grid-area: 1/1;
fill: currentColor;
pointer-events: none;
}
.fx__halo {
fill: none;
stroke: #17141a;
stroke-width: 0.8;
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
}
@keyframes halo-ring {
0% {
opacity: 0.55;
transform: scale(0.4);
}
100% {
opacity: 0;
transform: scale(1.7);
}
}
.fx__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px #17141a);
}
@keyframes spark-pop {
0% {
opacity: 0.95;
transform: scale(1.8);
}
60% {
opacity: 0.4;
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(0.3);
}
}
.fx__bit {
opacity: 0;
transform: translate(0, 0);
animation: bit-fly 420ms ease-out forwards;
}
@keyframes bit-fly {
0% {
opacity: 0.9;
transform: translate(0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate(var(--dx), var(--dy)) scale(0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.iconbtn.is-demo .ic--dots, .iconbtn.is-demo .ic--x {
animation: none;
}
.ic {
transition: none;
}
.fx {
display: none;
}
}document.querySelectorAll(".iconbtn").forEach(function (b) {
var t = b.querySelector("title");
b.addEventListener("click", function () {
b.classList.remove("is-demo");
var open = b.getAttribute("aria-expanded") === "true";
b.setAttribute("aria-expanded", String(!open));
t.textContent = open ? "Open menu" : "Close menu";
});
});
// ── 한 겹: 전환 순간 링 파동 + 스파크 입자 ─────────────────
// 점↔X 크로스페이드는 짧은 사건(spike)이다 — 01 처럼 펜 끝을 쫓는 대신, 전환 프레임을
// getComputedStyle opacity 실측으로 감지해 그 순간 중앙에서 링(halo)과 입자를 터뜨린다.
(function () {
var btn = document.querySelector('.iconbtn');
if (!btn) return;
var fx = btn.querySelector('.fx');
if (!fx) return;
var dots = btn.querySelector('.ic--dots');
var x = btn.querySelector('.ic--x');
var halo = fx.querySelector('.fx__halo');
var spark = fx.querySelector('.fx__spark');
var bits = [].slice.call(fx.querySelectorAll('.fx__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { fx.style.display = 'none'; return; }
if (!halo || !spark || !dots || !x) return;
var prevO = null, prevX = null, lastBurst = 0;
function burst(t) {
// 링 파동 — 스타일 재시작으로 다시 퍼진다
halo.style.animation = 'none';
void halo.getBoundingClientRect();
halo.style.animation = '';
halo.style.animationName = 'halo-ring';
// 중앙 스파크 점멸
spark.style.animation = 'none';
void spark.getBoundingClientRect();
spark.style.animation = '';
spark.style.animationName = 'spark-pop';
// 입자 6개 — 각자 지정된 방향으로 흩어진다
bits.forEach(function (b, i) {
b.style.animationDelay = (i % 3) * 18 + 'ms';
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
});
lastBurst = t;
}
function frame(t) {
// 전환 = 두 아이콘 opacity 의 교차. 어느 한쪽이 움직이는 프레임을 실측으로 잡는다
// (데모 루프의 교차는 500ms 에 걸쳐 일어나 프레임당 Δ≈.03 — 문턱은 그 아래로)
// 데모 루프의 교차 페이드는 500ms — 문턱을 그보다 길게 잡아 한 전환에 한 번만 터진다
var od = parseFloat(getComputedStyle(dots).opacity);
var ox = parseFloat(getComputedStyle(x).opacity);
if (prevO !== null) {
var dD = Math.abs(od - prevO), dX = Math.abs(ox - prevX);
if ((dD > .02 || dX > .02) && t - lastBurst > 700) burst(t);
}
prevO = od; prevX = ox;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MenuMorph.module.scss";
export type MenuMorphProps = {
/** 점→X 전환 시간, ms */
duration?: number;
easing?: string;
color?: string;
onToggle?: (open: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// 입자 6개의 시작 좌표(뷰박스)와 흩어질 방향 — 점 3개→X 교차점 중앙 기준 방사형
const BITS: Array<{ x: number; y: number; dx: number; dy: number; r: number }> = [
{ x: 5, y: 12, dx: -2.6, dy: 0, r: 0.5 },
{ x: 12, y: 12, dx: 0, dy: -2.6, r: 0.5 },
{ x: 19, y: 12, dx: 2.6, dy: 0, r: 0.5 },
{ x: 7, y: 7, dx: -2, dy: -2, r: 0.45 },
{ x: 17, y: 17, dx: 2, dy: 2, r: 0.45 },
{ x: 17, y: 7, dx: 2, dy: -2, r: 0.45 },
];
export default function MenuMorph({
duration = 220,
easing = "ease-out",
color = "#17141a",
onToggle,
}: MenuMorphProps) {
const [open, setOpen] = useState(false);
const haloRef = useRef<SVGCircleElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
const burstRef = useRef<(() => void) | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 한 겹: 전환 순간 링 파동 + 스파크 입자 (vanilla 데모와 동일 연출).
// 애니메이션은 css-module keyframes 가 달려 있고, burst 는 재시작만 담당한다.
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
// 01 과 같은 재시작 법칙: none 으로 지웠다가 지운다 — stylesheet 의 모듈 keyframes 이름이 그대로 돌아온다
const restart = (el: SVGElement, delay?: string) => {
el.style.animation = "none";
void el.getBoundingClientRect();
el.style.animation = "";
if (delay !== undefined) el.style.animationDelay = delay;
};
burstRef.current = () => {
if (haloRef.current) restart(haloRef.current);
if (sparkRef.current) restart(sparkRef.current);
bits.forEach((b, i) => restart(b, (i % 3) * 18 + "ms"));
};
return () => { burstRef.current = null; };
}, []);
return (
<button
type="button"
className={styles.iconbtn}
style={vars}
aria-expanded={open}
aria-label={open ? "메뉴 닫기" : "메뉴 열기"}
onClick={() => {
setOpen((o) => !o);
burstRef.current?.();
onToggle?.(!open);
}}
>
<span className={styles.iconbtn__stack}>
<svg className={`${styles.ic} ${open ? styles["ic--hidden"] : ""}`} viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" />
</svg>
<svg className={`${styles.ic} ${open ? "" : styles["ic--hidden"]}`} viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
<svg className={styles.fx} viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle ref={haloRef} className={styles["fx__halo"]} cx={12} cy={12} r={6} />
<circle ref={sparkRef} className={styles["fx__spark"]} r={0.9} cx={12} cy={12} />
{BITS.map((b, i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles["fx__bit"]}
r={b.r}
cx={b.x}
cy={b.y}
style={{ "--dx": `${b.dx}px`, "--dy": `${b.dy}px` } as CSSProperties}
/>
))}
</svg>
</span>
</button>
);
}06 · Menu icon morph — Measured area 0.111% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where the real name lives
<title> again carries the real name here, updated to "Open menu" or "Close menu" and read through the button's aria-labelledby.
.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; }
@media (prefers-reduced-motion: reduce) {
.ic { transition: none; }
}
07Heart pulse
Two hearts, one overshoot
An outlined heart and a filled one overlap, and a tap reveals the filled version while both briefly overshoot to 1.3x scale before settling back to 1, all timed off the same click.
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-pressed="false" aria-label="Like">
<span class="iconbtn__stack">
<svg class="ic ic--outline" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M12 20.2s-7.3-4.5-9.5-8.9C.9 7.9 2.6 4.6 5.9 4.1c2-.3 3.8.6 4.9 2.2 1-1.6 2.9-2.5 4.9-2.2 3.3.5 5 3.8 3.4 7.2-2.2 4.4-9.1 8.9-9.1 8.9z" fill="none" stroke="currentColor" stroke-width="1.7"/>
</svg>
<svg class="ic ic--fill" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M12 20.2s-7.3-4.5-9.5-8.9C.9 7.9 2.6 4.6 5.9 4.1c2-.3 3.8.6 4.9 2.2 1-1.6 2.9-2.5 4.9-2.2 3.3.5 5 3.8 3.4 7.2-2.2 4.4-9.1 8.9-9.1 8.9z"/>
</svg>
<svg class="fx" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle class="fx__spark" r=".9" cx="12" cy="12"/>
<circle class="fx__bit" r=".5" cx="12" cy="6" style="--dx:0px;--dy:-2.6px"/>
<circle class="fx__bit" r=".5" cx="12" cy="6" style="--dx:2.4px;--dy:-1.2px"/>
<circle class="fx__bit" r=".5" cx="12" cy="6" style="--dx:-2.4px;--dy:-1.2px"/>
<circle class="fx__bit" r=".45" cx="4" cy="8" style="--dx:-2.6px;--dy:-1px"/>
<circle class="fx__bit" r=".45" cx="20" cy="8" style="--dx:2.6px;--dy:-1px"/>
<circle class="fx__bit" r=".45" cx="12" cy="20" style="--dx:0px;--dy:2.6px"/>
</svg>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick; // 220ms — 튕기는 시간
$easing: $ease-pop;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.iconbtn {
border: 0; background: none; cursor: pointer; color: $color;
border-radius: $r-sm;
width: clamp(52px, 14vw, 64px); height: clamp(52px, 14vw, 64px);
display: grid; place-items: center;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
.iconbtn__stack { display: grid; place-items: center; width: 100%; height: 100%; }
.ic {
grid-area: 1 / 1;
transition: opacity $duration $easing, transform $duration $easing;
transform-box: fill-box; transform-origin: 50% 50%;
}
.ic--outline { opacity: 1; }
.ic--fill { fill: $color; opacity: 0; }
.iconbtn[aria-pressed="true"] .ic--outline { opacity: 0; }
.iconbtn[aria-pressed="true"] .ic--fill { opacity: 1; transform: scale(1); }
.iconbtn.is-demo .ic--outline { animation: outline-loop $dur-loop $easing infinite; }
.iconbtn.is-demo .ic--fill { animation: fill-loop $dur-loop $easing infinite; }
@keyframes outline-loop { 0%, 15% { opacity: 1; transform: scale(1); } 30% { transform: scale(1.3); } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; transform: scale(1); } }
@keyframes fill-loop { 0%, 15% { opacity: 0; transform: scale(1); } 30% { transform: scale(1.3); } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; transform: scale(1); } }
// ── 한 겹: 채워지는 순간 가장자리 입자 + 중앙 스파크 ─────────
// 아이콘과 같은 칸에 겹쳐 놓은 효과 레이어 — 레이아웃에는 영향이 없다
.fx {
grid-area: 1 / 1;
fill: currentColor;
pointer-events: none;
}
// 중앙 스파크 — 하트가 차오르는 순간 밝게 점멸
.fx__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px $color); // 미광
}
@keyframes spark-pop {
0% { opacity: .95; transform: scale(1.8); }
60% { opacity: .4; transform: scale(.8); }
100% { opacity: 0; transform: scale(.3); }
}
// 하트 가장자리에서 바깥으로 흩어지는 입자 — JS 가 좌표·방향을 계산해 넣는다
.fx__bit {
opacity: 0;
transform: translate(0, 0);
}
@keyframes bit-fly {
0% { opacity: .9; transform: translate(0, 0) scale(1); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.3); }
}
@media (prefers-reduced-motion: reduce) {
.iconbtn.is-demo .ic--outline, .iconbtn.is-demo .ic--fill { animation: none; }
.ic { transition: none; }
.fx { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.iconbtn {
border: 0;
background: none;
cursor: pointer;
color: #fff7e6;
border-radius: 8px;
width: clamp(52px, 14vw, 64px);
height: clamp(52px, 14vw, 64px);
display: grid;
place-items: center;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.iconbtn__stack {
display: grid;
place-items: center;
width: 100%;
height: 100%;
}
.ic {
grid-area: 1/1;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
transform-box: fill-box;
transform-origin: 50% 50%;
}
.ic--outline {
opacity: 1;
}
.ic--fill {
fill: #fff7e6;
opacity: 0;
}
.iconbtn[aria-pressed=true] .ic--outline {
opacity: 0;
}
.iconbtn[aria-pressed=true] .ic--fill {
opacity: 1;
transform: scale(1);
}
.iconbtn.is-demo .ic--outline {
animation: outline-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.iconbtn.is-demo .ic--fill {
animation: fill-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes outline-loop {
0%, 15% {
opacity: 1;
transform: scale(1);
}
30% {
transform: scale(1.3);
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fill-loop {
0%, 15% {
opacity: 0;
transform: scale(1);
}
30% {
transform: scale(1.3);
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
transform: scale(1);
}
}
.fx {
grid-area: 1/1;
fill: currentColor;
pointer-events: none;
}
.fx__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px #fff7e6);
}
@keyframes spark-pop {
0% {
opacity: 0.95;
transform: scale(1.8);
}
60% {
opacity: 0.4;
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(0.3);
}
}
.fx__bit {
opacity: 0;
transform: translate(0, 0);
}
@keyframes bit-fly {
0% {
opacity: 0.9;
transform: translate(0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate(var(--dx), var(--dy)) scale(0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.iconbtn.is-demo .ic--outline, .iconbtn.is-demo .ic--fill {
animation: none;
}
.ic {
transition: none;
}
.fx {
display: none;
}
}document.querySelectorAll(".iconbtn").forEach(function (b) {
b.addEventListener("click", function () {
b.classList.remove("is-demo");
var on = b.getAttribute("aria-pressed") === "true";
b.setAttribute("aria-pressed", String(!on));
});
});
// ── 한 겹: 하트 채워지는 순간 가장자리 입자 폭발 + 중앙 스파크 ──────
// 펄스는 짧은 사건이다 — 01 처럼 펜 끝을 쫓는 대신, '채움 하트가 나타나는 프레임'을
// getComputedStyle opacity 실측으로 감지해 그 순간 하트 가장자리에서 입자를 터뜨린다.
// 입자 좌표는 fill 하트 path 를 getTotalLength/getPointAtLength 로 6 점 샘플링해 얻고,
// 방향은 도심(12,12)에서 바깥으로 — 하트가 '탁' 차오르는 물성을 흉내 낸다.
(function () {
var btn = document.querySelector('.iconbtn');
if (!btn) return;
var fx = btn.querySelector('.fx');
var fill = btn.querySelector('.ic--fill path');
if (!fx || !fill || !fill.getTotalLength) return;
var spark = fx.querySelector('.fx__spark');
var bits = [].slice.call(fx.querySelectorAll('.fx__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark) { fx.style.display = 'none'; return; }
var L = fill.getTotalLength();
var CX = 12, CY = 12;
// 입자 하나를 하트 가장자리 임의 점에 재배치하고 바깥 방향으로 흩어지게 한다
function seed(b) {
var p = fill.getPointAtLength(Math.random() * L);
b.setAttribute('cx', p.x);
b.setAttribute('cy', p.y);
var dx = p.x - CX, dy = p.y - CY;
var len = Math.sqrt(dx * dx + dy * dy) || 1;
var sp = .45 + Math.random() * .55;
b.style.setProperty('--dx', (dx / len * sp * 6.5).toFixed(2) + 'px');
b.style.setProperty('--dy', (dy / len * sp * 6.5).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
function burst() {
spark.style.animation = 'none';
void spark.getBoundingClientRect();
spark.style.animation = '';
spark.style.animationName = 'spark-pop';
bits.forEach(function (b) { seed(b); });
}
var prevO = null, lastBurst = 0;
function frame(t) {
// 채움 하트가 나타나는 프레임 = opacity 가 0→1 로 오르는 중.
// 데모 루프의 상승 구간은 500ms — 문턱을 그보다 길게 잡아 한 번만 터진다
var o = parseFloat(getComputedStyle(fill.closest('.ic--fill')).opacity);
if (prevO !== null && o - prevO > .02 && t - lastBurst > 600) {
lastBurst = t;
burst();
}
prevO = o;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HeartPulse.module.scss";
export type HeartPulseProps = {
/** 튕기는 시간, ms */
duration?: number;
easing?: string;
color?: string;
defaultLiked?: boolean;
onChange?: (liked: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HEART = "M12 20.2s-7.3-4.5-9.5-8.9C.9 7.9 2.6 4.6 5.9 4.1c2-.3 3.8.6 4.9 2.2 1-1.6 2.9-2.5 4.9-2.2 3.3.5 5 3.8 3.4 7.2-2.2 4.4-9.1 8.9-9.1 8.9z";
const BIT_COUNT = 6;
const CX = 12, CY = 12;
export default function HeartPulse({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
defaultLiked = false,
onChange,
}: HeartPulseProps) {
const [liked, setLiked] = useState(defaultLiked);
const pathRef = useRef<SVGPathElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
const burstRef = useRef<(() => void) | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 한 겹: 좋아요 순간 하트 가장자리 입자 폭발 + 중앙 스파크 (vanilla 데모와 동일 계산).
// 하트 path 를 getTotalLength/getPointAtLength 로 샘플링해 입자를 가장자리에 뿌리고
// 도심에서 바깥 방향으로 흩어지게 한다.
useEffect(() => {
const heart = pathRef.current, spark = sparkRef.current;
if (!heart || !spark || !heart.getTotalLength) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
const L = heart.getTotalLength();
const restart = (el: SVGElement) => {
el.style.animation = "none";
void el.getBoundingClientRect();
el.style.animation = "";
};
burstRef.current = () => {
restart(spark);
bits.forEach((b) => {
const p = heart.getPointAtLength(Math.random() * L);
b.setAttribute("cx", p.x.toFixed(2));
b.setAttribute("cy", p.y.toFixed(2));
const dx = p.x - CX, dy = p.y - CY;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const sp = 0.45 + Math.random() * 0.55;
b.style.setProperty("--dx", ((dx / len) * sp * 6.5).toFixed(2) + "px");
b.style.setProperty("--dy", ((dy / len) * sp * 6.5).toFixed(2) + "px");
restart(b);
});
};
return () => { burstRef.current = null; };
}, []);
return (
<button
type="button"
className={styles.iconbtn}
style={vars}
aria-pressed={liked}
aria-label="좋아요"
onClick={() => {
setLiked((v) => !v);
burstRef.current?.();
onChange?.(!liked);
}}
>
<span className={styles.iconbtn__stack}>
<svg className={`${styles.ic} ${liked ? styles["ic--hidden"] : ""}`} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d={HEART} fill="none" stroke="currentColor" strokeWidth={1.7} />
</svg>
<svg className={`${styles.ic} ${styles["ic--fill"]} ${liked ? "" : styles["ic--hidden"]}`} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
{/* pathRef — 입자 좌표 샘플링용 원본 경로. getTotalLength 는 경로 기하학만 보므로
스케일 애니메이션·투명도와 무관하게 항상 같은 하트 좌표를 돌려준다 */}
<path ref={pathRef} d={HEART} />
</svg>
<svg className={styles.fx} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle ref={sparkRef} className={styles["fx__spark"]} r={0.9} cx={12} cy={12} />
{Array.from({ length: BIT_COUNT }, (_, i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles["fx__bit"]}
r={i < 3 ? 0.5 : 0.45}
cx={12}
cy={6}
style={{ "--dx": "0px", "--dy": "-2.6px" } as CSSProperties}
/>
))}
</svg>
</span>
</button>
);
}07 · Heart pulse — Measured area 0.338% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
A label that never depends on state
aria-pressed toggles on the button alongside a fixed aria-label="Like", so the accessible name never depends on which heart happens to be visible.
.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); }
@keyframes fill-loop {
30% { transform: scale(1.3); }
40%, 60% { opacity: 1; }
}
08Arrow move
An 8px bob in place
A single downward arrow bobs up and down 8px in place on a slow two-second loop, nudging a "See more" label without ever changing shape.
<main class="stage">
<p class="cue">
See more
<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 · Arrow move — Measured area 0.108% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
No name of its own
It sits right next to that visible label, so the svg stays aria-hidden and carries no name of its own.
$duration: $dur-loop; // 2s
$easing: $ease-spring;
.cue__arrow { animation: bob $duration $easing infinite; }
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY($sp-2); } // 8px
}
@media (prefers-reduced-motion: reduce) {
.cue__arrow { animation: none; }
}
09Check draw
A polyline drawn left to right
A polyline checkmark, also carrying pathLength="100", draws left to right on stroke-dashoffset the same way item 01's star does, then unwinds after a short hold.
<main class="stage">
<p class="confirm">
<svg class="confirm__icon" viewBox="0 0 24 24" width="64" height="64" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity=".35"/>
<polyline class="confirm__trail" points="7 12.5 10.5 16 17 8.5" pathLength="100" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline class="confirm__check" points="7 12.5 10.5 16 17 8.5" pathLength="100" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle class="confirm__spark" r="1" cx="7" cy="12.5"/>
<circle class="confirm__bit" r=".6" cx="7" cy="12.5" style="--dx:-1.8px;--dy:1.4px"/>
<circle class="confirm__bit" r=".5" cx="7" cy="12.5" style="--dx:-1.2px;--dy:-1.8px"/>
<circle class="confirm__bit" r=".5" cx="7" cy="12.5" style="--dx:1.4px;--dy:-1.6px"/>
</svg>
Saved
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base; // 300ms — 한 바퀴(그리기→멈춤→지우기)
$easing: $ease-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.confirm {
margin: 0;
display: inline-flex; align-items: center; gap: clamp($sp-2, 3vw, $sp-3);
color: $color; font-weight: 600; font-size: clamp(14px, 4vw, 18px); line-height: 1; font-family: inherit;
}
.confirm__icon { color: $color; }
.confirm__check {
stroke-dasharray: 100; // pathLength="100"
animation: draw $duration $easing infinite;
}
@keyframes draw {
0% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
// ── 한 겹: 펜 끝 스파크 + 미광 잔상 ──────────────────────
.confirm__trail {
fill: none; stroke: currentColor; stroke-width: 3.2;
stroke-linecap: round; stroke-linejoin: round;
stroke-dasharray: 100;
animation: trail-fade $duration $easing infinite;
}
@keyframes trail-fade {
0% { stroke-dashoffset: 100; opacity: .45; }
55% { stroke-dashoffset: 0; opacity: .45; }
85% { stroke-dashoffset: 0; opacity: .45; }
100% { stroke-dashoffset: -100; opacity: 0; }
}
.confirm__spark {
fill: currentColor;
opacity: 0;
transform: scale(1.5); transform-origin: center; transform-box: fill-box;
filter: drop-shadow(0 0 1px currentColor);
}
.confirm__bit {
fill: currentColor;
opacity: 0;
animation: bit-fly 380ms $ease-out forwards;
}
@keyframes bit-fly {
0% { opacity: .9; transform: translate(0, 0) scale(1); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.3); }
}
// ── 접근성: 움직임 줄이기 설정이면 완성된 체크만 남긴다 ──────
@media (prefers-reduced-motion: reduce) {
.confirm__check { animation: none; stroke-dashoffset: 0; }
.confirm__trail { display: none; }
.confirm__spark { display: none; }
.confirm__bit { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.confirm {
margin: 0;
display: inline-flex;
align-items: center;
gap: clamp(8px, 3vw, 12px);
color: #fff7e6;
font-weight: 600;
font-size: clamp(14px, 4vw, 18px);
line-height: 1;
font-family: inherit;
}
.confirm__icon {
color: #fff7e6;
}
.confirm__check {
stroke-dasharray: 100;
animation: draw 300ms ease-out infinite;
}
@keyframes draw {
0% {
stroke-dashoffset: 100;
}
55% {
stroke-dashoffset: 0;
}
80% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -100;
}
}
.confirm__trail {
fill: none;
stroke: currentColor;
stroke-width: 3.2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
animation: trail-fade 300ms ease-out infinite;
}
@keyframes trail-fade {
0% {
stroke-dashoffset: 100;
opacity: 0.45;
}
55% {
stroke-dashoffset: 0;
opacity: 0.45;
}
85% {
stroke-dashoffset: 0;
opacity: 0.45;
}
100% {
stroke-dashoffset: -100;
opacity: 0;
}
}
.confirm__spark {
fill: currentColor;
opacity: 0;
transform: scale(1.5);
transform-origin: center;
transform-box: fill-box;
filter: drop-shadow(0 0 1px currentColor);
}
.confirm__bit {
fill: currentColor;
opacity: 0;
animation: bit-fly 380ms ease-out forwards;
}
@keyframes bit-fly {
0% {
opacity: 0.9;
transform: translate(0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate(var(--dx), var(--dy)) scale(0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.confirm__check {
animation: none;
stroke-dashoffset: 0;
}
.confirm__trail {
display: none;
}
.confirm__spark {
display: none;
}
.confirm__bit {
display: none;
}
}// 체크 드로잉 한 겹 — 그려지는 동안 펜 끝을 추적해 스파크를 옮기고 찌꺼기를 뿌린다.
// getTotalLength 실측 + getPointAtLength 계산. 지워지는 구간(offset<0)은 시작점 쪽 경계를 따른다.
(function () {
var svg = document.querySelector('.confirm__icon');
if (!svg) return;
var check = svg.querySelector('.confirm__check');
var spark = svg.querySelector('.confirm__spark');
var bits = [].slice.call(svg.querySelectorAll('.confirm__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark || !check.getTotalLength) { if (spark) spark.style.display = 'none'; return; }
var L = check.getTotalLength();
var prev = null, lastEmit = 0, emitIdx = 0;
function frame(t) {
var off = parseFloat(getComputedStyle(check).strokeDashoffset) || 0;
var at = off >= 0 ? 100 - off : -off;
var p = check.getPointAtLength(Math.max(0, Math.min(100, at)) / 100 * L);
if (prev && (Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y)) > .05) {
spark.setAttribute('cx', p.x);
spark.setAttribute('cy', p.y);
spark.style.opacity = 1;
if (t - lastEmit > 40) { // 체크는 짧아서 방출 간격도 짧게
lastEmit = t;
var ang = Math.atan2(p.y - prev.y, p.x - prev.x) + Math.PI;
var sp = .4 + Math.random() * .5;
var b = bits[emitIdx++ % bits.length];
b.setAttribute('cx', p.x);
b.setAttribute('cy', p.y);
b.style.setProperty('--dx', (Math.cos(ang) * sp * 4).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp * 4).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBBox;
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
prev = p;
} else {
if (prev) spark.style.opacity = 0;
if (!prev) prev = p;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CheckDraw.module.scss";
export type CheckDrawProps = {
label?: string;
/** 한 바퀴(그리기→멈춤→지우기) 시간, ms */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const BIT_DIRS: Array<[number, number]> = [
[-1.8, 1.4],
[-1.2, -1.8],
[1.4, -1.6],
];
export default function CheckDraw({
label = "저장 완료",
duration = 300,
easing = "ease-out",
color = "#fff7e6",
}: CheckDrawProps) {
const checkRef = useRef<SVGPolylineElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
// 한 겹: 펜 끝 추적 — getTotalLength 실측 + getPointAtLength 로 스파크를 옮기고
// 진행 반대 방향으로 찌꺼기를 뿌린다. 지워지는 구간(offset<0)은 시작점 쪽 경계를 따른다
// (vanilla 데모와 동일 계산)
useEffect(() => {
const check = checkRef.current, spark = sparkRef.current;
if (!check || !spark || !check.getTotalLength) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const L = check.getTotalLength();
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
let prev: DOMPoint | null = null, lastEmit = 0, emitIdx = 0, raf = 0;
const frame = (t: number) => {
const off = parseFloat(getComputedStyle(check).strokeDashoffset) || 0;
const at = off >= 0 ? 100 - off : -off;
const p = check.getPointAtLength(Math.max(0, Math.min(100, at)) / 100 * L);
if (prev && Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y) > 0.05) {
spark.setAttribute("cx", String(p.x));
spark.setAttribute("cy", String(p.y));
spark.style.opacity = "1";
if (t - lastEmit > 40 && bits.length) { // 체크는 짧아서 방출 간격도 짧게
lastEmit = t;
const ang = Math.atan2(p.y - prev.y, p.x - prev.x) + Math.PI;
const sp = 0.4 + Math.random() * 0.5;
const b = bits[emitIdx++ % bits.length];
b.setAttribute("cx", String(p.x));
b.setAttribute("cy", String(p.y));
b.style.setProperty("--dx", (Math.cos(ang) * sp * 4).toFixed(2) + "px");
b.style.setProperty("--dy", (Math.sin(ang) * sp * 4).toFixed(2) + "px");
b.style.animation = "none";
void b.getBoundingClientRect();
b.style.animation = "";
}
prev = p;
} else {
if (prev) spark.style.opacity = "0";
if (!prev) prev = p;
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<p className={styles.confirm} style={vars}>
<svg className={styles.confirm__icon} viewBox="0 0 24 24" width="64" height="64" aria-hidden="true">
<circle cx="12" cy={12} r={10} fill="none" stroke="currentColor" strokeWidth={2} opacity={0.35} />
<polyline
className={styles.confirm__trail}
points="7 12.5 10.5 16 17 8.5"
pathLength={100}
fill="none" stroke="currentColor" strokeWidth={3.2} strokeLinecap="round" strokeLinejoin="round"
/>
<polyline
ref={checkRef}
className={styles.confirm__check}
points="7 12.5 10.5 16 17 8.5"
pathLength={100}
fill="none" stroke="currentColor" strokeWidth={3.2} strokeLinecap="round" strokeLinejoin="round"
/>
<circle ref={sparkRef} className={styles.confirm__spark} r={1} cx={7} cy={12.5} />
{BIT_DIRS.map(([dx, dy], i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles.confirm__bit}
r={i === 0 ? 0.6 : 0.5}
cx={7}
cy={12.5}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
{label}
</p>
);
}09 · Check draw — Measured area 0.34% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Confirming what text already said
It confirms something already announced in visible text — "Saved" — so it too stays purely aria-hidden.
.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; }
}
@media (prefers-reduced-motion: reduce) {
.confirm__check { animation: none; stroke-dashoffset: 0; }
}
Where does the rotation axis break?
The pivot is the viewport, not the shape
The trap that shows up most often here is the rotation axis. Rotating a <g> directly spins it around the SVG viewport's own (0,0)-(24,24) corner rather than the shape's own center, which is exactly what threw off early passes of item 05's refresh arrows and item 04's bell. Both swung off to one side instead of turning in place. Adding transform-box: fill-box fixes it by making the shape's own bounding box the pivot instead of the viewport. Support reaches back far enough to ignore as a concern: Chrome 64, Edge 79, Firefox 55, and Safari 11 (including iOS) all ship it, per caniuse's transform-box table. Two other items needed a second pass after their first cut measured too weak on screen — item 04's shake originally covered just 15% of its loop and barely registered, and item 09's checkmark started at 26px, small enough that almost no pixels changed at all. Both got widened and enlarged until the motion actually showed up in frame-by-frame capture. Those corrected values are exactly what ships in the archive, unlocked with datzbfgj typed exactly as it reads on this line, no spaces added anywhere.
Why no text glyphs
A second trap worth naming: none of these nine are drawn as text glyphs like ✓ or →. A character like that renders however the visitor's operating system font happens to draw it, which sometimes means it doesn't render at all. Every icon above is an SVG path instead, so its shape never depends on which font is installed.
Stroke widths here were picked assuming the icon sits on a flat card. Drop the same path onto a surface puffed out with paired inner and outer shadows and the contrast reads differently, which is the kind of ground the nine claymorphism landing sections are built to produce.
Accessibility
Naming icon-only buttons
Five of the nine — 03, 04, 05, 06, and 07 — are icon-only buttons, so without a spoken name a screen reader only announces "button" and gives no hint what it does. 03 and 06 update an <title> element inside the SVG and point the button at it with aria-labelledby, swapping that title text the moment the state flips:
<button aria-labelledby="t3">
<svg><title id="t3">Play</title><path d="M8 5l11 7-11 7z"/></svg>
</button>
Decorative icons stay hidden
04, 05, and 07 skip the title and label the button directly with a fixed aria-label instead — 07 also toggles the boolean aria-pressed attribute, but the label text itself never changes. The other four — 01, 02, 08, and 09 — sit next to visible text that already carries the meaning, so their SVG only needs aria-hidden="true":
<svg aria-hidden="true"><path class="badge__star" d="M12 2l2.9 6.9L22 9.3z"/></svg> New feature
Reduced motion keeps the end state
Under prefers-reduced-motion: reduce, all nine turn their animation off and land on the finished shape — a fully drawn check, a filled heart — rather than erasing the end state along with the motion. Dropping both would erase information such as whether a save actually went through. Browse the rest of this line of demos from the CSS category hub, or see what this site does and doesn't sell on the about page.
FAQ
Can a css icon pulse animation run as a webfont instead of inline SVG?
They can, but a webfont glyph can flash unstyled or fail to render at all before its file finishes loading. Inline SVG never waits on a file request, so every icon in the archive shows up the instant the page — or the opened zip — paints.
What actually breaks without transform-box: fill-box?
rotate()'s pivot falls back to the SVG viewport's top-left corner, so a shape sitting off-center in its 24x24 viewBox swings around a point nowhere near itself. Item 05 is the clearest case: strip out fill-box and the whole refresh arrow appears to leap sideways as it spins instead of turning in place. If the only shape you need is the one from item 08, nine variants of it are gathered in arrow icon CSS animations.
How do the five icon buttons track their state in React?
The archive's react/ folder keeps one useState boolean per button — playing, open, or liked — and swaps opacity and the aria attributes through a className branch instead of toggling a class name directly.









