SLOHERO® LAB 03 — Cursor Warp Type

글자가 커서를 읽는다
variable axes, played live
드래그 · 커서 이동 ← → 키로도 조작 마우스 추적
240px
100%

커서를 따라 휘는 텍스트 — 가변폰트 축을 실시간으로 연주하기

2026.08.30 · 슬로히어로 랩 03 · 가변폰트 / DOM / requestAnimationFrame

먼저 결론

  • 커서와 글자 중심의 거리를 0~1 값으로 바꾸고, 그 하나의 값으로 굵기·자간·기울기 세 가지를 동시에 몬다.
  • Pretendard Variable에는 wght 축 하나뿐이다. 기울기(slnt)와 폭(wdth)은 없는 축이라 CSS transform으로 흉내 냈다.
  • 글자마다 자간과 굵기를 매 프레임 바꾸면 줄 전체가 다시 배치되면서 덜덜 떨린다. 글자를 절대 위치로 고정하고 transform만 움직여 해결했다.
  • Google Fonts에서 wght@400;500;600 으로 받으면 정적 3벌이 온다. 굵기가 계단처럼 튄다. [email protected] 으로 바꿔야 진짜 가변폰트가 온다.
  • 폴백 3종 — 가변폰트 미지원 / 포인터 없는 기기 / prefers-reduced-motion. 전부 모듈 밖 일반 스크립트에 뒀다.

무엇을 만들었나

위 무대에 큰 제목이 한 줄 있다. 커서를 가까이 가져가면 그 근처 글자들만 굵어지고, 서로 밀려나 자간이 벌어지고, 커서 쪽으로 기울어진다. 커서가 지나가면 원래대로 되돌아온다. 글자 한 자 한 자가 커서와의 거리를 읽고 자기 모양을 바꾸는 것이다.

흔히 보는 "마우스 오버하면 커진다"와 다른 점은 경계가 없다는 것이다. 어떤 글자에도 :hover가 걸리지 않는다. 각 글자는 커서와 자기 사이의 거리를 실수로 알고 있고, 그 거리가 연속적으로 변하니까 변형도 연속적이다. 그래서 커서를 천천히 움직이면 글자 줄 위로 물결이 지나간다.

기술 구성은 단순하다. WebGL도, 캔버스도, 외부 자바스크립트 라이브러리도 쓰지 않았다. DOM <span> 26개, requestAnimationFrame 루프 하나, 그리고 가변폰트. 페이지 전체가 HTML 파일 한 개다.

가변폰트는 굵기를 고르는 게 아니라, 굵기를 연주하는 것이다.

먼저 축부터 확인했다 — 있는 축과 없는 축

"자간·기울기·굵기를 가변폰트 축으로 조정한다"가 원래 계획이었다. 그런데 폰트를 열어보고 계획을 절반 접었다.

Pretendard VariableInstrument Sans
wght굵기있음 (45–920)있음 (400–700)
slnt기울기없음없음
wdth없음없음
자간축이 아님. CSS letter-spacing 영역

즉 진짜 가변폰트 축으로 다룰 수 있는 건 굵기 하나뿐이었다. 자간은 원래부터 폰트 축이 아니라 CSS 속성이고, 기울기는 이 두 폰트에 축 자체가 없다. 그래서 이렇게 나눴다 — 굵기는 font-variation-settings, 기울기는 skewX(), 자간은 글자 위치를 직접 밀어내는 translateX(). 셋 다 같은 하나의 값 f가 몰고 간다.

거리를 변형량으로 바꾸는 공식

매 프레임 글자마다 커서까지의 거리를 재고, 반경 안이면 0~1 사이 값으로 바꾼다. 선형으로 두면 반경 경계에서 딱 끊기는 느낌이 나서 smoothstep을 한 번 먹였다.

var dx = c.cx - mx, dy = c.cy - my;
var d  = Math.sqrt(dx*dx + dy*dy);
var f  = Math.max(0, 1 - d / radius);   // 반경 밖이면 0
f = f * f * (3 - 2 * f);                // smoothstep — 경계를 부드럽게

// 스프링 대신 감쇠 보간. 손을 떼도 관성처럼 따라온다
c.cur += (f * power - c.cur) * 0.18;

0.18이라는 숫자는 눈으로 맞춘 값이다. 0.35면 커서에 딱 붙어서 기계적이고, 0.08이면 늘어져서 반응이 늦다. 0.18이 "글자가 커서를 좇는다"는 느낌의 지점이었다.

그 다음은 cur 하나를 세 갈래로 나눠 쓴다.

var w  = 200 + c.cur * 700;             // wght 200 → 900
var sk = -c.cur * 13 * Math.sign(dx);   // 커서 쪽으로 기울기
var ty = -c.cur * 12;                   // 살짝 떠오르기
var sy = 1 + c.cur * 0.14;              // 세로로 늘어나기

c.el.style.fontVariationSettings = '"wght" ' + w.toFixed(0);
c.el.style.transform =
  'translate3d(' + shift + 'px,' + ty + 'px,0) ' +
  'skewX(' + sk + 'deg) scaleY(' + sy + ')';

막힌 지점 1 — 자간을 바꾸면 줄 전체가 흔들렸다

처음엔 정직하게 글자마다 letter-spacing을 줬다. 결과는 최악이었다. 세 번째 글자의 자간이 바뀌면 그 뒤 스무 글자가 전부 밀린다. 줄이 가운데 정렬이니 앞쪽 글자들도 반대로 밀린다. 커서 근처가 아니라 줄 전체가 매 프레임 덜덜 떨었다.

해결은 발상을 뒤집는 것이었다. 자간을 "간격"으로 주지 말고 위치 이동량으로 계산한다. 글자 i가 받아야 할 추가 간격을 앞에서부터 누적하고, 전체 누적의 절반을 빼서 줄이 가운데를 유지하게 만든다. 그러면 각 글자는 자기 자리에서 translateX만 하면 된다. 레이아웃은 한 번도 다시 계산되지 않는다.

// 1) 추가 간격 누적
var sum = 0, pre = [];
for (var i = 0; i < chars.length; i++) {
  pre[i] = sum;
  sum += chars[i].cur * MAX_SPACE;      // MAX_SPACE = 폰트 크기의 0.16배
}
// 2) 줄 중심 유지 — 전체의 절반만큼 왼쪽으로
for (var i = 0; i < chars.length; i++) {
  chars[i].shift = pre[i] - sum / 2;
}

이 한 번의 누적합으로 "글자들이 커서를 피해 서로 밀려난다"는 느낌이 그대로 나온다. 그리고 transform은 합성 단계에서 처리되니까 레이아웃 비용이 0이다.

막힌 지점 2 — 굵기를 바꾸면 글자가 옆으로 밀렸다

1번을 고치고 나니 더 교묘한 문제가 남아 있었다. font-variation-settings로 굵기를 올리면 글리프의 폭 자체가 넓어진다. 인라인 요소니까 넓어진 만큼 뒤 글자들이 밀린다. 방금 없앤 레이아웃 흔들림이 다른 문 열고 다시 들어온 셈이다.

답은 각 글자를 절대 위치로 못 박는 것이었다. 처음 한 번 평소 굵기로 흘려 배치해 좌표와 폭을 재두고, 그 값으로 고정한다. 폭을 고정한 채 text-align:center를 주면 글자가 굵어질 때 제 상자 안에서 좌우로 대칭으로 부푼다. 이웃은 전혀 모른다.

// 평소 상태로 한 번 측정 (measuring 클래스가 절대 위치를 잠깐 해제)
stage.classList.add('measuring');
var lb = line.getBoundingClientRect();
var base = chars.map(function (c) {
  var r = c.el.getBoundingClientRect();
  return { x: r.left - lb.left, w: r.width };
});
stage.classList.remove('measuring');

// 재둔 값으로 고정
base.forEach(function (b, i) {
  var s = chars[i].el;
  s.classList.add('abs');
  s.style.left  = b.x + 'px';
  s.style.width = b.w + 'px';   // 폭 고정 + text-align:center
});

측정은 창 크기가 바뀔 때만 다시 한다(디바운스 120ms). 폰트가 늦게 로드돼도 document.fonts.ready 이후 한 번 더 잰다. 안 그러면 시스템 폰트 기준으로 잰 좌표에 Pretendard 글자를 얹게 되고, 글자들이 어긋난 채 굳는다.

막힌 지점 3 — Google Fonts가 가변폰트를 안 줬다

영문 줄만 굵기가 계단처럼 튀었다. 400에서 500으로, 500에서 600으로 . 한글 줄은 매끄러운데 영문만 그랬다.

원인은 폰트를 불러오는 URL이었다. 기존 랩 페이지들이 쓰던 family=Instrument+Sans:wght@400;500;600 은 세미콜론으로 정적 인스턴스 3벌을 지정하는 문법이다. Google Fonts는 요청한 그대로 3벌만 준다. 그러니 "wght" 437 을 요청해도 브라우저가 가장 가까운 400으로 스냅한다. 범위를 원하면 점 두 개다.

<!-- 정적 3벌 — 400 / 500 / 600 에서만 끊긴다 -->
family=Instrument+Sans:wght@400;500;600

<!-- 진짜 가변 — 400~700 사이 아무 값이나 -->
family=Instrument+Sans:[email protected]

자세한 축 문법은 MDN의 font-variation-settings 문서에 정리돼 있다. 참고로 font-variation-settings는 애니메이션이 되지만, 가능하면 font-weight 같은 상위 속성을 쓰라는 권고가 붙어 있다. 여기서는 wght 한 축만 직접 몰기 때문에 문제되지 않았다.

폴백 — 세 갈래를 전부 모듈 밖에 뒀다

랩 02(도자기 물레)에서 배운 게 하나 있다. 폴백 분기를 <script type="module"> 안에 넣으면, CDN이 막혔을 때 모듈이 통째로 실행되지 않아서 폴백까지 같이 죽는다. 이번 페이지는 아예 모듈을 쓰지 않았다. 외부 자바스크립트가 없으니 스크립트는 일반 스크립트 하나뿐이다.

상황감지동작
가변폰트 미지원CSS.supports('font-variation-settings','"wght" 500')굵기를 100 단위로 반올림해 font-weight로 적용. 자간·기울기는 그대로 동작
포인터 없음 (터치·TV)matchMedia('(hover:hover) and (pointer:fine)')가상 커서가 줄 위를 왕복하는 자동 재생. 손가락으로 드래그하면 즉시 수동 전환
모션 최소화matchMedia('(prefers-reduced-motion:reduce)')rAF 루프를 아예 시작하지 않고, 정적 프리셋을 한 번만 그림
폰트 CDN 실패2.5초 워치독 타이머시스템 폰트로 계속 동작. 무대에 안내 칩 표시

WebGL 미지원 분기는 없다. 이 페이지가 WebGL을 쓰지 않기 때문이다. 대신 자바스크립트가 통째로 꺼진 환경에서도 두 줄의 텍스트는 그대로 읽힌다 — 글자를 <span>으로 쪼개는 일 자체를 스크립트가 하므로, 스크립트가 없으면 평범한 텍스트 두 줄이 남는다.

숫자와 성능

글자 26자, 프레임당 계산은 거리 26번 + 누적합 1번. 스타일 쓰기는 글자당 2개(transform, fontVariationSettings). 레이아웃은 창 크기가 바뀔 때만 발생한다. 데스크톱 크로미움에서 프레임당 스크립트 시간은 0.4ms 안팎, 60fps 예산 16.7ms의 3% 수준이다.

가장 비싼 건 의외로 font-variation-settings였다. 굵기 축을 움직이면 브라우저가 글리프를 다시 래스터화한다. 소수점을 그대로 넘기면 미세하게 다른 값마다 캐시가 새로 생기므로, toFixed(0)으로 정수로 잘라 넘긴다. 이 한 줄로 저사양 기기에서 눈에 띄게 부드러워졌다.

커서가 무대 밖으로 나가면 목표값이 전부 0이 되고, 모든 글자의 cur0.001 아래로 내려가면 루프를 멈춘다. 가만히 두면 CPU를 전혀 쓰지 않는다.

다음에 해볼 것

지금은 커서 하나만 읽는다. 포인터 이벤트로 여러 접점을 동시에 읽으면 두 손가락으로 글자를 양쪽에서 벌리는 것도 된다. 그리고 slnt 축이 실제로 있는 폰트 — Recursive나 Roboto Flex — 를 쓰면 skewX 흉내가 아니라 진짜 기울기 축을 쓸 수 있다. 기울일 때 글자가 찌그러지지 않고 원래 설계된 대로 눕는다. 그 차이는 크게 띄웠을 때 바로 보인다.

다른 실험들은 랩 목록에 모아두고 있고, 만드는 과정에서 나온 생각들은 블로그에 쓴다. 작업 의뢰나 소개는 에 있다.