GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Circular Text CSS: 9 Rings Spaced by Letter Width

Circular text css puts letters on a ring. Place twelve letters 30 degrees apart and an i floats while a W crowds.

Equal Angles Make Uneven Gaps: Glyph Width and Radius Decide Circular Text

Auto-plays · click a tile to jump to its section · all nine in one zip

A round badge looks finished when the space between neighboring letters reads the same all the way around, and an equal slice of the circle per letter does not give that. Glyphs differ in width, so the arc a glyph needs depends on its own width and on the radius it sits at. Each of the nine scenes is a canvas drawing computed in JavaScript: the SCSS sizes the stage, the card and its shadow, and the canvas works in a fixed 480×300 coordinate space inside that card, with the pixel ratio capped at 2.

Gaps on the equal-angle copy and on the width-fitted copy

In 01, with the Korean copy at a 100px radius, splitting the top arc into equal angles leaves gaps from −2.4px (two letters overlapping) to 10.6px between neighbors, while giving each glyph its width over the radius keeps them between 2.15px and 3.5px. The two layouts drift apart by up to 5.8° at a single letter in the Korean copy and 4.9° in the English one; the Japanese copy uses glyphs of one width, so both layouts land on the same spots. The faint outer row in the demo is that equal-angle layout, drawn so you can hold it against the fitted ring inside it.

Why letters have to shrink when the radius shrinks

An upright glyph has its lower edge closer to the center than its middle, and on a small ring that lower edge runs out of room first. 01 fits widths at a radius half a glyph height inward (INNER_FIT 0.5) and shrinks the font once the top phrase needs more than its 2.5 rad budget: at a 58px radius the Korean badge drops to 54.4% (10.89px), the English one to 71.8% (14.35px) and the Japanese one to 83.3%. The share differs by locale because each language version of the demo lays out its own copy with that copy's glyph widths.

Angle Is Width over Radius, Spin Rate Falls with Radius, Spirals Need Arc Length — the Math Under Lettering on a Circle

Three formulas carry the nine scenes, and each one swaps a guessed step for a measured length.

Why width over radius is an angle

An angle in radians is arc length divided by radius, so a glyph w pixels wide needs w / r radians at radius r. placeArc adds up the widths of the glyphs before glyph i, adds half of its own width, subtracts half the phrase so the phrase centers on the arc, and divides by the placing radius. That is why an i and a W end up with the same visible gap: each one gets exactly the arc its width asks for. Most arc text css recipes rotate each letter by a fixed step in a stylesheet; the rotation here is the same move, but the step comes from measured widths.

Spin rates of two touching rings

Where two rings touch, their surfaces move at one shared speed v, so each ring turns at v / r. In 05 the edge speed is 60px/s, the large ring has a 101.86px radius and the small one 50.93px, which gives 33.8°/s and 67.5°/s, a ratio of exactly 2.000, and the two turn in opposite directions the way a pair of gears does.

Summing a spiral's arc length in short steps

A spiral r = a + bθ has no tidy length formula to place letters with, so 06 builds a table: for each step it takes ds = √(r² + (dr/dθ)²) dθ at the middle of the step and adds it to a running total. Looking a length up in that table returns the θ for each letter, so the distance along the curve between neighbors stays one glyph width while the spiral opens up.

Circular Text CSS: 9 Scenes — a Width-Fitted Badge, a Scroll-Speed Spin Badge, a Line That Curls into a Ring, a Cylinder Band, Two Meshing Rings, Spiral Lettering, a Letter Progress Ring, a Rippling Ring and a Bakery Seal Hero

The capture behind the table is the same for all nine: a 480×300 canvas recorded for 2 seconds in 24 frames, which leaves 23 frame-to-frame differences to count.

What moved in two seconds on a 480×300 canvas

Scene Area touched Frames that moved Mean intensity
01 Width-fitted round badge 25.6% 23/23 58.0
02 Scroll-speed spin badge 22.1% 23/23 79.1
03 Line that curls into a ring 39.9% 23/23 66.5
04 Cylinder band 33.8% 23/23 57.0
05 Two meshing rings 15.2% 23/23 58.9
06 Spiral lettering 28.3% 23/23 74.9
07 Letter progress ring 15.0% 23/23 67.9
08 Rippling ring 20.6% 23/23 71.3
09 Bakery seal hero 30.0% 23/23 79.2

03 covers the largest share of the canvas (39.9%) because one line sweeps from flat to a closed ring, and 07 (15.0%) and 05 (15.2%) the smallest because only one ring or two thin bands of letters change. No scene sat still: each one changed in 23 of the 23 intervals.

01A Round Badge That Splits Angles by Glyph Width

For a neighborhood coffee shop logo, "CORNER COFFEE" runs clockwise over the top and "OPEN AT 7" counterclockwise under the bottom so both read upright, while the radius springs from 100px to 58px at 0.95 s and back to 100px at 2.6 s (stiffness 70, damping 11). badgeAt calls arcScale once per arc, keeps the smaller factor so both arcs share one font size, and passes it to placeArc; fitArc in the fence below is defined in the file but not called anywhere. Two failing copies sit on the right: the upper one keeps the bottom arc clockwise and comes out upside down, and the lower one is an unshrunk ring at 0.44 times the current radius with coral dots where letters collide.

A Round Badge That Splits Angles by Glyph Width
Open in a new tab

01 · A Round Badge That Splits Angles by Glyph Width — Measured area 25.592% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

INNER_FITTOP_SPANRADIUS_NARROW
    // Font shrink factor: 1 if the text fits without shrinking
    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);
    }
    // Place with a given shrink factor; it receives the smaller factor of the two arcs so top and bottom share one font size
    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 };
    }

02A Spinning Badge That Speeds Up with Scroll Velocity

This is circular rotating text css for a portfolio landing screen, with the spin rate worked out in script: a simulated scroll speed sets a target of 0.35 rad/s plus 0.0024 times the speed, the current rate closes 8% of the gap every 1/60 s (SPIN_EASE), and the scroll speed decays as e^(−2.6t) (SCROLL_FRICTION). Across four scripted flicks at 0.25, 0.9, 1.4 and 2.9 s, the rate peaks at 306.4°/s at 1.625 s against a target peak of 530.5°/s, reaches 47% of the target 0.1 s after the first flick and 96% after 0.25 s, then coasts back to 20.1°/s at rest. The letters lean by the spin rate times 0.06, capped at 24.1°, and tilt 18.4° at the peak.

A Spinning Badge That Speeds Up with Scroll Velocity
Open in a new tab

02 · A Spinning Badge That Speeds Up with Scroll Velocity — Measured area 22.098% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

03A Line of Text That Curls into a Ring at Constant Length

For a logo entrance on a brand introduction page, a line springs from flat toward 331.2° (0.92 of a turn) from 0.2 s and uncurls at 1.9 s on a 3.2 s loop, and curlPoint recomputes the radius as length over curl angle so the letters keep their spacing. In the Korean copy the line is 392.3px long and closes to a 67.9px radius, the arc between the first and last glyph centers measures 374.3px straight, at a half turn and at 331°, and the curl overshoots to 410.3°, so the two ends overlap for a moment before the ring settles. Below STRAIGHT_EPS (0.001) the function returns a straight line rather than dividing by an angle that is nearly zero.

A Line of Text That Curls into a Ring at Constant Length
Open in a new tab

03 · A Line of Text That Curls into a Ring at Constant Length — Measured area 39.885% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CURL_STIFFNESSCURL_TARGETSTRAIGHT_EPS
    // Spot at arc length s: on a straight line (center + s, baseline), once curled the spot at angle α = s / R on a circle of radius 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; }

04A Band of Text Wrapped Around a Cylinder

For a rotating slogan on a product detail page, three rows of letters at heights −44, 0 and 44 circle a 150px-radius cylinder at 0.75, −0.55 and 0.95 rad/s, tilted between 12.6° and 26.4° and projected with a scale of 520 / (520 + z). Each glyph is drawn on a small frame built from the two screen vectors that 1px along the circumference and 1px along the axis map to, so a letter on the far side comes out mirrored, and back-side letters are drawn as two faint copies 0.8px apart behind a translucent band body. At 0 s the Korean copy has 51 of its 105 glyphs in front and 54 behind, all 54 back glyphs have a negative frame area (the mirror), the sort runs from far to near, and the scale ranges from 0.77 to 1.43.

A Band of Text Wrapped Around a Cylinder
Open in a new tab

04 · A Band of Text Wrapped Around a Cylinder — Measured area 33.824% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOCALTILTBACK_ALPHA
    // Every glyph in this frame, sorted with the deepest (farthest) first
    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);
                // Glyph x axis = where 1px along the cylinder's circumference lands on screen, y axis = where 1px along its axis lands
                const pa = project04(cylPoint(a + 1 / CYL_R, row[0], tilt));
                const py = project04(cylPoint(a, row[0] + 1, tilt));
                // z of the outward normal (cos a, 0, sin a) under the same tilt: above 0 it faces away from the screen (back side)
                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;
    }

05Two Touching Letter Rings That Turn Like Gears

For a craft workshop banner, both rings use 16px slots (TOOTH_PITCH), and each slot count is the phrase length times a repeat count chosen to land the circumference near the wanted radius; in the Korean copy that gives 40 and 20 slots, radii of 101.86px and 50.93px, and a center distance of 152.79px, the sum of the two. The edge speed springs up to 60px/s from 0.2 s, the small ring starts at π + π/20 so the large ring's slot edge meets the middle of a small-ring slot at the contact point, and 2.5 s later the phases read 0.719 and 0.219 of a slot, exactly half a slot apart, with both surfaces moving down at 60px/s where they touch. Two ghost copies trail each ring, spaced by the angle it turns in 1.5 frames (omega / 60 * 1.5), so they sit 1.5 and 3 frames back.

Two Touching Letter Rings That Turn Like Gears
Open in a new tab

05 · Two Touching Letter Rings That Turn Like Gears — Measured area 15.189% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

EDGE_SPEEDTOOTH_PITCHSMALL_PHASE
    // Slots = phrase length × repeats (circumference closest to the wanted radius), so the phrase wraps the ring exactly once or more.
    // The radius is then corrected to slots × TOOTH_PITCH / 2π (both rings need the same slot length for the teeth to mesh)
    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);
    // Centers of the large and small rings: center distance = R1 + R2 so the two pitch circles touch
    const BIG_CX = 186;
    const BIG_CY = 150;
    const SMALL_CX = BIG_CX + bigRing05.r + smallRing05.r;
    const SMALL_CY = BIG_CY;
    // Starting angle of the small ring: a slot middle, not a slot edge, sits at the contact point (angle π)
    const SMALL_PHASE = Math.PI + Math.PI / smallRing05.slots;

06Spiral Lettering Placed by Arc Length

For a lyric swirl on a music album page, the spiral r = 12 + bθ opens by 23px per turn (b = 23 / 2π) out to a 148px radius, 37.1 rad or 5.91 turns, and arcTable sums its length in 0.004 rad steps over 9,289 entries to 2,976.8px. Letters scale with the radius (0.115 times r, at least 5px, so 5 to 16.99px) and are remeasured at each size, the phrase flows outward at 46px/s with another letter inserted each time a gap of one glyph width opens, letters that reach the outer end run off the spiral, and the whole spiral turns at −0.22 rad/s; the Korean copy holds 350 letters on screen. Because each step uses the value at its middle, a 0.0002 rad step agrees to 0.000px and even a 0.5 rad step is off by only 0.002px, while dropping the b² term under the root comes out 4.6px short at 2,972.22px.

Spiral Lettering Placed by Arc Length
Open in a new tab

06 · Spiral Lettering Placed by Arc Length — Measured area 28.308% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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) {
            // Add the value at the middle of each step: a smaller error than the step-end value for the same step
            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 };
    }

07A Progress Ring Whose Letters Fill as the Upload Advances

For a photo upload indicator, a simulated speed of 8.5MB/s ± 55% (with a 7% chance of a quarter-speed dip, seed 7) fills a 24MB file, and the time left is the remaining size over an exponential average that takes in 6% of each new reading per 1/60 s. Each glyph owns the span from its cumulative width over the total width, times 2π, measured clockwise from the top; cover07 only decides which glyphs get any fill, and the filled copies are drawn inside a wedge clipped from the ring's center between the top (−π/2) and the progress angle Θ, so the boundary between filled and empty runs along the radius, the letter at the boundary comes out partly colored, and a glyph that has just filled swells by up to 16% over the next 0.25 rad. On the scene clock the upload ends at 3.1 s, and since it starts at 0.2 s (START_S) it takes about 2.9 s; the raw speed swings from 1.01 to 13.16MB/s while the average stays between 1.1 and 8.72MB/s, so the time-left number moves 8.4 per second on average instead of the 232.7 it would move on the raw reading.

A Progress Ring Whose Letters Fill as the Upload Advances
Open in a new tab

07 · A Progress Ring Whose Letters Fill as the Upload Advances — Measured area 14.951% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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; }
    // Rewrite the status line only when the 10% step changes; time left is frozen at that moment so screen readers do not read a number every frame
    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())));
    }

08A Ring That Ripples with Sound Level, Letters Following Its Normal

For the label around a voice memo record button, the radius is 90px plus three sine waves (harmonics 3, 5 and 8, amplitudes 11, 6 and 3.5px) scaled by a simulated level, and each frame rebuilds a 0.01 rad arc-length table and stretches the repeated phrase's widths to the current perimeter before thetaAt finds each letter's θ. The level follows scripted beats about 0.4 s apart (±25%, strength 0.45 to 1, seed 11), rising at rate 30 and falling at rate 4 with no audio API involved, and each letter turns to the curve's own tangent from tangentAngle, up to 30.8° away from the tangent of a plain circle. Over 4 s there were 10 beats and a peak level of 67%, and the perimeter swung from 565.5px (2π × 90 at rest) to 586.1px while the letter spacing stayed between 16.63 and 17.24px.

A Ring That Ripples with Sound Level, Letters Following Its Normal
Open in a new tab

08 · A Ring That Ripples with Sound Level, Letters Following Its Normal — Measured area 20.627% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RIPPLE_AMPSRIPPLE_HARMONICSTABLE_STEP
    // Glyph spots: stretch or shrink the repeated phrase's width sum to the current perimeter (spacing redistributed), then look θ up in the table
    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 };
    }

09A Bakery Hero with a Stamped Seal

For a bakery landing page, "CORNER BAKERY · BAKED THIS MORNING ·" curls from a 2.6 rad arch into the 72px seal ring from 0.2 s the way 03 curls its line, its letters first sit at spots measured with a monospace fallback, and at 0.75 s, the moment the scene treats as the font arriving, each one springs to its remeasured spot (stiffness 180, damping 14). At 1.15 s a height spring (260, 13) drops the seal from 1 to 0 at a scale of 1 + 0.22 × height, the imprint appears at 1.275 s, the moment the height falls below 0.05, before the seal presses on down to −0.245 (squashed by 5.4%) and stays within ±0.02 from 0.512 s after contact. At 1.75 s it lifts to 0.35 (1.077 times its size) and turns at 28.6°/s while the touching "FRESH ·" ring turns the other way at 68.7°/s so the speeds match where they touch, as in 05, and the heading's font shrinks once its width would pass 176px (HEAD_MAX_W).

A Bakery Hero with a Stamped Seal
Open in a new tab

09 · A Bakery Hero with a Stamped Seal — Measured area 30.042% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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 };
    }
    // Small ring spin = seal spin × seal outer radius (including the lift scale) / small ring outer radius
    function sealScale09() { return 1 + STAMP_LIFT_SCALE * stampZ09.x; }
    function badgeOmega09() { return SEAL_SPIN * SEAL_OUTER * sealScale09() / BADGE_OUTER; }

What Does Curved Text CSS Actually Have to Calculate?

Lettering on a live page comes down to three jobs: fit a phrase of unknown length into a fixed arc, turn it at a rate tied to something real, and keep the words readable for people who do not see the ring.

A coffee shop badge and a bakery seal

A shop owner changes the name, and the badge has to cope without anyone adjusting angles by hand; that is the job of arcScale in 01, and 09 adds the other half of the problem, the font that turns up after the first layout. If you came here for bend text css you can paste, the lettering in these files bends on a canvas rather than in a stylesheet, and the card around it is plain SCSS you can restyle without touching the math.

An upload ring and a ring that answers sound

07 and 08 tie letters to a number that changes many times a second. 07 smooths the upload speed before it reaches the time-left label, and 08 spreads its letters evenly again on every frame because the perimeter grows and shrinks with the level; both inputs come from seeded random numbers, so the demos run without a file or a microphone.

How this differs from neighboring text effects

A ribbon of letters sliding sideways along a curve and turning with its slope is scene 09 in Horizontal Scroll Animation CSS: 9 Inertia Scenes, and letters that dodge a pointer and spring back are scene 04 in Falling Text Animation: 9 Letters With Physics. Dropping words one by one into free spots along a spiral is scene 02 in Word Cloud JavaScript: 9 Scenes, Spiral to 3D. This post stays with closed rings, where a phrase has to share out an exact circumference.

Where Circular Text Breaks: Upside-Down Letters on the Lower Arc, Widths Measured Before the Web Font Arrives and Bold Letters Colliding on a Tight Ring

Three failures came up while building these rings, and each failing version is drawn somewhere in the demos.

Letters standing on their heads along the lower arc

If the bottom phrase keeps running clockwise like the top one (direction +1, rotation θ + π/2), the glyph at the bottom center of the Korean copy rotates to 196.4° and reads upside down; running it counterclockwise (direction −1, rotation θ − π/2) gives −16.4°, exactly 180° apart. The fix lives in one call, placeArc(bottomWidths, BADGE_PX, TRACKING, r, INNER_FIT, s, Math.PI / 2, -1), plus the −π/2 in the glyph rotation, and the upper-right copy in 01 shows the flipped version next to the correct badge. To try the clockwise form yourself (1 and +π/2 in place of -1 and −π/2) and watch the bottom arc turn over at full badge size, type b68yeu36 when the zip asks for its password, then edit index.html in folder 01.

A seal phrase pushed out of place by a late font

For its first 0.75 s, 09 places letters with widths taken from a monospace fallback. At 100px the Korean glyphs measure the same in both fonts, but a space comes to 55.0 instead of 35.2 and a middle dot 55.0 instead of 26.2, and Latin letters swing harder: W 55.0 instead of 101.0, i 55.0 instead of 27.4. The English seal phrase measures 325.5px in the fallback against 341.6px in the device font (4.7% narrower), which moves letters on the ring by up to 11.7°; the Korean phrase is 15.1% wider in the fallback (239.2px against 207.7px) and moves 11.2°, about 14.1px along the ring, and the Japanese one moves 4.6°. Measured the same way, the 01 top phrase would move 17.4° in English, 7.3° in Korean and 0° in Japanese.

The demos load no external web font; the canvas draws with a device font list starting with system-ui and ending in sans-serif, and the guard below still runs in all nine scenes. Widths are measured only after document.fonts.ready resolves, the demo loop does not start before that, and a later loadingdone event calls remeasure() and draw() again.

    // Measure glyph widths after the fonts have arrived: widths measured earlier belong to the fallback font, so spots shift the moment the font swaps.
    // If another font arrives later (loadingdone), measure again
    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();
        }
    });

Each width comes from measureText, one glyph at a time at 100px, kept in a cache and scaled to the size in use, and remeasure() empties that cache before every item lays itself out again.

Bold letters colliding on a tight ring

Fit the Korean badge (weight 800) at the glyph-center radius without shrinking, and at 100px 12 of the 14 neighboring pairs on the top arc overlap at their lower edges by up to 0.65px; at 58px the overlap reaches 2.21px and the two phrases together need 410.9°, more than a full turn, so top and bottom run into each other. Fitting at the INNER_FIT 0.5 radius and shrinking with arcScale brings the 58px ring to 54.4% (10.89px) with a 0.82px clearance at the tightest pair. The English badge overlaps on 12 of its 12 pairs by up to 1.26px at 58px until the same shrink takes it to 71.8%, and the lower-right copy in 01 draws the unshrunk ring with a coral dot at each collision.

A Snappier Spin, Faster Rings, a Stiffer Curl — One Constant That Reshapes the Lettering

Each row changes one constant, and the numbers come from calling the same functions again with the new value; the 05 row uses the ring sizes of the Korean copy.

Name Changed value What changes
A snappier spin 02 SPIN_EASE 0.08 → 0.2 Gap half-life 138.5 → 51.8ms, 47 → 85% of the target 0.1 s after the first flick, peak 306.4 → 379.0°/s; the time to fall back within 1.1 times the idle rate after the last flick only drops 1.90 → 1.78 s, since SCROLL_FRICTION sets that tail
Faster rings 05 EDGE_SPEED 60 → 140 Large ring 33.8 → 78.8°/s, small ring 67.5 → 157.5°/s, ratio still 2.000; one turn 10.67 and 5.33 s → 4.57 and 2.29 s; a letter at the contact point moves 1 → 2.33px per frame, so the trail gets longer and darker
A stiffer curl 03 CURL_STIFFNESS 120 → 320 First reach of 331.2° 0.196 → 0.100 s, overshoot 79.1 → 145.2°, target crossings 5 → 9, settled within ±2° from 1.054 → 1.108 s; damping stays at 9, so the damping ratio falls 0.411 → 0.252; to keep the ratio, raise damping by √(320 ÷ 120) ≈ 1.63 times, to about 14.7

The stiffer curl is the one that surprises: the ring closes in half the time, yet it comes to rest later, because the same damping now has to calm a spring that stores more energy.

Readable Circular Text: One Plain Line for Screen Readers, a Pause Button and Reduced Motion for Accessibility

One plain line for screen readers

The canvas carries aria-hidden="true" in all nine scenes, so the ring is not announced letter by letter, and a visually hidden <p class="cf__sr"> (1px, clip-path: inset(50%)) holds the same phrase once as ordinary text. The status line starts as aria-live="off" and switches to polite after the first input, and 07 rewrites it only when progress crosses a new 10% step, 11 times over a run, so a screen reader hears a sentence in the form "{p}% uploaded · about {s} s left" instead of a number per frame.

A pause button and reduced motion

Each scene has a real <button type="button" aria-pressed="false"> for pausing, with a 3px :focus-visible outline; Enter and Space work through the button's default behavior, there are no keydown handlers, and pressing it sets aria-pressed to true. Under reduced motion showRest() stops the loop and draws the final layout from restPose() with every letter in place: captures at 0.4 s and 1.2 s differed by 0 pixels in 9 of 9 scenes, and clearing the setting starts them again. 02 is the only scene with input, and its wheel listener is passive and does not call preventDefault, so the page keeps scrolling; with motion reduced, a wheel turn rotates the badge by that amount and redraws once. Every scene also stops while the tab is hidden and resumes when it comes back.

Frequently Asked Questions About Building Circular Text for the Web

Can SVG textPath replace the canvas for arc text css

Yes, for a badge that stays put. SVG textPath flows letters along a circular path by their lengths, which covers a still badge like 01. Scenes that need each letter's spot and angle on every frame, such as the curl in 03, the half-filled letters in 07 or the meshing in 05, are easier to handle with the direct calculation used here.

Which lines do I change to put my own shop name on the 01 badge

Change TOP_TEXT and BOTTOM_TEXT in folder 01; in the English files they read "CORNER COFFEE" and "OPEN AT 7". If a longer name needs more than the 2.5 rad of TOP_SPAN, arcScale shrinks the font to fit, and the badge takes the smaller factor of the two arcs so top and bottom keep one size.

Do letters get too small when the phrase is long

On the 58px ring the 20px font drops to 14.35px in English and 10.89px in the 15-character Korean copy. Much below that, shorten the phrase or give it more length the way 06 does: a spiral holds 350 letters of the Korean copy on one screen.

Enter the archive password

The password is inside this article. You will find it as you read.