02使った文字数で満ちていくスプリングリング
使用率をリングの弧の長さに変え、スプリング(剛性220・減衰14)で追わせる。80%からミントがアンバーへ、上限でピンクへ色が連続して変わり、上限を超えた瞬間に減衰サインで揺れる。
使いどころ上限が一目でわかるリング
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">メモ</label>
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="書いてみてください"></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 = ['今夜', '友達と', '新しくできた', '食堂に行って', '温かいスープと', 'サクサクの揚げ物を', '分け合うことにした', 'けれど', '席が足りなくて', 'しばらく', '待たなければならず', 'お腹が空いて', 'メニューばかり', 'ずっと', 'めくっていた', 'それでも', '味は', '期待以上だった', 'レビューを', '残そうと', '写真も', '撮っておいた', '次は', 'もっと早く', '来よう'];
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 ? '残り ' + remain + '文字' : '超過 ' + (-remain) + '文字';
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 ? '上限を超えました' : (next === 1 ? '上限が近いです' : '');
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 使った文字数で満ちていくスプリングリング — 計測 累積面積 30.287% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · テキストエリア 文字数カウント 9パターン — 書記素で数えて上限をリングで見せる
このデモが属する記事
テキストエリア 文字数カウント 9パターン — 書記素で数えて上限をリングで見せる文字数はlength一行で済みそうだが、絵文字が2と数えられ、変換中は数字がちらつく — 01番と04番がその二か所を一つずつ塞ぐ。テキストエリア 文字数カウンターは、入力が変わるたびに人が見る一文字ぶんを数え、残りの上限を知らせる数字です この記事のデモをすべて見る


