GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

文字 円形 配置 9種 — 円形 文字 デザインを文字幅で計算

文字 円形 配置とは、文字を円の線に沿って一つずつ並べ、輪の形のまま読ませる作り方です。十二文字を30度ずつ置くと、iの横は空き、Wの横は詰まります。だから01番は文字幅÷半径で角度を決め、03番は長さを保ったまま直線を輪に丸め、05番は半径ごとに角速度を変えて二つの輪をかみ合わせます。

同じ角度で並べると字間がばらつきます — 円形の文字を決めるのは文字幅と半径です

自動再生 · タイルを押すとその項目へ · すべて1つのzip

丸いバッジが仕上がって見えるのは、となり合う文字のすき間が一周どこでも同じに見えるときで、円を字数で等分しただけではそうなりません。字は一字ごとに幅が違うので、一字が必要とする弧は、その字の幅と、その字が座る半径の二つで決まります。九つの場面はどれも JavaScript で計算して描くキャンバスで、SCSS は舞台・カード・影の大きさを受け持ち、キャンバスはカードの中で 480×300 の固定座標を使い、画素の倍率は 2 で頭打ちにしています。

同じ角度の版と幅に合わせた版の字間

01 の上の半円を半径 100px で比べると、韓国語版の文言では、等分した角度で並べた版の字間が −2.4px(二字が重なる)から 10.6px までばらつき、幅÷半径で角度を配った版は 2.15〜3.5px に収まります。一字あたりの位置の差は韓国語版で最大 5.8°、英語版で 4.9° ですが、日本語版の「まちのコーヒー」は字の幅がそろっているため、二つの版が同じ位置に重なって差は 0° です。デモの外側に薄く出ている列がこの等分版で、内側の幅合わせの輪と見比べられるように描いてあります。

半径を小さくすると字も縮めなければならない理由

立てた字は下の端が真ん中より円の中心に近く、小さな輪ではその下の端から先に場所が足りなくなります。01 は字の高さの半分だけ内側の半径(INNER_FIT 0.5)で幅を合わせ、上の文言が 2.5 rad の持ち分を超えるとフォントを縮めます。半径 58px では韓国語版が 54.4%(10.89px)、英語版が 71.8%(14.35px)、日本語版が 83.3% まで縮み、言語ごとに率が違うのは、それぞれの言語版のデモがその言語の文言をその字の幅で並べ直すからです。

角度は幅÷半径、角速度は半径に反比例、らせんは弧の長さで求めます — 円の上の文字を支える計算

九つの場面を支える式は三つで、どれも目分量の刻みを、測った長さに置き換えるためのものです。

幅÷半径が角度になる理由

ラジアンで表した角度は弧の長さを半径で割った値なので、幅 w ピクセルの字は半径 r の上で w / r ラジアンを使います。placeArc は字 i より前の字幅を足し、自分の幅の半分を加え、文言全体の半分を引いて弧の中央にそろえ、置く半径で割ります。i も W もその幅が求めるぶんだけの弧をもらうので、見た目のすき間が同じになります。「css 文字 曲線」で見つかる作例の多くはスタイルシートで一字ずつ決まった刻みで回していますが、ここでの回転も同じ動きで、刻みだけが測った幅から来ています。

接した二つの輪の角速度

二つの輪が接する点では両方の表面が同じ速さ v で動くので、それぞれの輪は v / r で回ります。05 では接点の速さが 60px/s、韓国語版の文言で大きな輪の半径が 101.86px、小さな輪が 50.93px なので、33.8°/s と 67.5°/s、比はちょうど 2.000 になり、一組の歯車のように逆向きに回ります。

らせんの弧の長さを小さな歩幅で足していく

一定の幅で広がる渦巻き r = a + bθ には、字を置くのに使える手ごろな長さの式がないので、06 は表を作ります。一歩ごとに歩幅の真ん中で ds = √(r² + (dr/dθ)²) dθ をとって累計に足し、その表で長さを引けば各字の θ が返ってきます。らせんが外へ開いていっても、曲線に沿ったとなりの字との距離は一字の幅のままです。

文字 円形 配置 9種 — 幅合わせの円形バッジ・スクロール速度で回るバッジ・輪に丸まる一行・円筒の帯・かみ合って回る二つの輪・らせん文字・文字の進捗リング・波打つ輪・ベーカリーの判子の場面

下の表の数値は、九つとも同じ条件の録画から取っています。480×300 のキャンバスを 2 秒間・24 枚で記録したので、数えるフレーム間の差は 23 個です。

480×300 のキャンバスで 2 秒のあいだに動いたもの

場面 動いた面積 動いたフレーム 強さの平均
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

画面をいちばん広く塗り替えたのは 03 の 39.9% で、一行が平らな形から閉じた輪まで掃いていくためです。いちばん狭いのは 07 の 15.0% と 05 の 15.2% で、変わるのが文字の輪一つか細い帯二本だけだからです。止まっていた場面はなく、九つとも 23 のすき間のうち 23 で絵が変わりました。

01文字幅で角度を割りふる円形バッジ

「円形 文字 ロゴ」として街のコーヒー店に置く想定で、「まちのコーヒー」が上を時計回りに、「毎朝7時」が下を反時計回りに走ってどちらも正立して読め、半径はばね(硬さ 70・減衰 11)で 100px から 0.95 秒に 58px へ、2.6 秒に 100px へ戻ります。badgeAt は弧ごとに arcScale を一度呼び、二つの率の小さいほうを残して上下の文字サイズを一つにそろえてから placeArc に渡しますが、下のコード片にある fitArc はファイルの中で定義されているだけで、どこからも呼ばれていません。右側には失敗例が二つあり、右上の「下も時計回り — 逆さま」は下の弧を時計回りのままにして逆さに出る版、右下の「縮めないとぶつかる」はいまの半径の 0.44 倍の輪に縮めないまま字を置き、ぶつかった場所にコーラル色の点を打った版です。

文字幅で角度を割りふる円形バッジ
新しいタブで開く

01 · 文字幅で角度を割りふる円形バッジ — 計測 累積面積 25.592% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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.35 rad/s + 0.0024 × 速さで決め、いまの角速度は 1/60 秒ごとに差の 8% を詰め(SPIN_EASE)、スクロールの速さは e^(−2.6t) で落ちていきます(SCROLL_FRICTION)。0.25・0.9・1.4・2.9 秒の四回のはじきで角速度は 1.625 秒に最高 306.4°/s(目標の最高は 530.5°/s)に達し、最初のはじきから 0.1 秒後に目標の 47%、0.25 秒後に 96% まで追いつき、休むと 20.1°/s に戻ります。字は角速度 × 0.06 だけ傾き(上限 24.1°)、最高のときには 18.4° 傾いています。

スクロールの速さに合わせて速くなる回転バッジ
新しいタブで開く

02 · スクロールの速さに合わせて速くなる回転バッジ — 計測 累積面積 22.098% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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長さを保ったまま輪に丸まる一行の文字

ブランド紹介ページのロゴの登場に向いた場面で、一行が 0.2 秒から 331.2°(0.92 周)に向かってばねで丸まり、1.9 秒にほどけて 3.2 秒で繰り返し、curlPoint が半径を「長さ ÷ 丸まりの角」で計算し直すので字間は変わりません。韓国語版の文言では一行の長さが 392.3px、閉じたときの半径が 67.9px で、最初と最後の字の中心どうしの弧の長さはまっすぐでも半周でも 331° でも 374.3px のままで、丸まりは 410.3° まで行き過ぎて両端が一瞬重なってから落ち着きます。角が STRAIGHT_EPS(0.001)より小さいときは、ほぼ 0 の角で割らずにまっすぐな行を返します。

長さを保ったまま輪に丸まる一行の文字
新しいタブで開く

03 · 長さを保ったまま輪に丸まる一行の文字 — 計測 累積面積 39.885% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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円筒に巻きついて回る帯の文字

商品詳細ページで回るスローガンを想定し、高さ −44・0・44 の三列の字が半径 150px の円筒を 0.75・−0.55・0.95 rad/s で回り、傾きは 12.6°〜26.4° のあいだを揺れ、520 / (520 + z) の倍率で投影されます。一字ずつ、円周方向の 1px と軸方向の 1px が画面のどこへ行くかという二つのベクトルで小さな枠を組んで描くので、奥側の字は鏡に映ったように反転し、裏側の字は 0.8px ずらした薄い二枚として半透明の帯の本体より奥に描かれます。韓国語版の文言では 0 秒の時点で 105 字のうち表が 51 字・裏が 54 字で、裏の 54 字は 54 字とも枠の面積が負(鏡)になり、並べ替えは遠いものから近いものへの順で、倍率は 0.77〜1.43 でした。

円筒に巻きついて回る帯の文字
新しいタブで開く

04 · 円筒に巻きついて回る帯の文字 — 計測 累積面積 33.824% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 のマス(TOOTH_PITCH)を使い、マス数は文言の字数に、周長が望む半径に近くなる繰り返し数を掛けたもので、韓国語版の文言では 40 マスと 20 マス、半径 101.86px と 50.93px、中心距離はその和の 152.79px です。接点の速さは 0.2 秒からばねで 60px/s まで上がり、小さな輪は π + π/20 から始まるので接点では大きな輪のマスの境目が小さな輪のマスの真ん中に当たり、2.5 秒後の位相は 0.719 と 0.219 マスでちょうど半マスずれ、接点ではどちらの表面も下向きに 60px/s で動いています。残像は 1.5 フレームで回る角ずつ後ろへずらした二枚で(omega / 60 * 1.5)、1.5 フレーム後ろと 3 フレーム後ろに並びます。

接して歯車のように回る二つの文字の輪
新しいタブで開く

05 · 接して歯車のように回る二つの文字の輪 — 計測 累積面積 15.189% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 ずつ開き(b = 23 / 2π)、半径 148px まで 37.1 rad、5.91 周あり、arcTable が 0.004 rad の歩幅で 9,289 区間を足して全長 2,976.8px を出します。字の大きさは半径に比例し(r の 0.115 倍、最小 5px なので 5〜16.99px)、大きさごとに幅を測り直し、文言は 46px/s で外へ流れて一字ぶんのすき間が開くたびに次の字が差し込まれ、外の端に着いた字はらせんの外へ流れ出ていき、らせん全体は −0.22 rad/s で回り、韓国語版の文言では画面に 350 字が乗ります。各歩幅で真ん中の値を使うので 0.0002 rad の歩幅との差は 0.000px、歩幅を 0.5 rad まで広げても 0.002px しかずれず、根号の中の b² を落とすと 2,972.22px と 4.6px 短くなります。

弧の長さで文字を置くらせんの文字
新しいタブで開く

06 · 弧の長さで文字を置くらせんの文字 — 計測 累積面積 28.308% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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進み具合のぶん文字が満ちていく円形の進捗リング

写真のアップロード表示を想定し、まねた速さ 8.5MB/s ± 55%(7% の確率でその瞬間だけ 4 分の 1 に落ちる、シード 7)で 24MB のファイルを満たし、残り時間は残量を、1/60 秒ごとに新しい値を 6% ずつ取り込む指数平均で割って出します。各字の角の区間は幅の累計 ÷ 全体の幅 × 2π で決めてありますが、cover07 はどの字に色を入れるかを選ぶだけで、塗りは輪の中心から真上(−π/2)〜進み具合の角 Θ の扇形で切り抜いた内側に描かれるので、塗りと空の境目は半径の向きに走って境目の字は一部だけ色がつき、満ちたばかりの字はその先 0.25 rad のあいだ最大 16% ふくらみます。場面の時計では 3.1 秒に終わり、始まりが 0.2 秒(START_S)なので実際に上がっている時間は計算で約 2.9 秒、瞬間の速さが 1.01〜13.16MB/s と揺れても平均は 1.1〜8.72MB/s に収まるので、残り時間の数字は 1 秒あたり平均 8.4 しか動かず、瞬間の値で出していたら 232.7 動くところでした。

進み具合のぶん文字が満ちていく円形の進捗リング
新しいタブで開く

07 · 進み具合のぶん文字が満ちていく円形の進捗リング — 計測 累積面積 14.951% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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音の大きさで波打つ輪と法線に沿う文字

ボイスメモの録音ボタンを囲むラベルを想定し、半径は 90px に三つの正弦波(倍音 3・5・8、振幅 11・6・3.5px)をまねた音の大きさで掛けて足したもので、フレームごとに 0.01 rad の弧の長さの表を作り直し、繰り返した文言の幅をいまの周長に合わせて引き伸ばしてから thetaAt が各字の θ を探します。音の大きさは約 0.4 秒おきの拍(±25%、強さ 0.45〜1、シード 11)を、上がるときは速さ 30、下がるときは 4 で追い、オーディオの API は使わず、各字は tangentAngle で曲線そのものの接線に向くので、ただの円の接線から最大 30.8° 傾きます。4 秒のあいだに拍は 10 回、音の大きさの最高は 67% で、周長は 565.5px(静かなときの 2π × 90)から 586.1px まで揺れましたが、字間は 16.63〜17.24px にそろっていました。

音の大きさで波打つ輪と法線に沿う文字
新しいタブで開く

08 · 音の大きさで波打つ輪と法線に沿う文字 — 計測 累積面積 20.627% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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ベーカリーのファーストビューの判子の場面

パン屋のランディングページを想定し、「まちのパン屋・朝焼いたパン・」が 0.2 秒から 2.6 rad のアーチの形を 03 と同じ丸め方で閉じて半径 72px の判子の輪になり、字は初め monospace の代わりのフォントで測った位置に座り、場面がフォントの到着とみなす 0.75 秒に、測り直した位置へ一字ずつばね(硬さ 180・減衰 14)で移ります。1.15 秒に高さのばね(260・13)が判子を 1 から 0 へ落とし、大きさは 1 + 0.22 × 高さで、高さが 0.05 を下回った 1.275 秒に紙へ跡を残してから、いちばん深く押されたときの −0.245(5.4% つぶれる)まで沈み、接してから 0.512 秒後には ±0.02 に収まります。1.75 秒に 0.35(大きさ 1.077 倍)まで浮いて 28.6°/s で回り、接する「焼きたて・」の輪は 05 と同じく接点で速さがそろうよう、逆向きに 68.7°/s で回ります。

ベーカリーのファーストビューの判子の場面
新しいタブで開く

09 · ベーカリーのファーストビューの判子の場面 — 計測 累積面積 30.042% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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; }

css テキスト 円形を実現するには、何を計算すればよいのでしょうか?

本物のページで円の上に字を置く仕事は三つに分かれます。長さの決まっていない文言を決まった弧に収めること、実際の値に結びついた速さで回すこと、そして輪を目で追わない人にも言葉として届くようにしておくことです。

コーヒー店のバッジとパン屋の判子

店主が店名を変えても、だれも角度を手で直さずにバッジが持ちこたえる必要があり、それを受け持つのが 01 の arcScale です。09 はもう半分の問題、最初に並べたあとで届くフォントへの備えを足しています。貼り付けてすぐ使える CSS を探して来た方へお伝えすると、ここのファイルの字はスタイルシートではなくキャンバスの上で曲がっていて、まわりのカードは素の SCSS なので、計算に触れずに見た目だけを変えられます。

アップロードの輪と音に応える輪

07 と 08 は、一秒に何度も変わる数字に字を結びつけています。07 はアップロードの速さを残り時間の表示に渡す前にならし、08 は周長が音の大きさで伸び縮みするのでフレームごとに字を均等に並べ直します。どちらの入力もシードを固定した乱数から作っているので、ファイルもマイクもなしでデモが動きます。

となりの文字の効果との違い

曲線に沿って横に滑りながら傾きに合わせて回る字の帯は横スクロールの慣性を扱った記事の 09、ポインターをよけてばねで戻る字は物理演算で文字を落とす記事の 04 にあります。らせんに沿って空いた場所へ単語を一つずつ落としていく配置はワードクラウドの作り方の記事の 02 です。この記事は閉じた輪にとどまり、文言がきっちり決まった周長を分け合う場面だけを扱います。

円形の文字が壊れやすい場面 — 下半分で逆さになる文字・Webフォントが届く前に測った幅・小さな輪でぶつかる太字

輪を作っているあいだに三つの失敗に出会い、どの失敗版もデモのどこかに描き込んであります。

下の弧で逆立ちする文字

下の文言も上と同じく時計回り(向き +1、回転 θ + π/2)で続けると、韓国語版の文言では下の中央の字の回転が 196.4° になって逆さに読め、反時計回り(向き −1、回転 θ − π/2)にすると −16.4° で、差はちょうど 180° です。直す場所は placeArc(bottomWidths, BADGE_PX, TRACKING, r, INNER_FIT, s, Math.PI / 2, -1) の呼び出し一つと字の回転の −π/2 で、01 の右上の版がその逆さの形を正しいバッジのとなりに見せています。時計回りの形(−1 と −π/2 の代わりに 1 と +π/2)を自分で試し、バッジと同じ大きさで下の弧がひっくり返るのを確かめたいときは、zip のパスワードに b68yeu36 を入れて開き、01 フォルダの index.html を書き換えてください。

遅れて届いたフォントで押し出される判子の文言

09 は最初の 0.75 秒、monospace の代わりのフォントで測った幅で字を置きます。100px で測ると韓国語の字はどちらのフォントでも同じ幅ですが、空白は 35.2 ではなく 55.0、中黒は 26.2 ではなく 55.0 になり、ラテン文字はもっと大きく振れて W は 101.0 が 55.0 に、i は 27.4 が 55.0 になります。日本語版の判子の文言「まちのパン屋・朝焼いたパン・」は代わりのフォントで 233.1px、端末のフォントで 229.9px と差が小さく、輪の上で字が動くのは最大 4.6° ですが、韓国語版は代わりのフォントで 15.1% 広く(239.2px と 207.7px)11.2°(輪に沿って約 14.1px)、英語版は 4.7% 狭く(325.5px と 341.6px)11.7° 動きます。同じ測り方で 01 の上の文言を調べると、日本語版は 0°、韓国語版は 7.3°、英語版は 17.4° でした。

デモは外部の Web フォントを一つも読み込まず、キャンバスは system-ui から始まり sans-serif で終わる端末のフォント一覧で描いていますが、下の守りは九つの場面すべてで動いています。幅を測るのは document.fonts.ready が解決したあとだけで、それより前にデモのループは始まらず、あとから loadingdone が届いたら remeasure() と draw() をもう一度呼びます。

    // 文字幅はフォントが届いてから測る — それより前に測った幅は代わりのフォントの幅なので、フォントが替わった瞬間に位置がずれる。
    // あとで別のフォントが届いたら(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();
        }
    });

字の幅は measureText で 100px のときの一字ずつを測ってキャッシュに置き、使う大きさに比例させています。remeasure() はそのキャッシュを空にしてから、各場面の relayout() を呼び直します。

小さな輪でぶつかる太字

韓国語版のバッジ(太さ 800)を字の中心の半径で縮めずに合わせると、半径 100px では上の弧のとなり合う 14 組のうち 12 組が下の端で最大 0.65px 重なり、58px では重なりが 2.21px に達し、二つの文言で 410.9° と一周を超えるので上と下がぶつかります。INNER_FIT 0.5 の半径で合わせて arcScale で縮めると、58px の輪は 54.4%(10.89px)になり、いちばん詰まった組でも 0.82px のすき間が残ります。英語版も 58px で 12 組の 12 組とも最大 1.26px 重なり、同じ縮め方で 71.8% にすると解消し、01 の右下の版は縮めない輪を描いて、ぶつかる場所ごとにコーラル色の点を打っています。

回転を軽く、輪を速く、丸まりを固くすると — 定数一つで変わる円形の文字

どの行も定数を一つだけ変え、数値は同じ関数を新しい値でもう一度呼んで出したものです。05 の行は韓国語版の文言の輪の大きさで計算しています。

名前 変えた値 変わること
回転を軽く 02 SPIN_EASE 0.08 → 0.2 差の半減 138.5 → 51.8ms、最初のはじきから 0.1 秒後に目標の 47 → 85%、最高 306.4 → 379.0°/s。最後のはじきのあと休みの速さの 1.1 倍以内に戻る時間は 1.90 → 1.78 秒とわずかしか縮まず、その尾は SCROLL_FRICTION が決めています
輪を速く 05 EDGE_SPEED 60 → 140 大きな輪 33.8 → 78.8°/s、小さな輪 67.5 → 157.5°/s で比は 2.000 のまま。一周 10.67・5.33 秒 → 4.57・2.29 秒、接点の字は 1 フレームに 1 → 2.33px 進むので、残像が長く濃くなります
丸まりを固く 03 CURL_STIFFNESS 120 → 320 331.2° への最初の到達 0.196 → 0.100 秒、行き過ぎ 79.1 → 145.2°、目標を越える回数 5 → 9、±2° 以内に落ち着くまで 1.054 → 1.108 秒。減衰は 9 のままなので、減衰比は 0.411 → 0.252 に下がります。減衰比を保つには減衰も √(320 ÷ 120) ≈ 1.63 倍の約 14.7 に上げます(計算)

意外なのは丸まりを固くした行で、輪は半分の時間で閉じるのに、止まるのはかえって遅くなります。同じ減衰のまま、より多くのエネルギーをためたばねを静めなければならなくなるためと考えられます。

読める円形の文字 — 読み上げ用の一行・一時停止ボタン・動きを減らす設定のアクセシビリティ

読み上げ用の一行

キャンバスには九つの場面すべてで aria-hidden="true" がついているので輪は一字ずつ読み上げられず、目には見えない <p class="cf__sr">(1px、clip-path: inset(50%))が同じ文言を普通の文として一度だけ持っています。状態の行は aria-live="off" で始まって最初の入力のあとに polite へ切り替わり、07 は進み具合が新しい 10% の刻みを越えたときだけ書き直すので、一回の再生で書き直しは 11 回、読み上げ機能には毎フレームの数字ではなく「{p}% 完了・残り約 {s} 秒」の形の一文が届きます。

一時停止ボタンと動きを減らす設定

各場面には一時停止のための本物の <button type="button" aria-pressed="false"> があり、:focus-visible で 3px の輪郭線が出ます。Enter と Space はボタンの既定の動作で効き、keydown のハンドラーはなく、押すと aria-pressed が true になります。動きを減らす設定では showRest() がループを止め、restPose() の最終配置を字がすべて定位置にある一枚として描くので、0.4 秒と 1.2 秒の撮影の差は九つの場面すべてで 0 画素で、設定を外すとまた動き出します。入力があるのは 02 だけで、そのホイールのリスナーは passive で preventDefault を呼ばないのでページのスクロールはそのまま続き、動きを減らしているあいだはホイールを回したぶんだけバッジを回して一度だけ描き直します。どの場面もタブが隠れているあいだは止まり、戻ってくると再開します。

円形の文字をWebで作るときのよくある質問

円形 文字 デザインを SVG の textPath で作ってもかまいませんか

動かないバッジなら作れます。SVG の textPath は円の道筋に沿って字を長さで流すので、01 のような止まったバッジはまかなえます。03 の丸まり、07 の半分だけ塗られた字、05 のかみ合わせのように、毎フレーム各字の位置と角度が要る場面は、ここで使っている直接の計算のほうが扱いやすくなります。

01 のバッジを自分の店名にするにはどの行を変えますか

01 フォルダの TOP_TEXT と BOTTOM_TEXT を変えます。日本語版のファイルでは「まちのコーヒー」と「毎朝7時」です。長い名前が TOP_SPAN の 2.5 rad を超えると arcScale が収まるまでフォントを縮め、バッジは二つの弧の小さいほうの率をとるので、上下は同じ大きさのままです。

文言が長いと字が小さくなりすぎませんか

58px の輪では 20px のフォントが、英語版で 14.35px、15 字の韓国語版で 10.89px まで小さくなり、日本語版は 83.3% なので計算で約 16.7px です。それより小さくなるなら文言を短くするか、06 のように長さを足してください。らせんなら韓国語版の文言で 350 字が一画面に収まります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。