08しきい値ごとに文言と色がなめらかに変わる残り文字の案内
余裕・注意・危険の境界で色を線形補間でつなぎ、残り文字が少ないほど数字が1倍から1.8倍までなめらかに大きくなり、案内文は段階が変わるときだけ切り替わる。
使いどころ文字数の上限が厳しい入力
HINT_LIMITWARN_RATIOMAX_SCALEOVER_SPANMSG_FADE_MS

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<label class="cc__label" for="ccText">メモ</label>
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="書いてみてください"></textarea>
<div class="cc__hintRow" id="ccCount">
<span class="cc__remain" id="ccRemain">100</span>
<span class="cc__msg" id="ccMsg">余裕があります</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 부터 한도까지는 민트에서 앰버로,
// 한도를 넘으면 앰버에서 핑크로 색을 선형 보간한다. 残り 숫자는 비율에 따라 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 = ['今夜', '友達と', '新しくできた', '食堂に行って', '温かいスープと', 'サクサクの揚げ物を', '分け合うことにした', 'けれど', '席が足りなくて', 'しばらく', '待たなければならず', 'お腹が空いて', 'メニューばかり', 'ずっと', 'めくっていた', 'それでも', '味は', '期待以上だった', 'レビューを', '残そうと', '写真も', '撮っておいた', '次は', 'もっと早く', '来よう'];
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 ? '上限を超えました' : (next === 1 ? '上限が近いです' : '余裕があります');
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 しきい値ごとに文言と色がなめらかに変わる残り文字の案内 — 計測 累積面積 20.176% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · テキストエリア 文字数カウント 9パターン — 書記素で数えて上限をリングで見せる
このデモが属する記事
テキストエリア 文字数カウント 9パターン — 書記素で数えて上限をリングで見せる文字数はlength一行で済みそうだが、絵文字が2と数えられ、変換中は数字がちらつく — 01番と04番がその二か所を一つずつ塞ぐ。テキストエリア 文字数カウンターは、入力が変わるたびに人が見る一文字ぶんを数え、残りの上限を知らせる数字です この記事のデモをすべて見る


