08화살표 이동
아래쪽을 가리키는 화살표 아이콘이 제자리에서 위아래로 부드럽게 오간다.
쓰임다음 섹션으로 더 보라는 스크롤 유도 화살표
translateY 8px2s 루프ease-spring
<main class="stage">
<p class="cue">
더 보기
<span class="cue__wrap">
<svg class="cue__arrow" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg class="cue__fx" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path class="cue__trail" d="M6 9l6 6 6-6" pathLength="100" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle class="cue__spark" r="1" cx="18" cy="9"/>
<circle class="cue__bit" r=".5" cx="18" cy="9" style="--dx:2.4px;--dy:-1.4px"/>
<circle class="cue__bit" r=".45" cx="18" cy="9" style="--dx:1.8px;--dy:.8px"/>
<circle class="cue__bit" r=".45" cx="18" cy="9" style="--dx:2.6px;--dy:-.4px"/>
<circle class="cue__bit" r=".45" cx="18" cy="9" style="--dx:1.2px;--dy:1.8px"/>
</svg>
</span>
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-loop; // 2s — 한 번 오가는 시간
$easing: $ease-spring;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.cue {
margin: 0;
display: inline-flex; align-items: center; gap: clamp($sp-2, 2vw, $sp-3);
color: $color; font-weight: 600; font-size: clamp(14px, 4vw, 18px); line-height: 1; font-family: inherit;
}
// 화살표와 효과 레이어를 같은 칸에 겹쳐 놓는다 — 레이아웃은 그대로
.cue__wrap { position: relative; display: grid; }
.cue__arrow { grid-area: 1 / 1; color: $color; animation: bob $duration $easing infinite; }
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY($sp-2); } // 8px
}
// ── 한 겹: 화살표 끝 스파크 + 미광 잔상 ──────────────────────
// fx 레이어는 화살표와 같은 칸 — SVG 좌표계도 같아 스파크를 경로 위에 얹을 수 있다
.cue__fx {
grid-area: 1 / 1;
color: $color;
pointer-events: none;
overflow: visible; // 스파크·찌꺼기가 뷰밖으로 나가도 잘리지 않게
}
// 미광 잔상 — 화살표가 내려간 자리가 잠깐 빛났다 올라오며 지워진다 (JS 가 dashoffset 구동)
.cue__trail {
stroke: currentColor;
stroke-dasharray: 100; // pathLength="100"
stroke-dashoffset: 100;
opacity: 0;
}
// 화살표 끝점을 따라다니는 스파크 — JS 가 getComputedStyle transform 실측으로 옮긴다
.cue__spark {
fill: currentColor;
opacity: 0;
transform: scale(1.6); transform-origin: center; transform-box: fill-box;
filter: drop-shadow(0 0 1px currentColor); // 미광 — 끝점이 살짝 발광한다
}
// 화살표가 지나간 뒤 진행 반대 방향으로 튀는 찌꺼기
.cue__bit {
fill: currentColor;
opacity: 0;
transform: translate(0, 0);
animation: bit-fly 420ms $ease-out forwards;
}
@keyframes bit-fly {
0% { opacity: .9; transform: translate(0, 0) scale(1); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.3); }
}
// ── 접근성: 움직임 줄이기 설정이면 제자리 화살표만 남긴다 ──────
@media (prefers-reduced-motion: reduce) {
.cue__arrow { animation: none; }
.cue__fx { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cue {
margin: 0;
display: inline-flex;
align-items: center;
gap: clamp(8px, 2vw, 12px);
color: #17141a;
font-weight: 600;
font-size: clamp(14px, 4vw, 18px);
line-height: 1;
font-family: inherit;
}
.cue__wrap {
position: relative;
display: grid;
}
.cue__arrow {
grid-area: 1/1;
color: #17141a;
animation: bob 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(8px);
}
}
.cue__fx {
grid-area: 1/1;
color: #17141a;
pointer-events: none;
overflow: visible;
}
.cue__trail {
stroke: currentColor;
stroke-dasharray: 100;
stroke-dashoffset: 100;
opacity: 0;
}
.cue__spark {
fill: currentColor;
opacity: 0;
transform: scale(1.6);
transform-origin: center;
transform-box: fill-box;
filter: drop-shadow(0 0 1px currentColor);
}
.cue__bit {
fill: currentColor;
opacity: 0;
transform: translate(0, 0);
animation: bit-fly 420ms ease-out forwards;
}
@keyframes bit-fly {
0% {
opacity: 0.9;
transform: translate(0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate(var(--dx), var(--dy)) scale(0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.cue__arrow {
animation: none;
}
.cue__fx {
display: none;
}
}// 화살표 이동 한 겹 — bob 애니메이션의 현재 위치를 getComputedStyle transform 실측으로
// 읽어(01 과 같은 원리: 타이밍 하드코딩 없음) 화살표 끝점을 따라다니는 스파크 + 진행 반대
// 방향으로 튀는 찌꺼기 + 이동이 끝난 뒤 잠깐 남는 미광 잔상.
(function () {
var arrow = document.querySelector('.cue__arrow');
var fx = document.querySelector('.cue__fx');
if (!arrow || !fx) return;
var spark = fx.querySelector('.cue__spark');
var trail = fx.querySelector('.cue__trail');
var bits = [].slice.call(fx.querySelectorAll('.cue__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark) { fx.style.display = 'none'; return; }
// 화살표 경로 — 꼭짓점(12,15)이 아니라 '펜 끝'(18,9)을 추적한다
var END = { x: 18, y: 9 };
// bob 과 같은 리듬으로 잔상이 차오랐다 비운다 — 그려진 길이를 transform 위치에서 역산
var prev = null, lastEmit = 0, emitIdx = 0;
function arrowY() {
// translateY 실측(px) → 0..1 진행도. bob 은 8px ($sp-2) 오간다
var m = /matrix\(([^)]+)\)/.exec(getComputedStyle(arrow).transform);
if (!m) return 0;
var dy = parseFloat(m[1].split(', ')[5]) || 0;
return Math.max(0, Math.min(1, dy / 8));
}
function frame(t) {
var prog = arrowY();
// 잔상 — 아래로 이동 중(그리기) 차오르고 올라가는 동안 비운다. 진행도 그대로 dashoffset
if (prev !== null) {
var dirDown = prog >= prev;
var off = dirDown ? 100 - prog * 100 : -(prog * 100);
trail.style.strokeDashoffset = off.toFixed(2);
trail.style.opacity = dirDown ? .45 : (1 - prog) * .45;
}
// 스파크 — 화살표 끝점이 이동 중일 때만 켠다
var p = { x: END.x, y: END.y + prog * 8 };
if (prev !== null && Math.abs(prog - prev) > .004) {
spark.setAttribute('cx', p.x);
spark.setAttribute('cy', p.y);
spark.style.opacity = 1;
if (t - lastEmit > 60) {
lastEmit = t;
// 진행 반대(이동 방향 뒤쪽)로 찌꺼기 — 수직 이동이라 각도는 위/아래로 갈린다
var ang = (prog > prev ? Math.PI / 2 : -Math.PI / 2) + (Math.random() - .5) * 1.2 + Math.PI;
var sp = .4 + Math.random() * .5;
var b = bits[emitIdx++ % bits.length];
b.setAttribute('cx', p.x);
b.setAttribute('cy', p.y);
b.style.setProperty('--dx', (Math.cos(ang) * sp * 5).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp * 5).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
prev = prog;
} else {
if (prev !== null && Math.abs(prog - prev) < .0005) spark.style.opacity = 0;
if (prev === null) prev = prog;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ArrowCue.module.scss";
export type ArrowCueProps = {
label?: string;
/** 한 번 오가는 시간, ms */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const END = { x: 18, y: 9 }; // 화살표 '펜 끝' — 꼭짓점이 아니라 끝점을 추적한다
const BOB_PX = 8; // bob translateY 최대치 (vanilla $sp-2 와 동일)
const BIT_DIRS: Array<[number, number]> = [
[2.4, -1.4],
[1.8, 0.8],
[2.6, -0.4],
[1.2, 1.8],
];
export default function ArrowCue({
label = "더 보기",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: ArrowCueProps) {
const arrowRef = useRef<SVGSVGElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const trailRef = useRef<SVGPathElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
// 한 겹: bob 현재 위치를 getComputedStyle transform 실측으로 읽어(타이밍 하드코딩 없음)
// 화살표 끝점 스파크 + 진행 반대 방향 찌꺼기 + 미광 잔상 (vanilla 데모와 동일 계산)
useEffect(() => {
const arrow = arrowRef.current, spark = sparkRef.current, trail = trailRef.current;
if (!arrow || !spark || !trail) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
let prev: number | null = null, lastEmit = 0, emitIdx = 0, raf = 0;
const arrowY = () => {
// translateY 실측(px) → 0..1 진행도. bob 은 8px 오간다
const m = /matrix\(([^)]+)\)/.exec(getComputedStyle(arrow).transform);
if (!m) return 0;
const dy = parseFloat(m[1].split(", ")[5]) || 0;
return Math.max(0, Math.min(1, dy / BOB_PX));
};
const frame = (t: number) => {
const prog = arrowY();
// 잔상 — 아래로 이동 중(그리기) 차오르고 올라가는 동안 비운다. 진행도 그대로 dashoffset
if (prev !== null) {
const dirDown = prog >= prev;
const off = dirDown ? 100 - prog * 100 : -(prog * 100);
trail.style.strokeDashoffset = off.toFixed(2);
trail.style.opacity = dirDown ? "0.45" : String((1 - prog) * 0.45);
}
// 스파크 — 화살표 끝점이 이동 중일 때만 켠다
if (prev !== null && Math.abs(prog - prev) > 0.004) {
spark.setAttribute("cx", String(END.x));
spark.setAttribute("cy", String(END.y + prog * BOB_PX));
spark.style.opacity = "1";
if (t - lastEmit > 60 && bits.length) {
lastEmit = t;
// 진행 반대(이동 방향 뒤쪽)로 찌꺼기 — 수직 이동이라 각도는 위/아래로 갈린다
const ang =
(prog > prev ? Math.PI / 2 : -Math.PI / 2) + (Math.random() - 0.5) * 1.2 + Math.PI;
const sp = 0.4 + Math.random() * 0.5;
const b = bits[emitIdx++ % bits.length];
b.setAttribute("cx", String(END.x));
b.setAttribute("cy", String(END.y + prog * BOB_PX));
b.style.setProperty("--dx", (Math.cos(ang) * sp * 5).toFixed(2) + "px");
b.style.setProperty("--dy", (Math.sin(ang) * sp * 5).toFixed(2) + "px");
b.style.animation = "none";
void b.getBoundingClientRect();
b.style.animation = "";
}
prev = prog;
} else {
if (prev !== null && Math.abs(prog - prev) < 0.0005) spark.style.opacity = "0";
if (prev === null) prev = prog;
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<p className={styles.cue} style={vars}>
{label}
<span className={styles.cue__wrap}>
<svg ref={arrowRef} className={styles.cue__arrow} viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
<svg className={styles.cue__fx} viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
ref={trailRef}
className={styles.cue__trail}
d="M6 9l6 6 6-6"
pathLength={100}
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle ref={sparkRef} className={styles.cue__spark} r={1} cx={18} cy={9} />
{BIT_DIRS.map(([dx, dy], i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles.cue__bit}
r={i === 0 ? 0.5 : 0.45}
cx={18}
cy={9}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
</span>
</p>
);
}08 화살표 이동 — 측정 누적면적 0.108% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 아이콘 애니메이션 9종 — SVG 로고도 한 줄 복붙으로


