08Remaining-Count Hint with Threshold Stages and Color Interpolation
Color is blended linearly across the comfort, caution and danger boundaries; the fewer characters remain, the more smoothly the number grows from 1x to 1.8x, and the hint text changes only when the stage changes.
Best forInputs with a strict length limit
HINT_LIMITWARN_RATIOMAX_SCALEOVER_SPANMSG_FADE_MS

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<label class="cc__label" for="ccText">Note</label>
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="Write something"></textarea>
<div class="cc__hintRow" id="ccCount">
<span class="cc__remain" id="ccRemain">100</span>
<span class="cc__msg" id="ccMsg">Plenty of room</span>
</div>
<div class="cc__meter" aria-hidden="true"><i class="cc__fill" id="ccFill"></i></div>
<p class="cc__sr" id="ccSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 색·숫자 크기·막대 길이가 모두 같은 보간 비율 하나에서 나온다. 문구만 구간 경계에서 바뀐다.
@include stage($stage-ink);
.cc {
width: min(100%, 420px);
display: grid;
gap: $sp-2;
color: $stage-paper;
}
.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: 56px;
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__hintRow {
display: flex;
align-items: center;
gap: $sp-4;
height: 48px;
padding: 0 $sp-3;
overflow: hidden;
border-radius: $r-card;
background: rgba($subject-mint, .18);
}
.cc__remain {
display: block;
min-width: 76px;
font-size: 22px;
line-height: 28px;
font-weight: 700;
color: $subject-mint;
font-variant-numeric: tabular-nums;
transform-origin: 0 50%;
}
.cc__msg { font-size: 13px; line-height: 20px; font-weight: 600; color: $subject-mint; }
.cc__meter {
height: 6px;
overflow: hidden;
border-radius: $r-pill;
background: rgba($stage-paper, .12);
}
.cc__fill {
display: block;
height: 100%;
border-radius: $r-pill;
background: $subject-mint;
transform: scaleX(0);
transform-origin: 0 50%;
}
.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__hintRow { gap: $sp-3; height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__fill { transition: 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__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: 56px;
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__hintRow {
display: flex;
align-items: center;
gap: 16px;
height: 48px;
padding: 0 12px;
overflow: hidden;
border-radius: 16px;
background: rgba(76, 212, 166, 0.18);
}
.cc__remain {
display: block;
min-width: 76px;
font-size: 22px;
line-height: 28px;
font-weight: 700;
color: #4cd4a6;
font-variant-numeric: tabular-nums;
transform-origin: 0 50%;
}
.cc__msg {
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: #4cd4a6;
}
.cc__meter {
height: 6px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.cc__fill {
display: block;
height: 100%;
border-radius: 999px;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: 0 50%;
}
.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__hintRow {
gap: 12px;
height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__fill {
transition: none;
}
}(function () {
// 기법: 사용 비율이 WARN_RATIO 에 닿기 전까지는 민트, WARN_RATIO 부터 한도까지는 민트에서 앰버로,
// 한도를 넘으면 앰버에서 핑크로 색을 선형 보간한다. Left: 숫자는 비율에 따라 1배에서 MAX_SCALE 배까지 커진다.
// 구간이 바뀔 때만 안내 문구를 바꾸고(스크린 리더에도 그때만 알린다) 문구는 짧게 페이드인한다.
var HINT_LIMIT = 100;
var WARN_RATIO = 0.8;
var MAX_SCALE = 1.8;
var OVER_SPAN = 0.15;
var RATIO_LERP_RATE = 10;
var MSG_FADE_MS = 220;
var START_MS = 200;
var CHUNK_MS = 130;
var HOLD_MS = 1500;
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 rowEl = document.getElementById('ccCount');
var remainEl = document.getElementById('ccRemain');
var msgEl = document.getElementById('ccMsg');
var fill = document.getElementById('ccFill');
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 lerp(a, b, t) { return a + (b - a) * t; }
function mix(a, b, t) {
return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
}
function css(c) { return 'rgb(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ')'; }
function tint(c, alpha) { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + alpha + ')'; }
function rgbAt(ratio) {
var t1 = clamp((ratio - WARN_RATIO) / (1 - WARN_RATIO), 0, 1);
var t2 = clamp((ratio - 1) / OVER_SPAN, 0, 1);
return mix(mix(MINT, AMBER, t1), PINK, t2);
}
function smooth(t) { t = clamp(t, 0, 1); return t * t * (3 - 2 * t); }
var used = 0;
var shownUsed = 0;
var stage = 0;
var swapAt = -1;
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function paint(nowMs) {
var ratio = shownUsed / HINT_LIMIT;
var rgb = rgbAt(ratio);
var color = css(rgb);
rowEl.style.background = tint(rgb, 0.18);
var scale = lerp(1, MAX_SCALE, smooth((ratio - 0.5) / 0.6));
remainEl.textContent = String(Math.round(HINT_LIMIT - shownUsed));
remainEl.style.color = color;
remainEl.style.transform = 'scale(' + scale.toFixed(3) + ')';
msgEl.style.color = color;
fill.style.background = color;
fill.style.transform = 'scaleX(' + clamp(ratio, 0, 1).toFixed(4) + ')';
var a = swapAt < 0 || reduce ? 1 : clamp((nowMs - swapAt) / MSG_FADE_MS, 0, 1);
msgEl.style.opacity = a.toFixed(3);
if (a >= 1) swapAt = -1;
}
function update(nowMs) {
used = countGraphemes(ta.value);
var ratio = used / HINT_LIMIT;
var next = ratio >= 1 ? 2 : (ratio >= WARN_RATIO ? 1 : 0);
if (next !== stage) {
stage = next;
var text = next === 2 ? 'Over the limit' : (next === 1 ? 'Close to the limit' : 'Plenty of room');
msgEl.textContent = text;
sr.textContent = next === 0 ? '' : text;
swapAt = nowMs;
}
if (reduce) { shownUsed = used; 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 lastN = -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 !== lastN) {
lastN = n;
ta.value = CHUNKS.slice(0, n).join(' ');
update(now);
}
}
shownUsed += (used - shownUsed) * (1 - Math.exp(-dt * RATIO_LERP_RATE));
paint(now);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = DEMO_TEXT;
update(0);
} else {
requestAnimationFrame(tick);
}
})();// ThresholdHintColor — vanilla 판(08-remaining-hint-threshold-color-interpolation) 의 React 포트.
// 사용 비율에 따라 색을 선형 보간하고 남은 글자 숫자를 키우며, 구간이 바뀔 때만 안내 문구를 바꾼다.
import { useEffect, useRef } from "react";
import styles from "./ThresholdHintColor.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 HINT_LIMIT = 100;
const WARN_RATIO = 0.8;
const MAX_SCALE = 1.8;
const OVER_SPAN = 0.15;
const RATIO_LERP_RATE = 10;
const MSG_FADE_MS = 220;
const START_MS = 200;
const CHUNK_MS = 130;
const HOLD_MS = 1500;
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 lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function mix(a: Rgb, b: Rgb, t: number): Rgb { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
function css(c: Rgb): string { return "rgb(" + Math.round(c[0]) + "," + Math.round(c[1]) + "," + Math.round(c[2]) + ")"; }
function tint(c: Rgb, alpha: number): string { return "rgba(" + Math.round(c[0]) + "," + Math.round(c[1]) + "," + Math.round(c[2]) + "," + alpha + ")"; }
function rgbAt(ratio: number): Rgb {
const t1 = clamp((ratio - WARN_RATIO) / (1 - WARN_RATIO), 0, 1);
const t2 = clamp((ratio - 1) / OVER_SPAN, 0, 1);
return mix(mix(MINT, AMBER, t1), PINK, t2);
}
function smooth(t0: number): number { const t = clamp(t0, 0, 1); return t * t * (3 - 2 * t); }
function makeSegmenter(): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity: "grapheme" }) : null;
}
export default function ThresholdHintColor() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const rowRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const remainRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const msgRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const fillRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const rowEl = rowRef.current;
const remainEl = remainRef.current;
const msgEl = msgRef.current;
const fill = fillRef.current;
const sr = srRef.current;
if (!ta || !rowEl || !remainEl || !msgEl || !fill || !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 shownUsed = 0;
let stage = 0;
let swapAt = -1;
let lastN = -1;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function paint(nowMs: number): void {
if (!rowEl || !remainEl || !msgEl || !fill) return;
const ratio = shownUsed / HINT_LIMIT;
const rgb = rgbAt(ratio);
const color = css(rgb);
rowEl.style.background = tint(rgb, 0.18);
const scale = lerp(1, MAX_SCALE, smooth((ratio - 0.5) / 0.6));
remainEl.textContent = String(Math.round(HINT_LIMIT - shownUsed));
remainEl.style.color = color;
remainEl.style.transform = "scale(" + scale.toFixed(3) + ")";
msgEl.style.color = color;
fill.style.background = color;
fill.style.transform = "scaleX(" + clamp(ratio, 0, 1).toFixed(4) + ")";
const a = swapAt < 0 || reduce ? 1 : clamp((nowMs - swapAt) / MSG_FADE_MS, 0, 1);
msgEl.style.opacity = a.toFixed(3);
if (a >= 1) swapAt = -1;
}
function update(nowMs: number): void {
if (!ta || !msgEl || !sr) return;
used = countGraphemes(ta.value);
const ratio = used / HINT_LIMIT;
const next = ratio >= 1 ? 2 : (ratio >= WARN_RATIO ? 1 : 0);
if (next !== stage) {
stage = next;
const text = next === 2 ? "한도를 넘었어요" : (next === 1 ? "곧 한도예요" : "여유 있어요");
msgEl.textContent = text;
sr.textContent = next === 0 ? "" : text;
swapAt = nowMs;
}
if (reduce) { shownUsed = used; 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 !== lastN) {
lastN = n;
ta.value = CHUNKS.slice(0, n).join(" ");
update(now);
}
}
shownUsed += (used - shownUsed) * (1 - Math.exp(-dt * RATIO_LERP_RATE));
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}>
<label className={styles.cc__label} htmlFor="ccText">메모</label>
<textarea className={styles.cc__ta} id="ccText" ref={taRef} aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
<div className={styles.cc__hintRow} id="ccCount" ref={rowRef}>
<span className={styles.cc__remain} ref={remainRef}>100</span>
<span className={styles.cc__msg} ref={msgRef}>여유 있어요</span>
</div>
<div className={styles.cc__meter} aria-hidden="true"><i className={styles.cc__fill} ref={fillRef}></i></div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}08 Remaining-Count Hint with Threshold Stages and Color Interpolation — Measured area 20.176% · 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
Inputs09 Continuous color-value morph — the strength value changes continuously instead of jumping between stages, with its mapped color value shifting smoothly alongPassword Strength Meter UI: 9 Calculation Patterns
Inputs03 Keyboard cycling — picking items with arrows and wrapping to the far end via mod arithmeticAutocomplete Input: 9 Search Dropdowns
Inputs03 Windows spectrum — a color picker that splits a 2D position into saturation and lightness9 Color Picker Design Patterns (Copy-Paste)