📖react-bits 파헤치기·6/6단계
100%

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) 을 담아서, 원하는 값을 실수 단위로 지정할 수 있다.

축 태그이름범위 예시설명
wghtWeight100~900글자 굵기 (Thin ~ Black)
wdthWidth5~200글자 너비 (Ultra-Condensed ~ Ultra-Expanded)
italItalic0~1이탤릭 정도 (정수가 아닌 연속값)
slntSlant-90~90기울기 각도
opszOptical Size6~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 / maxDistval (maxVal=900)반환값 (minVal=100)
0 (바로 위)09001000 (=900+100)
maxDist/40.25675775
maxDist/20.5450550
maxDist1.00100 (=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 데모

텍스트가 곡선을 따라 흐른다 — 드래그하면 방향이 바뀐다.

react-bits 파헤치기 
2
400

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 루프 재개 (새 방향으로)

  • Variable Font — TextPressure
  • 💡 Variable Font이란?
  • 💡 font-variation-settings 문법
  • 💡 @font-face로 Variable Font 로딩
  • TextPressure 데모
  • 1단계: 글자별 span 분리 + ref 배열
  • 2단계: 마우스 좌표 추적 + lerp 보간
  • 3단계: 거리 → font-variation-settings 매핑
  • 4단계: rAF 루프에서 DOM 직접 업데이트
  • 5단계: 리사이즈 처리 + scale 옵션
  • SVG textPath — CurvedLoop
  • 💡 SVG path + textPath
  • 💡 Quadratic Bezier 곡선
  • CurvedLoop 데모
  • 1단계: SVG path 생성 + textPath 연결
  • 2단계: 텍스트 길이 측정 + 반복 횟수 계산
  • 3단계: rAF 루프에서 startOffset 애니메이션
  • 4단계: 포인터 드래그 인터랙션
  • Variable Font — TextPressure
  • 💡 Variable Font이란?
  • 💡 font-variation-settings 문법
  • 💡 @font-face로 Variable Font 로딩
  • TextPressure 데모
  • 1단계: 글자별 span 분리 + ref 배열
  • 2단계: 마우스 좌표 추적 + lerp 보간
  • 3단계: 거리 → font-variation-settings 매핑
  • 4단계: rAF 루프에서 DOM 직접 업데이트
  • 5단계: 리사이즈 처리 + scale 옵션
  • SVG textPath — CurvedLoop
  • 💡 SVG path + textPath
  • 💡 Quadratic Bezier 곡선
  • CurvedLoop 데모
  • 1단계: SVG path 생성 + textPath 연결
  • 2단계: 텍스트 길이 측정 + 반복 횟수 계산
  • 3단계: rAF 루프에서 startOffset 애니메이션
  • 4단계: 포인터 드래그 인터랙션
📚

react-bits 파헤치기

  1. ✅1. CSS만으로 글리치 텍스트 만들기
  2. ✅2. Motion: React에서 선언적으로 애니메이션하기
  3. ✅3. GSAP + ScrollTrigger: 타임라인으로 정밀하게 제어하기
  4. ✅4. Canvas 2D: 텍스트를 픽셀로 다루기
  5. ✅5. WebGL & Shader: GPU로 텍스트 렌더링하기
  6. 🔍6. Variable Font & SVG: 브라우저 내장 기술의 가능성
←WebGL & Shader: GPU로 텍스트 렌더링하기

관련 포스트

이 포스트와 관련된 다른 글들을 확인해보세요

Variable Font & SVG: 브라우저 내장 기술의 가능성

font-variation-settings로 마우스 근접도에 반응하는 텍스트, SVG textPath로 곡선 위를 흐르는 텍스트를 구현한다.

2026년 2월 23일•10분
variable-fontsvgcss+2

CSS만으로 글리치 텍스트 만들기

JavaScript 없이 CSS keyframes, pseudo-elements, custom properties만으로 글리치 효과를 구현한다. CSS 애니메이션의 가능성과 한계를 탐구한다.

2026년 2월 16일•5분
cssanimationkeyframes+2

컬러 팔레트 & 타이포그래피 — 업종별 실전 레퍼런스 북

96개 업종별 컬러 팔레트와 57개 폰트 페어링 데이터를 기반으로, 컬러 이론부터 다크모드 변환, 한글 폰트 페어링까지 실전에서 바로 쓸 수 있는 디자인 레퍼런스.

2026년 4월 8일•5분
ui-uxcssdesign+3

Comments