Variable Font & SVG: 브라우저 내장 기술의 가능성
시리즈의 마지막 편이다. 01편(CSS)에서 시작해 02편(Motion), 03편(GSAP), 04편(Canvas), 05편(WebGL)까지 — 모두 라이브러리나 별도 API를 도입해야 했다. 이번 편은 다르다. 브라우저가 이미 갖고 있는 두 가지 기능, Variable Font과 SVG만으로 인상적인 텍스트 애니메이션을 만든다.
Variable Font는 하나의 폰트 파일에 무한한 변형을 담는다. font-variation-settings 한 줄이면 weight, width, italic을 실시간으로 바꿀 수 있다. SVG의 <textPath>는 벡터 경로 위에 텍스트를 배치하고, startOffset을 움직이면 곡선을 따라 텍스트가 끝없이 흘러가는 효과를 만든다.
라이브러리 없이도 이 정도 표현이 가능하다는 것, 그리고 언제 이 기술을 선택해야 하는지를 이번 편에서 정리한다.
Variable Font — TextPressure
💡 Variable Font이란?
기존 폰트는 weight별로 별도 파일이 필요하다 — Regular(400), Bold(700), ExtraBold(800)을 쓰려면 3개 파일을 로딩해야 한다. Variable Font는 하나의 파일에 연속적인 축(axis) 을 담아서, 원하는 값을 실수 단위로 지정할 수 있다.
| 축 태그 | 이름 | 범위 예시 | 설명 |
|---|---|---|---|
wght | Weight | 100~900 | 글자 굵기 (Thin ~ Black) |
wdth | Width | 5~200 | 글자 너비 (Ultra-Condensed ~ Ultra-Expanded) |
ital | Italic | 0~1 | 이탤릭 정도 (정수가 아닌 연속값) |
slnt | Slant | -90~90 | 기울기 각도 |
opsz | Optical Size | 6~144 | 본문/제목용 자동 최적화 |
4개의 등록 축(wght, wdth, ital, slnt) 외에 폰트 디자이너가 커스텀 축을 추가할 수도 있다. TextPressure가 사용하는 Compressa VF는 wght, wdth, ital 세 축을 모두 지원한다.
💡 font-variation-settings 문법
CSS에서 Variable Font 축을 제어하는 속성이다:
/* 4글자 태그 + 숫자 값, 쉼표로 구분 */
font-variation-settings: 'wght' 650, 'wdth' 120, 'ital' 0.5;
JavaScript에서 동적으로 설정할 때는 element.style.fontVariationSettings를 직접 조작한다:
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${ital}`;
이 속성은 매 프레임 변경해도 reflow가 발생하지 않는다 — 글자의 시각적 형태만 바뀌고 레이아웃은 유지되기 때문에, rAF 루프에서 안전하게 사용할 수 있다.
💡 @font-face로 Variable Font 로딩
TextPressure는 Compressa VF를 CDN에서 로딩한다:
@font-face {
font-family: 'Compressa VF';
src: url('https://res.cloudinary.com/.../CompressaPRO-GX.woff2');
font-style: normal;
}
Variable Font 파일은 보통 .woff2 형식이고, 여러 축을 포함해도 단일 파일이므로 개별 weight 파일을 여러 개 로딩하는 것보다 전체 용량이 작은 경우가 많다.
참고: 02편의 VariableProximity와의 관계
02편(Framer Motion)에서 다룬 VariableProximity도 같은 원리(font-variation-settings)를 사용한다. 차이점은 구현 방식이다 — VariableProximity는 Motion의 useAnimationFrame으로 프레임 루프를 돌리고, TextPressure는 네이티브 requestAnimationFrame을 직접 사용한다. 핵심 수학(마우스 거리 → 축 값 매핑)은 동일하다.
TextPressure 데모
마우스를 글자 위로 움직여보자 — 가까운 글자일수록 굵고 넓어지고 기울어진다.
Pressure
1단계: 글자별 span 분리 + ref 배열
const chars = text.split('');
const spansRef = useRef<(HTMLSpanElement | null)[]>([]);
// 렌더링: 각 글자를 독립 span으로 분리
{chars.map((char, i) => (
<span
key={i}
ref={el => { spansRef.current[i] = el; }}
data-char={char} // stroke 모드에서 ::after 콘텐츠용
style={{ display: 'inline-block' }}
>
{char}
</span>
))}
text.split('')으로 글자를 분리하고, 각 span의 DOM 참조를 spansRef 배열에 저장한다. rAF 루프에서 이 ref를 통해 React state를 거치지 않고 직접 스타일을 변경한다.
2단계: 마우스 좌표 추적 + lerp 보간
const mouseRef = useRef({ x: 0, y: 0 }); // 보간된 위치 (부드러운 값)
const cursorRef = useRef({ x: 0, y: 0 }); // 실제 커서 위치 (즉시 업데이트)
// rAF 루프 내부
mouseRef.current.x += (cursorRef.current.x - mouseRef.current.x) / 15;
mouseRef.current.y += (cursorRef.current.y - mouseRef.current.y) / 15;
두 개의 ref를 분리한 이유는 lerp(선형 보간) 때문이다. cursorRef는 mousemove 이벤트에서 즉시 업데이트되고, mouseRef는 매 프레임 cursorRef를 향해 1/15씩 접근한다. 커서가 순간이동해도 mouseRef는 부드럽게 따라가므로, font-variation 값이 급변하지 않는다.
/ 15는 "15프레임에 걸쳐 목표에 도달"이 아니다. 지수적 감쇠(exponential decay)라서 매 프레임 남은 거리의 1/15을 이동한다 — 처음에 빠르고 점차 느려지는 ease-out 커브다.
3단계: 거리 → font-variation-settings 매핑
const dist = (a, b) => Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
const getAttr = (distance, maxDist, minVal, maxVal) => {
const val = maxVal - Math.abs((maxVal * distance) / maxDist);
return Math.max(minVal, val + minVal);
};
getAttr은 거리를 축 값으로 변환하는 선형 보간 함수다:
| 거리 | distance / maxDist | val (maxVal=900) | 반환값 (minVal=100) |
|---|---|---|---|
| 0 (바로 위) | 0 | 900 | 1000 (=900+100) |
| maxDist/4 | 0.25 | 675 | 775 |
| maxDist/2 | 0.5 | 450 | 550 |
| maxDist | 1.0 | 0 | 100 (=minVal) |
maxDist는 titleRect.width / 2로 설정된다. 텍스트 전체 너비의 절반보다 멀면 최솟값으로 고정된다.
각 축의 매핑:
마우스 거리 → wght: getAttr(d, maxDist, 100, 900) → 100~1000
마우스 거리 → wdth: getAttr(d, maxDist, 5, 200) → 5~205
마우스 거리 → ital: getAttr(d, maxDist, 0, 1) → 0~1
4단계: rAF 루프에서 DOM 직접 업데이트
useEffect(() => {
let rafId: number;
const animate = () => {
// lerp 업데이트
mouseRef.current.x += (cursorRef.current.x - mouseRef.current.x) / 15;
spansRef.current.forEach(span => {
if (!span) return;
const rect = span.getBoundingClientRect();
const d = dist(mouseRef.current, { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
const wdth = width ? Math.floor(getAttr(d, maxDist, 5, 200)) : 100;
const wght = weight ? Math.floor(getAttr(d, maxDist, 100, 900)) : 400;
const italVal = italic ? getAttr(d, maxDist, 0, 1).toFixed(2) : '0';
const newSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal}`;
// 값이 바뀐 경우에만 DOM 업데이트
if (span.style.fontVariationSettings !== newSettings) {
span.style.fontVariationSettings = newSettings;
}
});
rafId = requestAnimationFrame(animate);
};
animate();
return () => cancelAnimationFrame(rafId);
}, [width, weight, italic, alpha]);
핵심 설계 결정: React state를 사용하지 않는다. 60fps에서 매 프레임 setState를 호출하면 글자 수 x 60 = 수백 번의 리렌더링이 발생한다. span.style.fontVariationSettings를 직접 조작하면 React 리렌더링 없이 DOM만 업데이트된다.
데이터 흐름을 정리하면:
mousemove → cursorRef 즉시 업데이트
↓
rAF 루프 → mouseRef lerp 보간 (1/15씩 접근)
↓
→ 각 span과의 거리 계산 (dist)
↓
→ 거리 → 축 값 변환 (getAttr)
↓
→ span.style.fontVariationSettings 직접 설정
5단계: 리사이즈 처리 + scale 옵션
const setSize = useCallback(() => {
const { width: containerW } = containerRef.current.getBoundingClientRect();
// 컨테이너 너비를 글자 수로 나눠 폰트 크기 결정
let newFontSize = containerW / (chars.length / 2);
newFontSize = Math.max(newFontSize, minFontSize);
setFontSize(newFontSize);
}, [chars.length, minFontSize, scale]);
useEffect(() => {
const debouncedSetSize = debounce(setSize, 100);
debouncedSetSize();
window.addEventListener('resize', debouncedSetSize);
return () => window.removeEventListener('resize', debouncedSetSize);
}, [setSize]);
debounce(setSize, 100)으로 리사이즈가 끝난 후 100ms 뒤에 한 번만 크기를 재계산한다. scale 옵션이 켜지면 텍스트 높이를 컨테이너에 맞춰 transform: scaleY(ratio)를 적용한다.
SVG textPath — CurvedLoop
💡 SVG path + textPath
SVG <text>는 기본적으로 직선 위에 텍스트를 배치한다. <textPath>를 사용하면 임의의 path 위에 텍스트를 배치할 수 있다:
<svg viewBox="0 0 1440 120">
<defs>
<path id="curve" d="M0,60 Q720,200 1440,60" fill="none" />
</defs>
<text>
<textPath href="#curve" startOffset="0px">
곡선 위의 텍스트
</textPath>
</text>
</svg>
href로 path를 참조하고, startOffset으로 텍스트의 시작 위치를 조절한다. startOffset을 음수로 밀면 텍스트가 path 시작점 너머로 사라지고, 양수로 밀면 오른쪽으로 이동한다.
💡 Quadratic Bezier 곡선
CurvedLoop의 path는 Quadratic Bezier 곡선이다:
M-100,40 Q500,440 1540,40
| 명령어 | 좌표 | 의미 |
|---|---|---|
M-100,40 | 시작점 | viewBox 왼쪽 밖에서 시작 (텍스트가 잘리지 않게) |
Q500,440 | 제어점 | 곡선의 꼭대기 — curveAmount=400이면 y=440 |
1540,40 | 끝점 | viewBox 오른쪽 밖에서 끝 |
제어점의 y값이 40 + curveAmount이므로, curveAmount가 클수록 곡선이 더 깊어진다. curveAmount=100이면 완만한 호, curveAmount=600이면 거의 U자 형태가 된다.
CurvedLoop 데모
텍스트가 곡선을 따라 흐른다 — 드래그하면 방향이 바뀐다.
1단계: SVG path 생성 + textPath 연결
const uid = useId();
const pathId = `curve-${uid}`;
const pathD = `M-100,40 Q500,${40 + curveAmount} 1540,40`;
// SVG 구조
<defs>
<path id={pathId} d={pathD} fill="none" stroke="transparent" />
</defs>
<text>
<textPath ref={textPathRef} href={`#${pathId}`} startOffset={offset + 'px'}>
{totalText}
</textPath>
</text>
useId()로 고유 ID를 생성한다 — SSR에서도 서버/클라이언트 간 ID가 일치하므로 hydration 불일치가 발생하지 않는다. <defs> 안에 path를 정의하고, <textPath>의 href로 참조한다.
2단계: 텍스트 길이 측정 + 반복 횟수 계산
// 숨겨진 <text>로 실제 렌더링 길이를 측정
useEffect(() => {
if (measureRef.current) setSpacing(measureRef.current.getComputedTextLength());
}, [text, className]);
// 경로를 빈틈 없이 채우기 위한 반복 횟수
const totalText = textLength
? Array(Math.ceil(1800 / textLength) + 2).fill(text).join('')
: text;
getComputedTextLength()는 SVG 전용 메서드로, 텍스트가 실제로 차지하는 px 길이를 반환한다. 경로의 viewBox 너비가 1440이므로, 좌우 여유를 포함해 1800px을 채우도록 텍스트를 반복한다.
예를 들어 텍스트 길이가 300px이면:
Math.ceil(1800 / 300) + 2 = 6 + 2 = 8번 반복
+ 2는 startOffset이 음수로 밀릴 때 빈 공간이 보이지 않도록 하는 여유분이다.
3단계: rAF 루프에서 startOffset 애니메이션
useEffect(() => {
if (!spacing || !ready) return;
let frame = 0;
const step = () => {
if (!dragRef.current && textPathRef.current) {
const delta = dirRef.current === 'right' ? speed : -speed;
const currentOffset = parseFloat(
textPathRef.current.getAttribute('startOffset') || '0'
);
let newOffset = currentOffset + delta;
// wrap 처리: 텍스트 한 단위 길이로 순환
const wrapPoint = spacing;
if (newOffset <= -wrapPoint) newOffset += wrapPoint;
if (newOffset > 0) newOffset -= wrapPoint;
textPathRef.current.setAttribute('startOffset', newOffset + 'px');
}
frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame);
}, [spacing, speed, ready]);
매 프레임 startOffset을 speed만큼 이동시킨다. wrap 처리가 핵심이다 — 오프셋이 -spacing 이하로 내려가면 +spacing을 더해서 순환시킨다. 텍스트가 충분히 반복되어 있으므로, wrap 순간에 시각적 끊김이 없다.
speed=2, direction='left'일 때:
프레임 0: offset = -300 (초기값)
프레임 1: offset = -302
프레임 2: offset = -304
...
offset <= -spacing → offset += spacing (순환)
4단계: 포인터 드래그 인터랙션
const onPointerDown = (e: PointerEvent) => {
dragRef.current = true;
lastXRef.current = e.clientX;
velRef.current = 0;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!dragRef.current || !textPathRef.current) return;
const dx = e.clientX - lastXRef.current;
lastXRef.current = e.clientX;
velRef.current = dx; // 속도 기록
// dx만큼 오프셋 이동
let newOffset = currentOffset + dx;
// ... wrap 처리
};
const endDrag = () => {
dragRef.current = false;
// 마지막 속도의 방향으로 자동 스크롤 방향 결정
dirRef.current = velRef.current > 0 ? 'right' : 'left';
};
setPointerCapture로 드래그 중 포인터가 요소 밖으로 나가도 이벤트를 계속 받는다. velRef는 마지막 dx(속도)를 기록하는데, 드래그를 놓으면 그 방향으로 자동 스크롤이 이어진다.
데이터 흐름:
pointerdown → dragRef = true, rAF 루프 일시정지
↓
pointermove → dx 계산 → startOffset 직접 이동 + velRef 기록
↓
pointerup → dragRef = false, velRef 방향으로 dirRef 설정
↓
→ rAF 루프 재개 (새 방향으로)