02ロゴのリビール
自作の幾何学ロゴマークが線としてまず描かれ、その後塗りが opacity でじわりと現れる。
使いどころLPヒーローでのブランドロゴの登場
stroke → fill 2단계600ms라인만 남기기
<main class="stage">
<p class="mark">
<svg viewBox="0 0 24 24" width="56" height="56" aria-hidden="true">
<path class="mark__fill" d="M12 3L21 12L12 21L3 12Z"/>
<path class="mark__trail" pathLength="100" d="M12 3L21 12L12 21L3 12Z"/>
<path class="mark__line" pathLength="100" d="M12 3L21 12L12 21L3 12Z"/>
<circle class="mark__spark" r="1" cx="12" cy="3"/>
<circle class="mark__bit" r=".5" cx="12" cy="3" style="--dx:0px;--dy:-2.4px"/>
<circle class="mark__bit" r=".45" cx="12" cy="3" style="--dx:2px;--dy:0px"/>
<circle class="mark__bit" r=".45" cx="12" cy="3" style="--dx:-2px;--dy:0px"/>
</svg>
GODRICH
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter; // 600ms — 선이 그려지는 시간
$easing: $ease-out;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.mark {
margin: 0;
display: inline-flex; align-items: center; gap: clamp($sp-2, 3vw, $sp-3);
color: $color; font-weight: 800; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit; letter-spacing: .08em;
}
.mark svg { width: clamp(44px, 13vw, 56px); height: clamp(44px, 13vw, 56px); }
.mark__fill {
fill: $color; opacity: 0;
animation: fill-in $duration $easing infinite;
}
.mark__line {
fill: none; stroke: $color; stroke-width: 1.6;
stroke-dasharray: 100; // pathLength="100"
animation: line-draw $duration $easing infinite;
}
// 1단계: 선으로 그린다 → 2단계: 면을 채운다 → 다시 선만 남기고 반복
@keyframes line-draw {
0% { stroke-dashoffset: 100; opacity: 1; }
55% { stroke-dashoffset: 0; opacity: 1; }
70% { opacity: 1; }
85% { opacity: 0; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes fill-in {
0%, 55% { opacity: 0; }
75%, 85% { opacity: 1; }
100% { opacity: 0; }
}
// ── 한 겹: 펜 끝 스파크 + 미광 잔상 ──────────────────────
// 본선 뒤에 남는 잔상 — 지워질 때 본선보다 반박자 늦게 사라져 펜 자국이 빛난다
.mark__trail {
fill: none; stroke: $color; stroke-width: 1.6;
stroke-dasharray: 100;
animation: trail-fade $duration $easing infinite;
}
@keyframes trail-fade {
0% { stroke-dashoffset: 100; opacity: .4; }
55% { stroke-dashoffset: 0; opacity: .4; }
78% { stroke-dashoffset: 0; opacity: .4; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
// 펜 끝 스파크 — 그리기 단계에만 JS 가 좌표를 옮기고 켠다
.mark__spark {
fill: $color;
opacity: 0;
transform: scale(1.5); transform-origin: center; transform-box: fill-box;
filter: drop-shadow(0 0 1px $color);
}
// 펜이 지난 자리에서 진행 반대 방향으로 튀는 찌꺼기
.mark__bit {
fill: $color;
opacity: 0;
animation: bit-fly 420ms $ease-out forwards;
}
@keyframes bit-fly {
0% { opacity: .9; transform: translate(0, 0) scale(1); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.3); }
}
// ── 접근성: 움직임 줄이기 설정이면 완성된 마크만 남긴다 ──────
@media (prefers-reduced-motion: reduce) {
.mark__line { animation: none; stroke-dashoffset: 0; }
.mark__fill { animation: none; opacity: 1; }
.mark__trail { display: none; }
.mark__spark { display: none; }
.mark__bit { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mark {
margin: 0;
display: inline-flex;
align-items: center;
gap: clamp(8px, 3vw, 12px);
color: #17141a;
font-weight: 800;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.08em;
}
.mark svg {
width: clamp(44px, 13vw, 56px);
height: clamp(44px, 13vw, 56px);
}
.mark__fill {
fill: #17141a;
opacity: 0;
animation: fill-in 600ms ease-out infinite;
}
.mark__line {
fill: none;
stroke: #17141a;
stroke-width: 1.6;
stroke-dasharray: 100;
animation: line-draw 600ms ease-out infinite;
}
@keyframes line-draw {
0% {
stroke-dashoffset: 100;
opacity: 1;
}
55% {
stroke-dashoffset: 0;
opacity: 1;
}
70% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
stroke-dashoffset: 0;
opacity: 0;
}
}
@keyframes fill-in {
0%, 55% {
opacity: 0;
}
75%, 85% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.mark__trail {
fill: none;
stroke: #17141a;
stroke-width: 1.6;
stroke-dasharray: 100;
animation: trail-fade 600ms ease-out infinite;
}
@keyframes trail-fade {
0% {
stroke-dashoffset: 100;
opacity: 0.4;
}
55% {
stroke-dashoffset: 0;
opacity: 0.4;
}
78% {
stroke-dashoffset: 0;
opacity: 0.4;
}
100% {
stroke-dashoffset: 0;
opacity: 0;
}
}
.mark__spark {
fill: #17141a;
opacity: 0;
transform: scale(1.5);
transform-origin: center;
transform-box: fill-box;
filter: drop-shadow(0 0 1px #17141a);
}
.mark__bit {
fill: #17141a;
opacity: 0;
animation: bit-fly 420ms ease-out forwards;
}
@keyframes bit-fly {
0% {
opacity: 0.9;
transform: translate(0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate(var(--dx), var(--dy)) scale(0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.mark__line {
animation: none;
stroke-dashoffset: 0;
}
.mark__fill {
animation: none;
opacity: 1;
}
.mark__trail {
display: none;
}
.mark__spark {
display: none;
}
.mark__bit {
display: none;
}
}// 로고 리빌 한 겹 — 선 그리기 단계(0~55%)에서만 펜 끝을 추적해 스파크를 옮기고 찌꺼기를 뿌린다.
// 계산 방식은 01 과 같다: getTotalLength 로 실측한 경로 위를 getPointAtLength 로 훑는다.
(function () {
var svg = document.querySelector('.mark svg');
if (!svg) return;
var line = svg.querySelector('.mark__line');
var spark = svg.querySelector('.mark__spark');
var bits = [].slice.call(svg.querySelectorAll('.mark__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark || !line.getTotalLength) { if (spark) spark.style.display = 'none'; return; }
var L = line.getTotalLength();
var prev = null, lastEmit = 0, emitIdx = 0;
function frame(t) {
var off = parseFloat(getComputedStyle(line).strokeDashoffset) || 0;
var at = Math.max(0, Math.min(100, 100 - off));
var p = line.getPointAtLength(at / 100 * L);
if (prev && (Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y)) > .05) {
spark.setAttribute('cx', p.x);
spark.setAttribute('cy', p.y);
spark.style.opacity = 1;
if (t - lastEmit > 60) {
lastEmit = t;
var ang = Math.atan2(p.y - prev.y, p.x - prev.x) + Math.PI;
var sp = .4 + Math.random() * .5;
var b = bits[emitIdx++ % bits.length];
b.setAttribute('cx', p.x);
b.setAttribute('cy', p.y);
b.style.setProperty('--dx', (Math.cos(ang) * sp * 5).toFixed(2) + 'px');
b.style.setProperty('--dy', (Math.sin(ang) * sp * 5).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBBox;
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
prev = p;
} else {
if (prev) spark.style.opacity = 0;
if (!prev) prev = p;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./LogoReveal.module.scss";
export type LogoRevealProps = {
wordmark?: string;
/** 선이 그려지는 시간, ms */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const BIT_DIRS: Array<[number, number]> = [
[0, -2.4],
[2, 0],
[-2, 0],
];
export default function LogoReveal({
wordmark = "GODRICH",
duration = 600,
easing = "ease-out",
color = "#17141a",
}: LogoRevealProps) {
const lineRef = useRef<SVGPathElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
// 한 겹: 펜 끝 추적 — getTotalLength 실측 + getPointAtLength 로 스파크를 옮기고
// 이동 방향 뒤쪽으로 찌꺼기를 방출한다 (vanilla 데모와 동일 계산)
useEffect(() => {
const line = lineRef.current, spark = sparkRef.current;
if (!line || !spark || !line.getTotalLength) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const L = line.getTotalLength();
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
let prev: DOMPoint | null = null, lastEmit = 0, emitIdx = 0, raf = 0;
const frame = (t: number) => {
const off = parseFloat(getComputedStyle(line).strokeDashoffset) || 0;
const at = Math.max(0, Math.min(100, 100 - off));
const p = line.getPointAtLength(at / 100 * L);
if (prev && Math.abs(p.x - prev.x) + Math.abs(p.y - prev.y) > 0.05) {
spark.setAttribute("cx", String(p.x));
spark.setAttribute("cy", String(p.y));
spark.style.opacity = "1";
if (t - lastEmit > 60 && bits.length) {
lastEmit = t;
const ang = Math.atan2(p.y - prev.y, p.x - prev.x) + Math.PI;
const sp = 0.4 + Math.random() * 0.5;
const b = bits[emitIdx++ % bits.length];
b.setAttribute("cx", String(p.x));
b.setAttribute("cy", String(p.y));
b.style.setProperty("--dx", (Math.cos(ang) * sp * 5).toFixed(2) + "px");
b.style.setProperty("--dy", (Math.sin(ang) * sp * 5).toFixed(2) + "px");
b.style.animation = "none";
void b.getBoundingClientRect();
b.style.animation = "";
}
prev = p;
} else {
if (prev) spark.style.opacity = "0";
if (!prev) prev = p;
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<p className={styles.mark} style={vars}>
<svg viewBox="0 0 24 24" width="56" height="56" aria-hidden="true">
<path className={styles.mark__fill} d="M12 3L21 12L12 21L3 12Z" />
<path className={styles.mark__trail} pathLength={100} d="M12 3L21 12L12 21L3 12Z" />
<path ref={lineRef} className={styles.mark__line} pathLength={100} d="M12 3L21 12L12 21L3 12Z" />
<circle ref={sparkRef} className={styles.mark__spark} r={1} cx={12} cy={3} />
{BIT_DIRS.map(([dx, dy], i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles.mark__bit}
r={i === 0 ? 0.5 : 0.45}
cx={12}
cy={3}
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` } as CSSProperties}
/>
))}
</svg>
{wordmark}
</p>
);
}02 ロゴのリビール — 計測 累積面積 0.881% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · CSS アイコン アニメーション 9選 — 一行でコピペOK


