GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

원형 텍스트 만들기 9종 — css 원형 텍스트를 글자 폭으로 계산하기

원형 텍스트 만들기는 글자를 원 둘레를 따라 하나씩 돌려 세워 배지나 도장처럼 보이게 하는 일이다. 글자 열두 개를 30도씩 놓으면 i 옆은 비고 W 옆은 붙는다. 01번이 각도 대신 글자 폭÷반지름으로 자리를 나누는 이유다.

같은 각도로 놓으면 글자 사이가 들쭉날쭉하다 — 원형 텍스트를 정하는 것은 글자 폭과 반지름

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

원 둘레에 글자를 두르는 가장 쉬운 방법은 문구가 차지할 전체 각을 글자 수로 똑같이 나누는 것이다. 그런데 한글 한 자, 띄어쓰기 한 칸, 가운뎃점 하나는 폭이 서로 달라서, 같은 몫의 각을 받으면 넓은 글자는 이웃에 붙고 좁은 글자 옆은 빈다. 01 배지는 그 비교판을 바깥에 흐린 줄로 함께 그려 두었다.

같은 각도 판과 폭 배치 판의 틈

반지름 100px 배지의 위 반원 「동네 커피 · 갓 볶은 원두」 15자를 같은 각도로 놓으면 이웃 글자 사이 틈이 −2.4px(겹침)에서 10.6px 까지 갈린다. 각 글자에 제 폭만큼의 호를 주면 틈은 2.15~3.5px 로 고르게 모인다. 두 판에서 같은 글자의 각이 가장 크게 다른 곳은 5.8° 이고, 영어 문구에서는 4.9° 다. 일본어 문구는 글자 폭이 모두 같아 두 판이 똑같이 나온다.

반지름이 줄면 글자도 줄어야 하는 까닭

01 의 반지름은 100px 에서 58px 로 좁아졌다가 돌아온다. 반지름 100px 에서 글꼴은 원래 20px 의 93.8% 인 18.77px 이고, 58px 에서는 54.4% 인 10.89px 까지 줄어든다. 위 반원 문구가 차지하는 각은 두 반지름에서 똑같이 131.4° 다. 줄어드는 몫은 문구 글자에 따라 달라서 같은 58px 에서 영어 문구는 71.8%, 일본어 문구는 83.3% 에 멈춘다. 그래서 언어를 바꿀 때마다 폭을 다시 재야 한다.

각도는 폭÷반지름, 속도는 반지름에 반비례, 나선은 호 길이 적분 — 원 위 글자를 받치는 수식

아홉 장면을 보기 전에 계산 셋을 먼저 짚는다. 셋 다 반지름 r 인 원에서 호 길이 s 와 각 θ 가 s = rθ 로 이어진다는 한 줄에서 나온다.

폭÷반지름이 각인 까닭

글자 하나가 원 위에서 차지하는 각은 그 글자 폭을 글자를 놓는 반지름으로 나눈 값이다. 01 의 placeArc 는 글자 i 의 각을 「가운데 각 + 방향 × (앞 글자 폭 합 + 제 폭의 절반 − 전체 폭의 절반) × 축소 몫 ÷ 놓는 반지름」으로 정한다. 놓는 반지름은 원 반지름에서 글자 높이의 절반(INNER_FIT 0.5)을 뺀 값이라 글자 높이 가운데에서 폭을 맞춘다. 전체 각이 정해 둔 범위 span 을 넘으면 글꼴을 줄여야 하는데, 줄이면 글자 높이도 줄어 놓는 반지름이 커진다. 전체 폭 T 에 몫 k 를 곱하고 반지름 r − 0.5·px·k 로 나눈 각이 span 과 같아지는 k 를 풀면 k = span·r ÷ (T + span·0.5·px) 이고, arcScale 이 쓰는 식이 바로 이것이다.

맞물린 두 고리의 각속도

두 고리가 맞닿은 곳에서 미끄러지지 않으려면 두 표면이 같은 빠르기 v 로 지나가야 한다. 각속도는 ω = v ÷ r 이니 반지름이 절반인 고리는 두 배로 돈다. 05 는 이 비가 소수점 아래까지 맞도록 칸 길이부터 정하고 반지름을 거기에 맞춘다.

나선의 호 길이를 작은 걸음으로 더하기

반지름이 각에 따라 r = a + bθ 로 일정하게 벌어지는 나선에서는 s = rθ 가 그대로 통하지 않는다. 작은 각 dθ 동안 지나는 길이는 √(r² + (dr/dθ)²)·dθ 이고, 06 은 이 값을 걸음마다 걸음 가운데의 r 로 재어 누적표를 만든다. 06 의 나선(한 바퀴에 23px)에서 √ 안의 (dr/dθ)² 를 빼고 더하면 2,972.22px 로, 제대로 더한 2,976.8px 보다 4.6px 짧다. 걸음을 0.004rad 에서 0.5rad 로 크게 키워도 차이는 −0.002px 였다.

원형 텍스트 만들기 9종 — 폭 맞춤 원형 배지·스크롤 속도 회전 배지·원으로 말리는 한 줄·원통 띠 문구·맞물려 도는 두 고리·나선 문구·글자 진행 고리·출렁이는 고리·베이커리 도장 장면

01·02 는 로고 옆에 붙는 원형 배지, 03·09 는 한 줄 문구가 원으로 말리는 움직임, 04~06 은 원을 원통·톱니·나선으로 넓힌 모양, 07·08 은 고리가 진행률과 소리 크기를 나타내는 쓰임이다. 아홉 장면은 캔버스 2D 하나에 그리고 외부 웹폰트·라이브러리·이미지 파일 없이 기기 글꼴만 쓰며, 난수는 시드를 고정해 몇 번을 열어도 같은 시연이 나온다.

480×300 캔버스에서 2초 동안 잰 움직임

2초에 24장을 찍어 이웃한 두 장의 차이 23개를 센 값이다. 한 줄 문구가 판을 가로질러 말리는 03 이 39.9% 로 가장 넓고, 진행 고리 하나뿐인 07 과 고리 두 개의 글자만 도는 05 는 15% 안팎에 머문다.

장면 누적면적 움직인 프레임 강도 평균
01 폭 맞춤 원형 배지 25.6% 23/23 58.0
02 스크롤 속도 회전 배지 22.1% 23/23 79.1
03 원으로 말리는 한 줄 39.9% 23/23 66.5
04 원통 띠 문구 33.8% 23/23 57.0
05 맞물려 도는 두 고리 15.2% 23/23 58.9
06 나선 문구 28.3% 23/23 74.9
07 글자 진행 고리 15.0% 23/23 67.9
08 출렁이는 고리 20.6% 23/23 71.3
09 베이커리 도장 장면 30.0% 23/23 79.2

아홉 장면 모두 23/23 이라 2초 안에 멈춰 선 순간이 없다. 05 의 면적이 작은 것은 두 고리 안쪽이 비어 있고 글자 띠만 돌기 때문으로 보인다.

01글자 폭으로 각도를 나누는 원형 배지

카페 로고 배지처럼 위 반원에 가게 이름, 아래 반원에 「매일 아침 일곱 시」 를 두른 배지이고, 반지름이 스프링(단단함 70·감쇠 11)을 타고 100px → 58px → 100px 로 오가는 동안 badgeAt 이 위·아래 문구의 몫을 arcScale 로 따로 구해 작은 몫 하나로 두 placeArc 의 글꼴을 함께 줄인다(펜스 가운데 fitArc 는 정의만 있고 아홉 파일 어디서도 부르지 않는다). 아래 반원은 가운데 각 +π/2 에서 반대 방향(−1)으로 놓고 글자를 θ − π/2 만큼 돌려 아래쪽 글자도 바로 선다. 바깥 흐린 줄은 같은 각도 판, 오른쪽 위는 아래 반원까지 시계 방향으로 이은 실패판, 오른쪽 아래는 줄이지 않은 판이고 부딪힌 자리에 산호 점이 찍힌다.

글자 폭으로 각도를 나누는 원형 배지
새 창에서 열기

01 · 글자 폭으로 각도를 나누는 원형 배지 — 측정 누적면적 25.592% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

INNER_FITTOP_SPANRADIUS_NARROW
    // 글꼴 축소 몫 — 줄이지 않아도 들어가면 1
    function arcScale(ws, px, tracking, r, inner, span) {
        const total = arcTotal(ws, tracking);
        const need = total / Math.max(1, r - inner * px);
        return need > span ? span * r / (total + span * inner * px) : 1;
    }
    function fitArc(ws, px, tracking, r, inner, span, center, dir, allowShrink) {
        return placeArc(ws, px, tracking, r, inner, allowShrink ? arcScale(ws, px, tracking, r, inner, span) : 1, center, dir);
    }
    // 정한 축소 몫으로 놓기 — 위·아래 반원이 같은 글꼴 크기를 쓰도록 둘 중 작은 몫을 넘겨받는다
    function placeArc(ws, px, tracking, r, inner, scale, center, dir) {
        const n = ws.length;
        const total = arcTotal(ws, tracking);
        const need = total / Math.max(1, r - inner * px);
        const layR = Math.max(1, r - inner * px * scale);
        const theta = [];
        let acc = 0;
        for (let i = 0; i < n; i += 1) {
            theta.push(center + dir * (acc + ws[i] / 2 - total / 2) * scale / layR);
            acc += ws[i] + tracking;
        }
        return { theta: theta, px: px * scale, scale: scale, total: total, layR: layR, need: need };
    }

02스크롤 속도만큼 빨라지는 회전 배지

작업 모음 페이지 구석에서 스크롤을 빨리 내릴수록 빨리 도는 배지로, 목표 각속도는 쉴 때 0.35rad/s 에 스크롤 속도 × 0.0024 를 더한 값이고 지금 각속도는 1/60초마다 차이의 8%(SPIN_EASE 0.08)씩 따라간다. 시연 일정표의 첫 튕김 0.1초 뒤 목표의 47%, 0.25초 뒤 96% 까지 붙고, 최고 306.4°/s 로 돌 때 글자가 18.4° 기울며 잔상 3장이 뒤에 남는다. 휠은 { passive: true } 로만 들어 페이지 스크롤을 막지 않고, 스크롤 속도는 e^(−2.6t) 로 줄어 쉴 때 20.1°/s 로 돌아온다.

스크롤 속도만큼 빨라지는 회전 배지
새 창에서 열기

02 · 스크롤 속도만큼 빨라지는 회전 배지 — 측정 누적면적 22.098% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPIN_EASESPIN_GAINSCROLL_FRICTION
    function flick02(v0) { scrollV02 += v0; }
    function spinTarget02() { return IDLE_SPIN + SPIN_GAIN * scrollV02; }
    function lean02() { return clamp(spin02 * LEAN_PER_SPIN, -LEAN_MAX, LEAN_MAX); }
    function statusFor02() {
        simStatus = STATUS_SPIN.replace('{v}', String(Math.round(scrollV02))).replace('{t}', String(Math.round(deg(spinTarget02())))).replace('{w}', String(Math.round(deg(spin02))));
    }
    function update(dt) {
        while (flickNext02 < flicks02.length && sceneT >= flicks02[flickNext02][0]) {
            flick02(flicks02[flickNext02][1]);
            flickNext02 += 1;
        }
        scrollV02 *= Math.exp(-SCROLL_FRICTION * dt);
        if (Math.abs(scrollV02) < 1) {
            scrollV02 = 0;
        }
        scrollY02 += scrollV02 * dt;
        spin02 += (spinTarget02() - spin02) * easeStep(SPIN_EASE, dt);
        angle02 += spin02 * dt;
        statusFor02();
    }

03길이를 지킨 채 원으로 말리는 한 줄 문구

행사 배너의 제목 한 줄이 제 길이 392.3px 를 그대로 지킨 채 고리로 말렸다가 풀리는 장면으로, 말림 각 φ 가 정해지면 반지름은 R = L ÷ φ, 글자 자리는 α = s ÷ R 이다. 그래서 첫 글자와 끝 글자 가운데 사이의 호 길이는 곧은 줄일 때, φ 가 π 일 때, 331° 일 때 모두 374.3px 로 같다. φ 는 단단함 120·감쇠 9 스프링으로 목표 331.2° 를 향해 말기 시작 0.196초 뒤 처음 닿고 410.3° 까지 넘쳤다가 말기 시작 1.054초 뒤 ±2° 안으로 자리 잡으며, 다 말린 반지름은 67.9px 다.

길이를 지킨 채 원으로 말리는 한 줄 문구
새 창에서 열기

03 · 길이를 지킨 채 원으로 말리는 한 줄 문구 — 측정 누적면적 39.885% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CURL_STIFFNESSCURL_TARGETSTRAIGHT_EPS
    // 호 길이 s 인 자리 — 곧은 줄이면 (가운데 + s, 바닥 줄), 말렸으면 반지름 R 원 위 각 α = s / R 자리
    function curlPoint(s, phi, len, cx, y0) {
        if (Math.abs(phi) < STRAIGHT_EPS) {
            return { x: cx + s, y: y0, rot: 0 };
        }
        const r = len / phi;
        const a = s / r;
        return { x: cx + r * Math.sin(a), y: y0 - r + r * Math.cos(a), rot: -a };
    }
    function curlTarget03(t) { return t >= CURL_AT_S && t < UNCURL_AT_S ? CURL_TARGET : 0; }

04원통을 감고 도는 띠 문구

원통형 포장이나 컵 홍보 화면처럼 띠 문구 세 줄이 반지름 150 원통을 감고 각자 0.75·−0.55·0.95 rad/s 로 돌고, 원통은 12.6°~26.4° 사이로 끄덕이며 원근 배율 s = 520 ÷ (520 + z) 에 따라 글자가 0.77~1.43배로 바뀐다. 글자 판은 원통 둘레 1px 과 축 1px 이 화면에서 가는 두 벡터로 세워, 0초 판 105자 가운데 뒷면 54자는 54/54 가 판 넓이 부호가 음수인 거울 글자로 비친다. 먼 것부터 정렬해 뒷면 글자, 반투명 띠 몸통, 앞면 글자 순으로 그린다.

원통을 감고 도는 띠 문구
새 창에서 열기

04 · 원통을 감고 도는 띠 문구 — 측정 누적면적 33.824% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FOCALTILTBACK_ALPHA
    // 이번 장면의 글자 전부 — 깊이 큰(먼) 것부터 정렬해 돌려준다
    function bandGlyphs(t) {
        const tilt = tiltAt04(t);
        const out = [];
        const n = bandGlyphs04.length;
        BAND_ROWS.forEach(function (row, ri) {
            const turn = row[1] * spinT04 + ri * 0.7;
            bandAngles04.forEach(function (a0, k) {
                const a = a0 + turn;
                const p = cylPoint(a, row[0], tilt);
                const sp = project04(p);
                // 글자 가로 = 원통 둘레 방향 1px 이 화면에서 가는 벡터, 세로 = 축 방향 1px 이 가는 벡터
                const pa = project04(cylPoint(a + 1 / CYL_R, row[0], tilt));
                const py = project04(cylPoint(a, row[0] + 1, tilt));
                // 바깥 법선 (cos a, 0, sin a) 을 같은 기울기로 돌린 z — 0 보다 크면 화면 반대쪽을 본다(뒷면)
                const nz = Math.sin(a) * Math.cos(tilt);
                out.push({ ch: bandGlyphs04[k % n], x: sp[0], y: sp[1], z: p[2], s: sp[2], tx: pa[0] - sp[0], ty: pa[1] - sp[1], ux: py[0] - sp[0], uy: py[1] - sp[1], back: nz > 0, row: ri });
            });
        });
        out.sort(function (p, q) { return q.z - p.z; });
        return out;
    }

05맞닿아 톱니처럼 도는 두 글자 고리

주말 장터 안내처럼 큰 문구 고리와 작은 문구 고리가 맞물려 도는 장면으로, 두 고리의 칸 길이를 16px 로 같게 두고 칸 수 40·20 에서 반지름을 101.86·50.93px 로 고쳐 중심 거리를 두 반지름 합 152.79px 로 맞춘다. 맞닿은 곳 빠르기 60px/s 에서 큰 고리는 시계 방향 33.8°/s, 작은 고리는 반대로 67.5°/s 이고 비는 정확히 2.000 이다. 작은 고리의 처음 각을 반 칸 돌려 두어 맞닿은 곳에 큰 고리는 칸 경계, 작은 고리는 칸 가운데가 오고, 2.5초 뒤에도 위상이 0.719 와 0.219 로 반 칸 차를 지킨다.

맞닿아 톱니처럼 도는 두 글자 고리
새 창에서 열기

05 · 맞닿아 톱니처럼 도는 두 글자 고리 — 측정 누적면적 15.189% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

EDGE_SPEEDTOOTH_PITCHSMALL_PHASE
    // 칸 수 = 문구 글자 수 × 되풀이 수(둘레가 바라는 반지름에 가장 가깝게) — 문구가 고리를 딱 맞게 한 바퀴 이상 돈다.
    // 반지름은 칸 수 × TOOTH_PITCH / 2π 로 고친다(두 고리의 칸 길이가 같아야 이가 맞물린다)
    function meshRing(text, rWant) {
        const src = glyphsOf(text);
        const reps = Math.max(1, Math.round(TAU * rWant / (TOOTH_PITCH * src.length)));
        const slots = src.length * reps;
        const gs = [];
        for (let k = 0; k < slots; k += 1) {
            gs.push(src[k % src.length]);
        }
        return { slots: slots, r: slots * TOOTH_PITCH / TAU, glyphs: gs, turn: 0 };
    }
    const bigRing05 = meshRing(BIG_TEXT, BIG_R_WANT);
    const smallRing05 = meshRing(SMALL_TEXT, SMALL_R_WANT);
    const edge05 = makeSpring(0);
    // 큰 고리 중심과 작은 고리 중심 — 두 피치원이 맞닿게 중심 거리 = R1 + R2
    const BIG_CX = 186;
    const BIG_CY = 150;
    const SMALL_CX = BIG_CX + bigRing05.r + smallRing05.r;
    const SMALL_CY = BIG_CY;
    // 작은 고리의 처음 각 — 맞닿은 곳(각 π)에 칸 경계가 아니라 칸 가운데가 오게
    const SMALL_PHASE = Math.PI + Math.PI / smallRing05.slots;

06호 길이로 글자를 놓는 나선 문구

공연 포스터 가운데를 채우는 장식처럼 글자가 나선을 따라 바깥으로 흘러 나가는 장면으로, r = 12 + bθ(한 바퀴에 23px)를 r 148 까지 5.91바퀴 그리고 0.004rad 걸음 9,289칸으로 호 길이 2,976.8px 표를 만든다. 글자 350개는 이 표에서 제 폭만큼씩 호 길이를 받아 놓이고, 크기는 반지름 × 0.115 라 안쪽 5px 에서 바깥 16.99px 까지 커지며 폭은 크기마다 다시 잰다. 머리 글자가 46px/s 로 바깥으로 밀리면서 앞에 한 글자 틈이 생길 때마다 글자가 하나씩 끼어들고, 나선 전체는 −0.22rad/s 로 돈다.

호 길이로 글자를 놓는 나선 문구
새 창에서 열기

06 · 호 길이로 글자를 놓는 나선 문구 — 측정 누적면적 28.308% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPIRAL_GAPSPIRAL_STEPSIZE_PER_R
    function arcTable(rf, drf, th0, th1, step) {
        const th = [th0];
        const s = [0];
        let acc = 0;
        const n = Math.max(1, Math.ceil((th1 - th0) / step));
        const h = (th1 - th0) / n;
        for (let i = 0; i < n; i += 1) {
            // 걸음 가운데의 값으로 더한다 — 걸음 끝 값보다 같은 걸음에서 오차가 작다
            const m = th0 + (i + 0.5) * h;
            const r = rf(m);
            const dr = drf(m);
            acc += Math.sqrt(r * r + dr * dr) * h;
            th.push(th0 + (i + 1) * h);
            s.push(acc);
        }
        return { th: th, s: s };
    }

07진행률만큼 글자가 차오르는 원형 진행 고리

사진 업로드 진행 표시를 글자 고리로 바꾼 장면으로, 「사진 올리는 중 · 」 을 세 번 되풀이한 33자가 맨 위에서 시계 방향으로 진행 각만큼 칠해진다. 글자마다 폭 누적 ÷ 전체 폭 × 2π 로 각 구간을 정해 두지만 cover07 은 칠할 글자만 고르고, 실제 칠하기는 원 중심에서 맨 위부터 진행 각까지 그은 부채꼴로 잘라(clip) 그 안쪽만 채우므로 진행 1.8% 에서 「사」 가 0.41 만큼 찬 순간처럼 한 글자 안을 반지름 방향 경계가 지나간다. 남은 시간은 순간 속도(1.01~13.16MB/s)가 아니라 지수 평균(1.1~8.72MB/s)으로 나눠 숫자의 초당 흔들림이 232.72 에서 8.4 로 줄고, 24MB 는 장면 시계로 3.1초에 다 오르는데 올리기가 0.2초(START_S)에 시작하니 실제로 오르는 시간은 약 2.9초(계산)다.

진행률만큼 글자가 차오르는 원형 진행 고리
새 창에서 열기

07 · 진행률만큼 글자가 차오르는 원형 진행 고리 — 측정 누적면적 14.951% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPEED_SMOOTHFILE_MBSTATUS_STEP
    function progress07() { return clamp(done07 / FILE_MB, 0, 1); }
    function cover07(i, theta) { return clamp((theta - progA07[i]) / Math.max(1e-6, progB07[i] - progA07[i]), 0, 1); }
    function remain07() { return speed07 > 0.01 ? (FILE_MB - done07) / speed07 : 0; }
    // 상태 줄은 10% 단위가 바뀔 때만 새로 쓴다 — 남은 시간도 그 순간 값으로 굳혀 낭독기가 프레임마다 숫자를 읽지 않게
    let statusStep07 = -1;
    function statusFor07() {
        const p = progress07();
        if (p >= 1) {
            simStatus = STATUS_DONE;
            statusStep07 = 100;
            return;
        }
        const step = Math.floor(p * 100 / STATUS_STEP) * STATUS_STEP;
        if (step === statusStep07) {
            return;
        }
        statusStep07 = step;
        simStatus = STATUS_PROG.replace('{p}', String(step)).replace('{s}', String(Math.ceil(remain07())));
    }

08소리 크기에 출렁이는 고리와 법선을 따르는 글자

음악 소개 페이지처럼 소리 크기에 고리가 출렁이고 글자가 그 곡선의 법선을 따라 서는 장면으로, 반지름은 90 에 3·5·8번 물결(진폭 11·6·3.5px)을 소리 크기만큼 더한 r(θ) 다. 매 장면 0.01 걸음 호 길이 표를 다시 만들고 34자 문구의 폭 누적을 지금 둘레에 맞게 늘이므로, 4초 동안 둘레가 565.5px 에서 586.1px 까지 변해도 글자 간격은 16.63~17.24px 에 머물고 글자는 원 접선에서 최대 30.8° 기운다. 소리는 오디오 API 없이 시드 11 로 흉내 낸 박자이고, 4초에 10번 울려 크기가 최고 67% 까지 오른다.

소리 크기에 출렁이는 고리와 법선을 따르는 글자
새 창에서 열기

08 · 소리 크기에 출렁이는 고리와 법선을 따르는 글자 — 측정 누적면적 20.627% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RIPPLE_AMPSRIPPLE_HARMONICSTABLE_STEP
    // 글자 자리 — 되풀이한 문구의 폭 누적을 지금 둘레에 맞게 늘이거나 줄여(간격 재분배) 표에서 θ 를 찾는다
    function placeRipple(t, lv) {
        const tab = rippleTable(t, lv);
        const len = arcLength(tab);
        const n = rippleGlyphs08.length;
        const natural = sum(rippleWidths08) * rippleRepeat08;
        const k = len / natural;
        const gs = [];
        let acc = 0;
        const shift = (spin08 / TAU) * len;
        for (let i = 0; i < n * rippleRepeat08; i += 1) {
            const w = rippleWidths08[i % n];
            let s = (acc + w / 2) * k + shift;
            s = ((s % len) + len) % len;
            const th = thetaAt(tab, s);
            const r = rippleR(th, t, lv);
            const dr = rippleDr(th, t, lv);
            gs.push({ ch: rippleGlyphs08[i % n], x: R_CX + r * Math.cos(th), y: R_CY + r * Math.sin(th), rot: tangentAngle(r, dr, th), th: th });
            acc += w;
        }
        return { gs: gs, len: len, gap: (natural / (n * rippleRepeat08)) * k };
    }

09베이커리 첫 화면의 도장 장면

동네 빵집 첫 화면에서 아치였던 「동네 빵집 · 아침에 구운 빵 · 」 19자가 0.2초부터 아래로 말려 반지름 72px 고리가 되고, 1.15초에 도장 높이 z 가 스프링(260·13)으로 1 에서 내려와 1.275초에 처음 닿으며 종이에 자국을 남기고, 0 을 지나 −0.245 까지 눌렸다가 돌아온다. 0.75초까지 대체 글꼴 폭으로 놓였던 글자는 「글꼴 도착」 뒤 다시 잰 몫을 향해 글자마다 스프링(180·14)으로 옮겨 간다. 1.75초에 떠오른 도장이 28.6°/s 로 돌면 맞닿은 작은 고리 「갓 구움 · 」 은 반지름 비만큼 빠른 68.7°/s 로 따라 돈다.

베이커리 첫 화면의 도장 장면
새 창에서 열기

09 · 베이커리 첫 화면의 도장 장면 — 측정 누적면적 30.042% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FONT_ARRIVE_SSTAMP_STIFFNESSSTAMP_LIFT_SCALE
    function sealPlace(i) {
        const phi = curl09.x;
        const ringLen = TAU * SEAL_R;
        const s = slots09[i].x * ringLen;
        const y0 = SEAL_CY - SEAL_R;
        if (Math.abs(phi) < STRAIGHT_EPS) {
            return { x: SEAL_CX + s, y: y0, rot: 0 };
        }
        const r = ringLen / phi;
        const a = s / r;
        return { x: SEAL_CX + r * Math.sin(a), y: y0 + r - r * Math.cos(a), rot: a };
    }
    // 작은 고리 각속도 = 도장 각속도 × 도장 바깥 반지름(떠 있는 배율 포함) / 작은 고리 바깥 반지름
    function sealScale09() { return 1 + STAMP_LIFT_SCALE * stampZ09.x; }
    function badgeOmega09() { return SEAL_SPIN * SEAL_OUTER * sealScale09() / BADGE_OUTER; }

글자 원형 배치를 웹 화면에서 하려면 무엇을 계산해야 할까?

텍스트 원형으로 두르는 화면은 쓰임에 따라 계산할 것이 다르다. 가만히 서 있는 배지는 글자 폭과 반지름만, 움직이는 고리는 각속도와 호 길이까지, 진행 표시는 글자 하나 안의 각 구간까지 알아야 한다.

카페 로고 배지와 베이커리 도장

가게 이름과 문구를 원 위아래에 두르는 배지는 이름 길이가 가게마다 달라서 고정 각도로는 맞지 않는다. 01 처럼 폭으로 각을 나누고 넘치면 arcScale 로 글꼴을 줄이면 이름을 바꿔 넣어도 같은 범위에 담긴다. 09 처럼 찍히는 도장이라면 말리는 동안 글자 자리를 직접 알아야 해서 03 과 같은 호 길이 보존 식을 쓴다.

업로드 진행 고리와 소리에 반응하는 고리

진행 표시를 글자로 하면 진행 각이 글자 경계에 딱 맞지 않는다. 07 은 글자마다 각 구간을 들고 있다가 진행 각이 닿은 글자만 골라 부채꼴 안쪽을 칠해, 한 글자가 반쯤 차는 순간까지 보여 준다. 08 처럼 고리 모양이 바뀌는 곡선 텍스트는 둘레가 매 장면 달라지므로 호 길이 표를 다시 만들고 글자 간격을 둘레에 맞게 다시 나눈다.

이웃한 글자 효과와 무엇이 다른가

곡선 위를 옆으로 흐르며 기울기만큼 돌아가는 글자 띠는 가로 스크롤 css 9가지의 09 리본이 다루고, 포인터를 피했다가 돌아오는 글자는 글자 떨어지는 효과 9종의 04 가 다룬다. 단어를 나선 위 빈칸에 하나씩 넣는 배치는 워드 클라우드 만들기 9종의 02 가 맡는다. 이 편은 닫힌 원 둘레를 문구가 정확히 나눠 갖는 쪽에 선다.

원형 텍스트가 깨지는 곳 — 아래 반원에서 뒤집히는 글자·웹폰트가 늦게 와서 틀어진 폭·좁은 고리에서 부딪히는 굵은 글자

아래 반원에서 거꾸로 서는 글자

아래 반원을 위 반원처럼 시계 방향(방향 +1, 회전 θ + π/2)으로 이어 놓으면 아래 가운데 글자 「일」 이 196.4° 돌아 거꾸로 선다. 반대 방향(−1)으로 놓고 회전을 θ − π/2 로 바꾸면 같은 글자가 −16.4° 로 바로 서고, 두 회전의 차는 정확히 180° 다. 고칠 곳은 badgeAt 에서 아래 문구를 부르는 placeArc 의 마지막 두 값 Math.PI / 2, -1 과 그리기의 회전 부호 하나뿐이다.

글꼴이 늦게 와서 밀리는 도장 문구

09 는 첫 0.75초 동안 대체 글꼴(monospace)로 잰 폭을 쓴다. 100px 기준으로 한글 폭은 같지만 띄어쓰기가 35.2 대신 55.0, 가운뎃점이 26.2 대신 55.0 이라 도장 문구 전체 폭이 207.7px 에서 239.2px 로 15.1% 넓어지고, 고리 위 글자가 최대 11.2°(둘레 14.1px) 어긋난다. 영어 문구는 라틴 글자 폭 차이로 11.7°, 일본어 문구는 4.6° 어긋난다. 09 는 이 어긋남을 일부러 흉내 낸 것이지만 외부 웹폰트를 받지 않는 데모라도 글꼴이 준비되기 전에 잰 폭은 대체 글꼴의 폭일 수 있어서, 아홉 장면 모두 document.fonts.ready 뒤에 remeasure() 로 폭을 다시 재고 그다음에 시연을 시작한다.

    // 글자 폭은 글꼴이 다 온 뒤에 잰다 — 그 전에 잰 폭은 대체 글꼴의 폭이라 글꼴이 바뀌는 순간 자리가 틀어진다.
    // 나중에 다른 글꼴이 더 오면(loadingdone) 다시 잰다
    let fontsReady = false;
    document.fonts.addEventListener('loadingdone', function () {
        if (!fontsReady) {
            return;
        }
        remeasure();
        draw();
    }, { signal: off.signal });
    document.fonts.ready.then(function () {
        if (off.signal.aborted) {
            return;
        }
        fontsReady = true;
        remeasure();
        if (still) {
            showRest();
        }
        else {
            play();
        }
    });

글꼴이 나중에 하나 더 와서 loadingdone 이 울리면 같은 remeasure() 와 draw() 를 한 번 더 부른다. 폭은 measureText 로 글자 하나씩 100px 기준으로 재어 두고 크기에 비례해 쓴다.

좁은 고리에서 부딪히는 굵은 글자

굵기 800 글자를 글자 가운데 반지름에서 폭을 맞추고 줄이지 않으면, 반지름 100px 에서 위 문구 이웃 14쌍 가운데 12쌍이 글자 아래 모서리에서 최대 0.65px 겹친다. 58px 까지 좁히면 겹침이 최대 2.21px 로 커지고 위·아래 문구의 전체 각이 410.9° 로 한 바퀴를 넘어 두 문구가 맞부딪힌다. 01 폴더의 index.html 에서 badgeAt 이 고르는 축소 몫 s 를 1 로 바꿔 이 실패판을 큰 배지로 직접 견주려면, 압축을 풀 때 암호 칸에 b68yeu36 을 넣으면 된다. 글자 높이 가운데(INNER_FIT 0.5)에서 맞추고 arcScale 로 줄이면 58px 에서 54.4%·10.89px 가 되고, 가장 가까운 두 글자 사이에도 0.82px 틈이 남는다. 영어 문구는 58px 에서 12쌍 모두 최대 1.26px 겹치다가 71.8% 로 줄여 풀린다.

회전을 날래게, 고리를 빠르게, 말림을 단단하게 — 상수 하나로 달라지는 원형 문구

이름 바꾼 값 달라지는 것
회전을 날래게 02 SPIN_EASE 0.08 → 0.2 차이 반감 138.5 → 51.8ms, 첫 튕김 0.1초 뒤 47 → 85%, 최고 306.4 → 379.0°/s, 멈춘 뒤 꼬리 1.90 → 1.78초
고리를 빠르게 05 EDGE_SPEED 60 → 140 큰 고리 33.8 → 78.8°/s, 작은 고리 67.5 → 157.5°/s, 비 2 그대로, 맞닿은 곳 글자 한 프레임 1 → 2.33px
말림을 단단하게 03 CURL_STIFFNESS 120 → 320 처음 닿음 0.196 → 0.100초, 넘침 79.1 → 145.2°, 오가는 횟수 5 → 9, 자리 잡음 1.054 → 1.108초

02 는 따라붙는 몫을 키워도 멈춘 뒤 꼬리가 0.12초만 짧아지는데, 꼬리는 스크롤 속도가 줄어드는 빠르기 SCROLL_FRICTION 2.6 이 정하기 때문이다. 05 는 맞닿은 곳 빠르기만 바꿨으니 두 각속도가 함께 2.33배가 되고 작은 고리는 2.29초에 한 바퀴를 돈다. 03 은 단단함만 올리고 감쇠 9 를 그대로 두면 감쇠비가 9 ÷ (2√120) = 0.411 에서 9 ÷ (2√320) = 0.252 로 내려가, 고리가 두 배 빨리 닫히는데도 끝까지 자리 잡는 시각은 오히려 늦어진다. 감쇠비 0.411 을 지키려면 감쇠를 √(320 ÷ 120) ≈ 1.63배인 약 14.7 로 함께 올려야 한다(계산 — 데모로 재지는 않았다).

읽히는 원형 텍스트 — 화면 낭독용 원문 한 줄·멈춤 버튼·움직임 줄이기의 접근성

한 글자씩 끊겨 읽히지 않게

원 위 글자는 캔버스에 그려지고 캔버스는 아홉 장면 모두 aria-hidden="true" 다. 대신 원 위 문구와 같은 글자를 한 줄로 이은 숨긴 문단 cf__sr 를 두어 낭독기가 「동네 커피 · 갓 볶은 원두 — 매일 아침 일곱 시」 를 한 문장으로 읽는다. 상태 줄은 처음에 aria-live="off" 였다가 첫 입력 뒤 polite 로 바뀌고, 07 은 진행률을 10% 단위가 바뀔 때만 문장으로 다시 써 한 번 올리는 동안 11번만 알린다.

멈춤 버튼과 움직임 줄이기

장면마다 진짜 button 요소인 일시정지가 하나씩 있어 Enter·Space 로 누르고, 누르면 aria-pressed 가 true 로 바뀐다. 키보드로 다루는 것은 이 버튼 하나이고 따로 붙인 키 처리기는 없다. 움직임 줄이기 설정에서는 루프를 멈추고 글자가 모두 제자리에 놓인 마지막 한 장만 그려, 0.4초와 1.2초에 찍은 두 장의 화소 차가 아홉 장면 모두 0 이었다. 설정을 풀면 다시 돌고, 탭이 숨으면 멈췄다가 돌아오면 이어 간다.

원형 텍스트를 웹에 만들 때 자주 묻는 질문

css 원형 텍스트는 SVG 경로에 글자를 얹는 방법으로도 되나요

된다. SVG 의 textPath 는 원 경로를 따라 글자를 길이대로 흘려 01 같은 가만히 있는 배지에는 충분하다. 03 의 말리는 도중이나 07 의 글자 반쯤 채우기, 05 의 맞물림처럼 글자 하나하나의 자리와 각을 매 프레임 알아야 하는 장면은 이 편처럼 직접 계산하는 쪽이 다루기 쉽다.

우리 가게 이름으로 01 배지를 바꾸려면 어디를 고치나요

01 의 TOP_TEXT 와 BOTTOM_TEXT 두 줄만 바꾸면 된다. 이름이 길어져 위 반원 범위 TOP_SPAN 2.5rad 를 넘으면 arcScale 이 글꼴을 줄여 담고, 위·아래가 같은 크기를 쓰도록 둘 중 작은 몫을 고른다.

글자 수가 많으면 원 위 글자가 너무 작아지지 않나요

반지름 58px 고리에 15자를 담으면 글꼴이 20px 에서 10.89px 까지 줄어든다. 이보다 작아지면 읽기 어려우니 문구를 줄이거나 06 처럼 나선으로 길이를 늘리는 편이 낫다 — 06 은 같은 화면에 글자 350개를 담는다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.