02Spring Ring That Fills with the Characters Used
The used ratio becomes the arc length of a ring that follows it with a spring (stiffness 220, damping 14); from 80% the color blends from mint to amber and at the limit to pink, and the ring sways with a damped sine the moment it goes over.
Best forA ring that shows the limit at a glance
CHAR_LIMITRING_SPRING_STIFFNESSRING_SPRING_DAMPINGWARN_RATIOSHAKE_AMP

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<div class="cc__body" id="ccBody">
<div class="cc__col">
<label class="cc__label" for="ccText">Note</label>
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="Write something"></textarea>
</div>
<div class="cc__ring" id="ccRing">
<svg viewBox="0 0 88 88" aria-hidden="true">
<circle class="cc__track" cx="44" cy="44" r="36"></circle>
<circle class="cc__arc" id="ccArc" cx="44" cy="44" r="36" transform="rotate(-90 44 44)"></circle>
</svg>
<div class="cc__ringNum" id="ccCount">120</div>
</div>
</div>
<p class="cc__hint" id="ccHint"></p>
<p class="cc__sr" id="ccSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 호 길이는 CSS 가 아니라 JS 스프링이 매 프레임 계산한다. 색은 JS 가 선형 보간한다.
@include stage($stage-ink);
.cc {
width: min(100%, 420px);
display: grid;
gap: $sp-2;
color: $stage-paper;
}
.cc__body {
display: grid;
grid-template-columns: 1fr 88px;
gap: $sp-3;
align-items: center;
padding: $sp-2 $sp-3;
border-radius: $r-card;
background: rgba($subject-mint, .16);
}
.cc__col { display: grid; gap: $sp-2; min-width: 0; }
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: rgba($stage-paper, .82);
}
.cc__ta {
box-sizing: border-box;
width: 100%;
height: 64px;
resize: none;
padding: $sp-2 $sp-3;
border: 1px solid rgba($stage-paper, .28);
border-radius: $r-sm;
background: rgba($stage-paper, .08);
color: $stage-paper;
font-family: inherit;
font-size: 13px;
line-height: 18px;
}
.cc__ta::placeholder { color: rgba($stage-paper, .7); }
.cc__ta:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.cc__ring { position: relative; width: 88px; height: 88px; }
.cc__ring svg { display: block; width: 88px; height: 88px; }
.cc__track { fill: none; stroke: rgba($stage-paper, .12); stroke-width: 8; }
.cc__arc {
fill: none;
stroke: $subject-mint;
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 0 226;
}
.cc__ringNum {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 22px;
line-height: 24px;
font-weight: 700;
color: $stage-paper;
font-variant-numeric: tabular-nums;
}
.cc__hint { margin: 0; font-size: 12px; line-height: 16px; color: rgba($stage-paper, .82); }
.cc__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.cc__body { grid-template-columns: 1fr 72px; }
.cc__ring, .cc__ring svg { width: 72px; height: 72px; }
.cc__ringNum { font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__ring { transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cc {
width: min(100%, 420px);
display: grid;
gap: 8px;
color: #ffffff;
}
.cc__body {
display: grid;
grid-template-columns: 1fr 88px;
gap: 12px;
align-items: center;
padding: 8px 12px;
border-radius: 16px;
background: rgba(76, 212, 166, 0.16);
}
.cc__col {
display: grid;
gap: 8px;
min-width: 0;
}
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: rgba(255, 255, 255, 0.82);
}
.cc__ta {
box-sizing: border-box;
width: 100%;
height: 64px;
resize: none;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-family: inherit;
font-size: 13px;
line-height: 18px;
}
.cc__ta::placeholder {
color: rgba(255, 255, 255, 0.7);
}
.cc__ta:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.cc__ring {
position: relative;
width: 88px;
height: 88px;
}
.cc__ring svg {
display: block;
width: 88px;
height: 88px;
}
.cc__track {
fill: none;
stroke: rgba(255, 255, 255, 0.12);
stroke-width: 8;
}
.cc__arc {
fill: none;
stroke: #4cd4a6;
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 0 226;
}
.cc__ringNum {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 22px;
line-height: 24px;
font-weight: 700;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.cc__hint {
margin: 0;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.82);
}
.cc__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.cc__body {
grid-template-columns: 1fr 72px;
}
.cc__ring, .cc__ring svg {
width: 72px;
height: 72px;
}
.cc__ringNum {
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__ring {
transform: none;
}
}(function () {
// 기법: 사용 비율(글자수 / 한도)을 링의 호 길이로 바꾸고, 호 길이를 스프링(강성·감쇠)으로 따라가게 한다.
// 비율이 WARN_RATIO 를 넘으면 민트에서 앰버로, 한도를 넘으면 핑크로 색을 선형 보간하고
// 한도를 처음 넘는 순간 감쇠 사인으로 링이 좌우로 흔들린다 (난수 없이 시각만으로 계산).
var CHAR_LIMIT = 120;
var RING_SPRING_STIFFNESS = 220;
var RING_SPRING_DAMPING = 14;
var WARN_RATIO = 0.8;
var SHAKE_AMP = 6;
var SHAKE_DECAY_MS = 260;
var SHAKE_OMEGA = 0.06;
var START_MS = 200;
var CHUNK_MS = 130;
var HOLD_MS = 1500;
var RING_LENGTH = 2 * Math.PI * 36;
var CHUNKS = ['Tonight', 'with pals', 'at the new', 'diner,', 'warm soup', 'and fries', 'to share.', 'But', 'seats ran out', 'so a long', 'wait began,', 'we got hungry', 'and just', 'kept', 'reading the menu.', 'Still,', 'the food', 'beat hopes.', 'A review', 'to write,', 'photos', 'were taken.', 'Next time', 'come earlier,', 'for sure.'];
var DEMO_TEXT = CHUNKS.join(' ');
var MINT = [76, 212, 166];
var AMBER = [250, 218, 140];
var PINK = [255, 158, 187];
var root = document.getElementById('ccRoot');
var ta = document.getElementById('ccText');
var arc = document.getElementById('ccArc');
var ring = document.getElementById('ccRing');
var card = document.getElementById('ccBody');
var countEl = document.getElementById('ccCount');
var hint = document.getElementById('ccHint');
var sr = document.getElementById('ccSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var seg = (typeof Intl !== 'undefined' && Intl.Segmenter) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
function countGraphemes(s) {
if (!seg) return Array.from(s).length;
return Array.from(seg.segment(s)).length;
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function mix(a, b, t) {
return 'rgb(' + Math.round(a[0] + (b[0] - a[0]) * t) + ',' + Math.round(a[1] + (b[1] - a[1]) * t) + ',' + Math.round(a[2] + (b[2] - a[2]) * t) + ')';
}
function colorAt(ratio) {
var t1 = clamp((ratio - WARN_RATIO) / (1 - WARN_RATIO), 0, 1);
var t2 = clamp((ratio - 1) / 0.1, 0, 1);
var warm = [MINT[0] + (AMBER[0] - MINT[0]) * t1, MINT[1] + (AMBER[1] - MINT[1]) * t1, MINT[2] + (AMBER[2] - MINT[2]) * t1];
return mix(warm, PINK, t2);
}
function tintAt(ratio, alpha) {
var t1 = clamp((ratio - WARN_RATIO) / (1 - WARN_RATIO), 0, 1);
var t2 = clamp((ratio - 1) / 0.1, 0, 1);
var c = [0, 1, 2].map(function (i) {
var warm = MINT[i] + (AMBER[i] - MINT[i]) * t1;
return Math.round(warm + (PINK[i] - warm) * t2);
});
return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + alpha + ')';
}
var used = 0;
var x = 0;
var v = 0;
var stage = 0;
var shakeAt = -1;
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function paint(nowMs) {
var ratio = used / CHAR_LIMIT;
var drawn = clamp(x, 0, 1);
arc.style.strokeDasharray = (drawn * RING_LENGTH).toFixed(2) + ' ' + RING_LENGTH.toFixed(2);
arc.style.stroke = colorAt(Math.min(x, 1.12));
card.style.background = tintAt(Math.min(x, 1.12), 0.16);
var remain = CHAR_LIMIT - used;
countEl.textContent = String(remain);
countEl.style.color = remain < 0 ? 'rgb(255,158,187)' : '';
hint.textContent = remain >= 0 ? 'Left: ' + remain + ' chars' : 'Over by ' + (-remain) + ' chars';
var pulse = 1 + Math.max(0, x - 1) * 0.5;
var dx = 0;
if (shakeAt >= 0 && !reduce) {
var age = nowMs - shakeAt;
var amp = SHAKE_AMP * Math.exp(-age / SHAKE_DECAY_MS);
dx = amp < 0.1 ? 0 : amp * Math.sin(age * SHAKE_OMEGA);
}
ring.style.transform = 'translateX(' + dx.toFixed(2) + 'px) scale(' + pulse.toFixed(3) + ')';
return ratio;
}
function update(nowMs) {
used = countGraphemes(ta.value);
var ratio = used / CHAR_LIMIT;
var next = ratio >= 1 ? 2 : (ratio >= WARN_RATIO ? 1 : 0);
if (next !== stage) {
if (next === 2 && !reduce) shakeAt = nowMs;
sr.textContent = next === 2 ? 'Over the limit' : (next === 1 ? 'Close to the limit' : '');
stage = next;
}
if (reduce) { x = Math.min(ratio, 1); v = 0; paint(nowMs); }
}
function stopDemo() { root.classList.remove('is-demo'); }
ta.addEventListener('focus', stopDemo);
ta.addEventListener('keydown', stopDemo);
ta.addEventListener('paste', stopDemo);
ta.addEventListener('input', function () { stopDemo(); update(perfNow()); });
var lastDemoN = -1;
var lastTs = null;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (root.classList.contains('is-demo')) {
var cyc = (now - START_MS) % (CHUNKS.length * CHUNK_MS + HOLD_MS);
var n = now < START_MS ? 0 : Math.min(CHUNKS.length, Math.floor(cyc / CHUNK_MS) + 1);
if (n !== lastDemoN) {
if (n === 1) shakeAt = -1;
lastDemoN = n;
ta.value = CHUNKS.slice(0, n).join(' ');
update(now);
}
}
var target = used / CHAR_LIMIT;
var steps = Math.max(1, Math.ceil(dt / 0.008));
var h = dt / steps;
for (var s = 0; s < steps; s += 1) {
var a = -RING_SPRING_STIFFNESS * (x - target) - RING_SPRING_DAMPING * v;
v += a * h;
x += v * h;
}
paint(now);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = DEMO_TEXT;
update(0);
} else {
requestAnimationFrame(tick);
}
})();// SpringLimitRing — vanilla 판(02-limit-ring-spring-fill-warning-stages) 의 React 포트.
// 사용 비율을 링 호 길이로 바꾸고 스프링으로 따라가게 한다. 색은 선형 보간, 한도를 넘으면 감쇠 사인으로 흔들린다.
import { useEffect, useRef } from "react";
import styles from "./SpringLimitRing.module.scss";
type Box<T> = { current: T };
type SegmenterLike = { segment(s: string): Iterable<{ segment: string }> };
type SegmenterCtor = new (locale: string | undefined, options: { granularity: string }) => SegmenterLike;
type Rgb = [number, number, number];
const CHAR_LIMIT = 120;
const RING_SPRING_STIFFNESS = 220;
const RING_SPRING_DAMPING = 14;
const WARN_RATIO = 0.8;
const SHAKE_AMP = 6;
const SHAKE_DECAY_MS = 260;
const SHAKE_OMEGA = 0.06;
const START_MS = 200;
const CHUNK_MS = 130;
const HOLD_MS = 1500;
const RING_LENGTH = 2 * Math.PI * 36;
const CHUNKS = ["오늘 저녁에", "친구들과", "새로 생긴", "식당에 가서", "따뜻한 국물과", "바삭한 튀김을", "나눠 먹기로", "했다 그런데", "자리가 모자라", "한참을", "기다려야 했고", "배가 고파서", "메뉴판만", "계속", "뒤적였다", "그래도", "맛은", "기대 이상이었다", "후기를", "남기려고", "사진도", "찍어 두었다", "다음엔", "더 일찍", "와야겠다"];
const DEMO_TEXT = CHUNKS.join(" ");
const MINT: Rgb = [76, 212, 166];
const AMBER: Rgb = [250, 218, 140];
const PINK: Rgb = [255, 158, 187];
function clamp(v: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, v)); }
function mix(a: Rgb, b: Rgb, t: number): Rgb {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function css(c: Rgb): string { return "rgb(" + Math.round(c[0]) + "," + Math.round(c[1]) + "," + Math.round(c[2]) + ")"; }
function colorAt(ratio: number): string {
const t1 = clamp((ratio - WARN_RATIO) / (1 - WARN_RATIO), 0, 1);
const t2 = clamp((ratio - 1) / 0.1, 0, 1);
return css(mix(mix(MINT, AMBER, t1), PINK, t2));
}
function tintAt(ratio: number, alpha: number): string {
const t1 = clamp((ratio - WARN_RATIO) / (1 - WARN_RATIO), 0, 1);
const t2 = clamp((ratio - 1) / 0.1, 0, 1);
const c = mix(mix(MINT, AMBER, t1), PINK, t2);
return "rgba(" + Math.round(c[0]) + "," + Math.round(c[1]) + "," + Math.round(c[2]) + "," + alpha + ")";
}
function makeSegmenter(): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity: "grapheme" }) : null;
}
export default function SpringLimitRing() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const arcRef = useRef<SVGCircleElement | null>(null) as Box<SVGCircleElement | null>;
const ringRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const cardRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const countRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const hintRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const arc = arcRef.current;
const ring = ringRef.current;
const card = cardRef.current;
const countEl = countRef.current;
const hint = hintRef.current;
const sr = srRef.current;
if (!ta || !arc || !ring || !card || !countEl || !hint || !sr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const seg = makeSegmenter();
const countGraphemes = (s: string): number =>
seg ? Array.from(seg.segment(s)).length : Array.from(s).length;
let demo = true;
let used = 0;
let x = 0;
let v = 0;
let stage = 0;
let shakeAt = -1;
let t0: number | null = null;
let lastTs: number | null = null;
let lastDemoN = -1;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function paint(nowMs: number): void {
if (!arc || !ring || !card || !countEl || !hint) return;
const drawn = clamp(x, 0, 1);
arc.style.strokeDasharray = (drawn * RING_LENGTH).toFixed(2) + " " + RING_LENGTH.toFixed(2);
arc.style.stroke = colorAt(Math.min(x, 1.12));
card.style.background = tintAt(Math.min(x, 1.12), 0.16);
const remain = CHAR_LIMIT - used;
countEl.textContent = String(remain);
countEl.style.color = remain < 0 ? "rgb(255,158,187)" : "";
hint.textContent = remain >= 0 ? "남은 글자 " + remain + "자" : "넘친 글자 " + (-remain) + "자";
const pulse = 1 + Math.max(0, x - 1) * 0.5;
let dx = 0;
if (shakeAt >= 0 && !reduce) {
const age = nowMs - shakeAt;
const amp = SHAKE_AMP * Math.exp(-age / SHAKE_DECAY_MS);
dx = amp < 0.1 ? 0 : amp * Math.sin(age * SHAKE_OMEGA);
}
ring.style.transform = "translateX(" + dx.toFixed(2) + "px) scale(" + pulse.toFixed(3) + ")";
}
function update(nowMs: number): void {
if (!ta || !sr) return;
used = countGraphemes(ta.value);
const ratio = used / CHAR_LIMIT;
const next = ratio >= 1 ? 2 : (ratio >= WARN_RATIO ? 1 : 0);
if (next !== stage) {
if (next === 2 && !reduce) shakeAt = nowMs;
sr.textContent = next === 2 ? "한도를 넘었어요" : (next === 1 ? "곧 한도예요" : "");
stage = next;
}
if (reduce) { x = Math.min(ratio, 1); v = 0; paint(nowMs); }
}
function stopDemo(): void { demo = false; }
const onInput = (): void => { stopDemo(); update(perfNow()); };
ta.addEventListener("focus", stopDemo);
ta.addEventListener("keydown", stopDemo);
ta.addEventListener("paste", stopDemo);
ta.addEventListener("input", onInput);
function tick(ts: number): void {
if (!ta) return;
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demo) {
const cyc = (now - START_MS) % (CHUNKS.length * CHUNK_MS + HOLD_MS);
const n = now < START_MS ? 0 : Math.min(CHUNKS.length, Math.floor(cyc / CHUNK_MS) + 1);
if (n !== lastDemoN) {
if (n === 1) shakeAt = -1;
lastDemoN = n;
ta.value = CHUNKS.slice(0, n).join(" ");
update(now);
}
}
const target = used / CHAR_LIMIT;
const steps = Math.max(1, Math.ceil(dt / 0.008));
const h = dt / steps;
for (let s = 0; s < steps; s += 1) {
const a = -RING_SPRING_STIFFNESS * (x - target) - RING_SPRING_DAMPING * v;
v += a * h;
x += v * h;
}
paint(now);
raf = requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = DEMO_TEXT;
update(0);
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
ta.removeEventListener("focus", stopDemo);
ta.removeEventListener("keydown", stopDemo);
ta.removeEventListener("paste", stopDemo);
ta.removeEventListener("input", onInput);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cc}>
<div className={styles.cc__body} ref={cardRef}>
<div className={styles.cc__col}>
<label className={styles.cc__label} htmlFor="ccText">메모</label>
<textarea className={styles.cc__ta} id="ccText" ref={taRef} aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
</div>
<div className={styles.cc__ring} ref={ringRef}>
<svg viewBox="0 0 88 88" aria-hidden="true">
<circle className={styles.cc__track} cx="44" cy="44" r="36"></circle>
<circle className={styles.cc__arc} ref={arcRef} cx="44" cy="44" r="36" transform="rotate(-90 44 44)"></circle>
</svg>
<div className={styles.cc__ringNum} id="ccCount" ref={countRef}>120</div>
</div>
</div>
<p className={styles.cc__hint} ref={hintRef}></p>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}02 Spring Ring That Fills with the Characters Used — Measured area 30.287% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Textarea Character Counter: 9 Grapheme-Aware UIs
The post this demo lives in
Textarea Character Counter: 9 Grapheme-Aware UIsA character count looks like one line of length, until one emoji counts as 2 and the number flickers mid-composition — s See all demos in this post
If you like this effect - similar demos
Inputs01 Step Connector That Fills With a Spring OvershootStep Indicator UI: 9 Physics-Based Patterns
Inputs06 A Field That Shakes on a Damped Sine on Error While the Label Changes to a Warning TintCSS Floating Label: 9 Inputs on Spring Physics
Inputs06 Character counter that turns warning near the limit9 Form Validation UI Patterns — As You Type