커서를 따라 휘는 텍스트 — 가변폰트 축을 실시간으로 연주하기
먼저 결론
- 커서와 글자 중심의 거리를
0~1값으로 바꾸고, 그 하나의 값으로 굵기·자간·기울기 세 가지를 동시에 몬다. - Pretendard Variable에는
wght축 하나뿐이다. 기울기(slnt)와 폭(wdth)은 없는 축이라 CSStransform으로 흉내 냈다. - 글자마다 자간과 굵기를 매 프레임 바꾸면 줄 전체가 다시 배치되면서 덜덜 떨린다. 글자를 절대 위치로 고정하고
transform만 움직여 해결했다. - Google Fonts에서
wght@400;500;600으로 받으면 정적 3벌이 온다. 굵기가 계단처럼 튄다.[email protected]으로 바꿔야 진짜 가변폰트가 온다. - 폴백 3종 — 가변폰트 미지원 / 포인터 없는 기기 /
prefers-reduced-motion. 전부 모듈 밖 일반 스크립트에 뒀다.
무엇을 만들었나
위 무대에 큰 제목이 한 줄 있다. 커서를 가까이 가져가면 그 근처 글자들만 굵어지고, 서로 밀려나 자간이 벌어지고, 커서 쪽으로 기울어진다. 커서가 지나가면 원래대로 되돌아온다. 글자 한 자 한 자가 커서와의 거리를 읽고 자기 모양을 바꾸는 것이다.
흔히 보는 "마우스 오버하면 커진다"와 다른 점은 경계가 없다는 것이다. 어떤 글자에도 :hover가 걸리지 않는다. 각 글자는 커서와 자기 사이의 거리를 실수로 알고 있고, 그 거리가 연속적으로 변하니까 변형도 연속적이다. 그래서 커서를 천천히 움직이면 글자 줄 위로 물결이 지나간다.
기술 구성은 단순하다. WebGL도, 캔버스도, 외부 자바스크립트 라이브러리도 쓰지 않았다. DOM <span> 26개, requestAnimationFrame 루프 하나, 그리고 가변폰트. 페이지 전체가 HTML 파일 한 개다.
가변폰트는 굵기를 고르는 게 아니라, 굵기를 연주하는 것이다.
먼저 축부터 확인했다 — 있는 축과 없는 축
"자간·기울기·굵기를 가변폰트 축으로 조정한다"가 원래 계획이었다. 그런데 폰트를 열어보고 계획을 절반 접었다.
| 축 | 뜻 | Pretendard Variable | Instrument 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이 되고, 모든 글자의 cur가 0.001 아래로 내려가면 루프를 멈춘다. 가만히 두면 CPU를 전혀 쓰지 않는다.
다음에 해볼 것
지금은 커서 하나만 읽는다. 포인터 이벤트로 여러 접점을 동시에 읽으면 두 손가락으로 글자를 양쪽에서 벌리는 것도 된다. 그리고 slnt 축이 실제로 있는 폰트 — Recursive나 Roboto Flex — 를 쓰면 skewX 흉내가 아니라 진짜 기울기 축을 쓸 수 있다. 기울일 때 글자가 찌그러지지 않고 원래 설계된 대로 눕는다. 그 차이는 크게 띄웠을 때 바로 보인다.
다른 실험들은 랩 목록에 모아두고 있고, 만드는 과정에서 나온 생각들은 블로그에 쓴다. 작업 의뢰나 소개는 홈에 있다.