textarea 글자수 카운터 9가지 — 자소 단위로 세고 한도를 링으로 보여 주는 입력창
글자수는 length 한 줄로 끝날 것 같지만, 이모지 하나가 2로 올라가고 한글 조합 중에는 숫자가 깜박인다 — 01번과 04번이 그 두 군데를 하나씩 막는다. textarea 글자수 카운터는 입력이 바뀔 때마다 사람이 보는 한 글자를 세어 남은 한도를 알려 주는 숫자다.
01 grapheme-cluster-live-length-counter
02 limit-ring-spring-fill-warning-stages
03 weighted-width-count-full-half-width
04 ime-composition-safe-counter
05 overflow-mirror-layer-highlight
06 paste-clip-overflow-damped-shake
07 chars-words-bytes-reading-time-meter
08 remaining-hint-threshold-color-interpolation
09 aria-live-throttled-counter-reduced-motion
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 자소 묶음 단위로 세는 실시간 글자수
- 02 남은 글자에 따라 차오르는 스프링 링
- 03 전각 2·반각 1로 세는 폭 가중 카운트
- 04 조합 중인 글자까지 흔들리지 않는 IME 대응 카운터
- 05 한도를 넘은 뒷부분만 표시하는 거울 레이어
- 06 붙여넣기가 넘치면 잘라 내고 감쇠 진동으로 알리기
- 07 글자·단어·바이트·읽는 시간을 한 번에 재는 계기판
- 08 임계 구간마다 문구와 색이 이어서 바뀌는 남은 글자 안내
- 09 낭독 폭주를 막는 aria-live 스로틀과 움직임 줄이기
textarea 글자수 카운터 9가지 — 자소 단위 계산이 숫자를 바로잡는다
아홉 개는 숫자가 틀리는 순서대로 놓았다. 01 은 이모지를 한 글자로 세는 기본 계산, 02 는 그 숫자를 한도 대비 링으로 바꾸는 장면, 03 은 전각과 반각의 폭이 다를 때의 계산이다. 04 는 조합 중인 입력에서 숫자가 깜박이는 문제를, 05 는 넘친 글자만 칠해 주는 거울 레이어를, 06 은 붙여넣기가 한도를 넘는 순간을 다루고, 07 은 글자·단어·바이트·읽는 시간을 한 번에 재며, 08 은 남은 글자 안내 문구와 색이 구간마다 이어서 바뀌는 방식, 09 는 스크린리더에 읽어 주는 횟수를 줄이는 방식이다. 글쓰기 폼에 처음 붙이는 사람이라면 01·02·08 세 개만 합쳐도 숫자와 링과 안내 문구가 한 입력창 안에서 맞물려 돌아간다.
기법별 코드로 보는 글자수 계산
아홉 항목 모두 textarea 하나와 그 label, 카운트 요소를 가리키는 aria-describedby 로 구성되고, 아래에는 숫자를 계산하는 핵심 구간만 발췌했다. 나머지 마크업과 스타일은 데모 아래 압축 파일 안에 통째로 들어 있고, 시연 문장은 데모용이며 서버와는 통신하지 않는다. 시연 중에 입력칸을 누르거나 키를 치면 시연은 멈추고 직접 쓴 글자가 같은 계산을 지나간다.
01자소 묶음 단위로 세는 실시간 글자수
graphemes 가 Intl.Segmenter 로 입력값을 사람이 보는 한 글자씩 쪼개 큰 숫자로 보여 주고 옆 「length 로는」 숫자에는 ta.value.length 를 놓아, 이모지가 들어가는 순간 두 숫자가 갈리며 포스터 시점에는 묶음 12자 대 length 19 로 벌어져 있다. 글자수 표시를 처음 붙이는 폼이라면 여기서 시작한다. 01 항목은 누적면적 13.4%·강도 평균 64.8·23프레임 중 23프레임에서 움직였다.

<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__row">
<div class="cc__big"><span class="cc__num" id="ccCount">0</span><span class="cc__unit">자</span></div>
<div class="cc__sub">length 로는 <b id="ccLen">0</b></div>
</div>
<div class="cc__strip" id="ccStrip" aria-hidden="true"></div>
<p class="cc__sr" id="ccSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 글자수는 length 가 아니라 자소 묶음(Intl.Segmenter) 개수다.
// 숫자는 보간으로, 새 칸은 스프링 오버슛으로 움직인다 (JS 가 transform 을 직접 쓴다).
@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__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-3;
}
.cc__big { display: flex; align-items: baseline; gap: $sp-1; }
.cc__num {
font-size: 28px;
line-height: 32px;
font-weight: 700;
color: $subject-mint;
font-variant-numeric: tabular-nums;
}
.cc__unit { font-size: 12px; font-weight: 600; color: rgba($stage-paper, .82); }
.cc__sub { font-size: 12px; line-height: 16px; color: rgba($stage-paper, .82); }
.cc__sub b { color: $subject-pink; font-variant-numeric: tabular-nums; }
.cc__strip { display: flex; gap: $sp-1; height: 40px; }
.cc__tile {
flex: 1 1 0;
min-width: 0;
max-width: 24px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: $r-xs;
background: rgba($stage-paper, .06);
font-size: 16px;
line-height: 20px;
}
.cc__tile.is-on { background: rgba($subject-mint, .22); }
.cc__tile.is-multi { background: rgba($subject-pink, .34); }
.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__num { font-size: 24px; line-height: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__tile { 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__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__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.cc__big {
display: flex;
align-items: baseline;
gap: 4px;
}
.cc__num {
font-size: 28px;
line-height: 32px;
font-weight: 700;
color: #4cd4a6;
font-variant-numeric: tabular-nums;
}
.cc__unit {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.82);
}
.cc__sub {
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.82);
}
.cc__sub b {
color: #ff9ebb;
font-variant-numeric: tabular-nums;
}
.cc__strip {
display: flex;
gap: 4px;
height: 40px;
}
.cc__tile {
flex: 1 1 0;
min-width: 0;
max-width: 24px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
font-size: 16px;
line-height: 20px;
}
.cc__tile.is-on {
background: rgba(76, 212, 166, 0.22);
}
.cc__tile.is-multi {
background: rgba(255, 158, 187, 0.34);
}
.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__num {
font-size: 24px;
line-height: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__tile {
transform: none;
}
}(function () {
// 기법: 입력이 바뀔 때마다 Intl.Segmenter 로 문자열을 사람이 보는 한 글자(자소 묶음) 단위로 쪼개 센다.
// 이모지 가족·국기·결합 악센트는 length 로는 여러 칸이지만 묶음으로는 한 글자다.
// 숫자는 보간으로 따라가고, 새로 들어온 묶음 칸은 스프링 오버슛으로 튀어 오른다.
var STRIP_SLOTS = 16;
var POP_MS = 460;
var COUNT_LERP_RATE = 14;
var START_MS = 200;
var CHAR_MS = 150;
var HOLD_MS = 1400;
var ANNOUNCE_EVERY = 10;
var FAMILY = '👨👩👧';
var FLAG = '🇰🇷';
var CAFE = 'café';
var DEMO_CHUNKS = ['하늘이 맑다 ', FAMILY, ' 가족과 ', FLAG, ' 여행 ', CAFE];
var root = document.getElementById('ccRoot');
var ta = document.getElementById('ccText');
var countEl = document.getElementById('ccCount');
var lenEl = document.getElementById('ccLen');
var strip = document.getElementById('ccStrip');
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 graphemes(s) {
if (!seg) return Array.from(s);
return Array.from(seg.segment(s), function (x) { return x.segment; });
}
var demoGraphemes = graphemes(DEMO_CHUNKS.join(''));
var tiles = [];
var born = [];
for (var i = 0; i < STRIP_SLOTS; i += 1) {
var tile = document.createElement('span');
tile.className = 'cc__tile';
strip.appendChild(tile);
tiles.push(tile);
born.push(-1);
}
var target = 0;
var shown = 0;
var lenTarget = 0;
var lenShown = 0;
var lastCount = 0;
var lastDemoN = -1;
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function popScale(age) {
if (age < 0) return 1;
if (age >= POP_MS) return 1;
return 1 - Math.exp(-age / 70) * Math.cos(age / 55);
}
function update(nowMs) {
var list = graphemes(ta.value);
var count = list.length;
var view = count <= STRIP_SLOTS ? list : list.slice(count - STRIP_SLOTS);
for (var k = 0; k < STRIP_SLOTS; k += 1) {
var g = view[k] || '';
tiles[k].textContent = g;
tiles[k].className = 'cc__tile' + (g ? ' is-on' : '') + (g.length > 1 ? ' is-multi' : '');
}
if (count > lastCount && !reduce) born[Math.min(count, STRIP_SLOTS) - 1] = nowMs;
if (count % ANNOUNCE_EVERY === 0 && count !== lastCount && count > 0) {
sr.textContent = '현재 ' + count + '자';
}
lastCount = count;
target = count;
lenTarget = ta.value.length;
if (reduce) {
shown = target;
lenShown = lenTarget;
paintNumbers();
}
}
function paintNumbers() {
countEl.textContent = String(Math.round(shown));
lenEl.textContent = String(Math.round(lenShown));
}
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 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) % (demoGraphemes.length * CHAR_MS + HOLD_MS);
var n = now < START_MS ? 0 : Math.min(demoGraphemes.length, Math.floor(cyc / CHAR_MS) + 1);
if (n !== lastDemoN) {
lastDemoN = n;
ta.value = demoGraphemes.slice(0, n).join('');
update(now);
}
}
var k = 1 - Math.exp(-dt * COUNT_LERP_RATE);
shown += (target - shown) * k;
lenShown += (lenTarget - lenShown) * k;
paintNumbers();
for (var m = 0; m < STRIP_SLOTS; m += 1) {
if (born[m] < 0) continue;
var s = popScale(now - born[m]);
tiles[m].style.transform = s === 1 ? '' : 'scale(' + s.toFixed(3) + ')';
if (s === 1) born[m] = -1;
}
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = demoGraphemes.join('');
update(0);
sr.textContent = '현재 ' + lastCount + '자';
} else {
requestAnimationFrame(tick);
}
})();// GraphemeLiveCounter — vanilla 판(01-grapheme-cluster-live-length-counter) 의 React 포트.
// 입력이 바뀔 때마다 Intl.Segmenter 로 자소 묶음 단위로 센다. 숫자는 보간, 새 칸은 스프링 오버슛.
import { useEffect, useRef } from "react";
import styles from "./GraphemeLiveCounter.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;
const STRIP_SLOTS = 16;
const POP_MS = 460;
const COUNT_LERP_RATE = 14;
const START_MS = 200;
const CHAR_MS = 150;
const HOLD_MS = 1400;
const ANNOUNCE_EVERY = 10;
const FAMILY = "👨👩👧";
const FLAG = "🇰🇷";
const CAFE = "café";
const DEMO_CHUNKS = ["하늘이 맑다 ", FAMILY, " 가족과 ", FLAG, " 여행 ", CAFE];
function makeSegmenter(): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity: "grapheme" }) : null;
}
function popScale(age: number): number {
if (age < 0 || age >= POP_MS) return 1;
return 1 - Math.exp(-age / 70) * Math.cos(age / 55);
}
export default function GraphemeLiveCounter() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const countRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const lenRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const tileRefs = useRef<Array<HTMLSpanElement | null>>([]) as Box<Array<HTMLSpanElement | null>>;
useEffect(() => {
const ta = taRef.current;
const countEl = countRef.current;
const lenEl = lenRef.current;
const sr = srRef.current;
if (!ta || !countEl || !lenEl || !sr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const seg = makeSegmenter();
const graphemes = (s: string): string[] =>
seg ? Array.from(seg.segment(s), (x) => x.segment) : Array.from(s);
const demoGraphemes = graphemes(DEMO_CHUNKS.join(""));
const tiles = tileRefs.current;
const born: number[] = [];
for (let i = 0; i < STRIP_SLOTS; i += 1) born.push(-1);
let demo = true;
let target = 0;
let shown = 0;
let lenTarget = 0;
let lenShown = 0;
let lastCount = 0;
let lastDemoN = -1;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function paintNumbers(): void {
if (!countEl || !lenEl) return;
countEl.textContent = String(Math.round(shown));
lenEl.textContent = String(Math.round(lenShown));
}
function update(nowMs: number): void {
if (!ta || !sr) return;
const list = graphemes(ta.value);
const count = list.length;
const view = count <= STRIP_SLOTS ? list : list.slice(count - STRIP_SLOTS);
for (let k = 0; k < STRIP_SLOTS; k += 1) {
const g = view[k] || "";
const el = tiles[k];
if (!el) continue;
el.textContent = g;
el.className = [styles.cc__tile, g ? styles["is-on"] : "", g.length > 1 ? styles["is-multi"] : ""].join(" ");
}
if (count > lastCount && !reduce) born[Math.min(count, STRIP_SLOTS) - 1] = nowMs;
if (count % ANNOUNCE_EVERY === 0 && count !== lastCount && count > 0) {
sr.textContent = "현재 " + count + "자";
}
lastCount = count;
target = count;
lenTarget = ta.value.length;
if (reduce) {
shown = target;
lenShown = lenTarget;
paintNumbers();
}
}
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) % (demoGraphemes.length * CHAR_MS + HOLD_MS);
const n = now < START_MS ? 0 : Math.min(demoGraphemes.length, Math.floor(cyc / CHAR_MS) + 1);
if (n !== lastDemoN) {
lastDemoN = n;
ta.value = demoGraphemes.slice(0, n).join("");
update(now);
}
}
const k = 1 - Math.exp(-dt * COUNT_LERP_RATE);
shown += (target - shown) * k;
lenShown += (lenTarget - lenShown) * k;
paintNumbers();
for (let m = 0; m < STRIP_SLOTS; m += 1) {
if (born[m] < 0) continue;
const s = popScale(now - born[m]);
const el = tiles[m];
if (el) el.style.transform = s === 1 ? "" : "scale(" + s.toFixed(3) + ")";
if (s === 1) born[m] = -1;
}
raf = requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = demoGraphemes.join("");
update(0);
sr.textContent = "현재 " + lastCount + "자";
} 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} ref={rootRef}>
<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__row}>
<div className={styles.cc__big}><span className={styles.cc__num} id="ccCount" ref={countRef}>0</span><span className={styles.cc__unit}>자</span></div>
<div className={styles.cc__sub}>length 로는 <b ref={lenRef}>0</b></div>
</div>
<div className={styles.cc__strip} aria-hidden="true">
{Array.from({ length: STRIP_SLOTS }, (_, i) => (
<span className={styles.cc__tile} key={i} ref={(el: HTMLSpanElement | null) => { tileRefs.current[i] = el; }}></span>
))}
</div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}01 · 자소 묶음 단위로 세는 실시간 글자수 — 측정 누적면적 13.419% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function update(nowMs) {
var list = graphemes(ta.value);
var count = list.length;
var view = count <= STRIP_SLOTS ? list : list.slice(count - STRIP_SLOTS);
for (var k = 0; k < STRIP_SLOTS; k += 1) {
var g = view[k] || '';
tiles[k].textContent = g;
tiles[k].className = 'cc__tile' + (g ? ' is-on' : '') + (g.length > 1 ? ' is-multi' : '');
}
if (count > lastCount && !reduce) born[Math.min(count, STRIP_SLOTS) - 1] = nowMs;
if (count % ANNOUNCE_EVERY === 0 && count !== lastCount && count > 0) {
sr.textContent = '현재 ' + count + '자';
}
lastCount = count;
target = count;
lenTarget = ta.value.length;
if (reduce) {
shown = target;
lenShown = lenTarget;
paintNumbers();
}
}
02남은 글자에 따라 차오르는 스프링 링
사용한 글자를 한도로 나눈 비율이 링의 호 길이가 되고, 그 값이 RING_SPRING_STIFFNESS·RING_SPRING_DAMPING 스프링을 거쳐 목표를 따라가며 80% 부터 민트에서 앰버로, 한도에서 핑크로 색이 이어 바뀐다. 처음 한도를 넘는 순간 감쇠 사인으로 링이 흔들리므로 한도 근처를 숫자보다 먼저 눈에 띄게 하고 싶을 때 쓴다. 02 항목은 누적면적 30.3%·강도 평균 108.5·23프레임 중 23프레임에서 움직였다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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;
}
03전각 2·반각 1로 세는 폭 가중 카운트
weightOf 는 묶음의 첫 코드 포인트가 0x7e 이하이거나 반각 가타카나 범위면 HALF_WEIGHT 1, 아니면 FULL_WEIGHT 2 를 돌려주고, 이 가중치를 합산해 80폭 한도까지 칸 너비가 두 배씩 차이 나는 막대를 그린다. 일본어 전각과 영문 반각이 섞인 입력, 한도가 글자 수가 아니라 폭으로 정해진 입력칸에 맞다. 03 항목은 누적면적 10.0%·강도 평균 150.6·23프레임 중 23프레임에서 움직였다.

<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__bar" id="ccBar" aria-hidden="true"></div>
<div class="cc__row">
<div class="cc__big"><span class="cc__num" id="ccCount">0</span><span class="cc__unit">/ 80 폭</span></div>
<div class="cc__legend"><span class="cc__chip cc__chip--half">반각 1</span><span class="cc__chip cc__chip--full">전각 2</span></div>
</div>
<p class="cc__sr" id="ccSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 막대 칸 하나가 글자 하나이고, 칸의 너비가 폭 가중치다 (반각 1 · 전각 2).
@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: 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__bar {
position: relative;
height: 28px;
overflow: hidden;
border-radius: $r-xs;
background: rgba($stage-paper, .08);
}
.cc__cell {
position: absolute;
top: 0;
bottom: 0;
transform-origin: 50% 100%;
border-radius: $r-xs;
}
.cc__cell.is-half { background: $subject-sky; }
.cc__cell.is-full { background: $subject-lilac; }
.cc__cell.is-over { background: $subject-pink; }
.cc__cell.is-space { opacity: .45; }
.cc__row { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-3; }
.cc__big { display: flex; align-items: baseline; gap: $sp-1; }
.cc__num {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: $subject-sky;
font-variant-numeric: tabular-nums;
}
.cc__unit { font-size: 12px; font-weight: 600; color: rgba($stage-paper, .82); }
.cc__legend { display: flex; gap: $sp-2; }
.cc__chip {
padding: 0 $sp-2;
border-radius: $r-xs;
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: $subject-ink;
}
.cc__chip--half { background: $subject-sky; }
.cc__chip--full { background: $subject-lilac; }
.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__legend { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cc__cell { 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__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__bar {
position: relative;
height: 28px;
overflow: hidden;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
}
.cc__cell {
position: absolute;
top: 0;
bottom: 0;
transform-origin: 50% 100%;
border-radius: 4px;
}
.cc__cell.is-half {
background: #6ec8ff;
}
.cc__cell.is-full {
background: #b9a0ff;
}
.cc__cell.is-over {
background: #ff9ebb;
}
.cc__cell.is-space {
opacity: 0.45;
}
.cc__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.cc__big {
display: flex;
align-items: baseline;
gap: 4px;
}
.cc__num {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: #6ec8ff;
font-variant-numeric: tabular-nums;
}
.cc__unit {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.82);
}
.cc__legend {
display: flex;
gap: 8px;
}
.cc__chip {
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: #17141a;
}
.cc__chip--half {
background: #6ec8ff;
}
.cc__chip--full {
background: #b9a0ff;
}
.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__legend {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__cell {
transform: none;
}
}(function () {
// 기법: 글자마다 코드 포인트 범위로 폭 가중치를 매긴다. ASCII 와 반각 가타카나는 1, 나머지는 2.
// 가중치를 모두 더한 값이 WIDTH_LIMIT 를 넘으면 그 뒤 칸은 핑크로 칠한다.
// 막대 칸의 너비는 가중치 비율이고, 새 칸은 아래에서 스프링 오버슛으로 솟는다.
var WIDTH_LIMIT = 80;
var HALF_WEIGHT = 1;
var FULL_WEIGHT = 2;
var POP_MS = 420;
var COUNT_LERP_RATE = 14;
var START_MS = 200;
var CHUNK_MS = 200;
var HOLD_MS = 1500;
var CHUNKS = ['Hello', 'こんにちは', 'World', 'カウンター', 'ABC', '文字数', '123', 'カタカナ', 'ありがとう', 'Text', '全角と半角', 'mix', 'ABC', '123', '日本語', 'kana', 'おわり'];
var DEMO_TEXT = CHUNKS.join(' ');
var root = document.getElementById('ccRoot');
var ta = document.getElementById('ccText');
var bar = document.getElementById('ccBar');
var countEl = document.getElementById('ccCount');
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 graphemes(s) {
if (!seg) return Array.from(s);
return Array.from(seg.segment(s), function (x) { return x.segment; });
}
function weightOf(g) {
var cp = g.codePointAt(0);
if (cp <= 0x7e || (cp >= 0xff61 && cp <= 0xff9f)) return HALF_WEIGHT;
return FULL_WEIGHT;
}
var cells = [];
var born = [];
var total = 0;
var shown = 0;
var prevCount = 0;
var stage = 0;
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function popScale(age) {
if (age < 0 || age >= POP_MS) return 1;
return 1 - Math.exp(-age / 70) * Math.cos(age / 55);
}
function update(nowMs) {
var list = graphemes(ta.value);
var cum = 0;
for (var i = 0; i < list.length; i += 1) {
if (!cells[i]) {
var c = document.createElement('span');
bar.appendChild(c);
cells.push(c);
born.push(-1);
}
var w = weightOf(list[i]);
var el = cells[i];
el.style.display = '';
el.style.left = (cum / WIDTH_LIMIT * 100).toFixed(3) + '%';
el.style.width = 'calc(' + (w / WIDTH_LIMIT * 100).toFixed(3) + '% - 1px)';
var over = cum + w > WIDTH_LIMIT;
el.className = 'cc__cell ' + (over ? 'is-over' : (w === FULL_WEIGHT ? 'is-full' : 'is-half')) + (list[i] === ' ' ? ' is-space' : '');
if (i >= prevCount && !reduce) born[i] = nowMs;
cum += w;
}
for (var j = list.length; j < cells.length; j += 1) cells[j].style.display = 'none';
prevCount = list.length;
total = cum;
var next = total > WIDTH_LIMIT ? 2 : 0;
if (next !== stage) {
sr.textContent = next === 2 ? '폭 한도를 넘었어요' : '';
stage = next;
}
if (reduce) { shown = total; paintNumber(); }
}
function paintNumber() {
countEl.textContent = String(Math.round(shown));
countEl.style.color = Math.round(shown) > WIDTH_LIMIT ? 'rgb(255,158,187)' : '';
}
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) prevCount = 0;
lastDemoN = n;
ta.value = CHUNKS.slice(0, n).join(' ');
update(now);
}
}
shown += (total - shown) * (1 - Math.exp(-dt * COUNT_LERP_RATE));
paintNumber();
for (var m = 0; m < born.length; m += 1) {
if (born[m] < 0) continue;
var s = popScale(now - born[m]);
cells[m].style.transform = s === 1 ? '' : 'scaleY(' + s.toFixed(3) + ')';
if (s === 1) born[m] = -1;
}
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = DEMO_TEXT;
update(0);
} else {
requestAnimationFrame(tick);
}
})();// WeightedWidthCounter — vanilla 판(03-weighted-width-count-full-half-width) 의 React 포트.
// 글자마다 코드 포인트 범위로 폭 가중치(반각 1·전각 2)를 매겨 합산하고, 칸 너비로 그린다.
import { useEffect, useRef } from "react";
import styles from "./WeightedWidthCounter.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;
const WIDTH_LIMIT = 80;
const HALF_WEIGHT = 1;
const FULL_WEIGHT = 2;
const POP_MS = 420;
const COUNT_LERP_RATE = 14;
const START_MS = 200;
const CHUNK_MS = 200;
const HOLD_MS = 1500;
const CHUNKS = ["Hello", "こんにちは", "World", "カウンター", "ABC", "文字数", "123", "カタカナ", "ありがとう", "Text", "全角と半角", "mix", "ABC", "123", "日本語", "kana", "おわり"];
const DEMO_TEXT = CHUNKS.join(" ");
function makeSegmenter(): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity: "grapheme" }) : null;
}
function weightOf(g: string): number {
const cp = g.codePointAt(0) || 0;
if (cp <= 0x7e || (cp >= 0xff61 && cp <= 0xff9f)) return HALF_WEIGHT;
return FULL_WEIGHT;
}
function popScale(age: number): number {
if (age < 0 || age >= POP_MS) return 1;
return 1 - Math.exp(-age / 70) * Math.cos(age / 55);
}
export default function WeightedWidthCounter() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const barRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const countRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const bar = barRef.current;
const countEl = countRef.current;
const sr = srRef.current;
if (!ta || !bar || !countEl || !sr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const seg = makeSegmenter();
const graphemes = (s: string): string[] =>
seg ? Array.from(seg.segment(s), (x) => x.segment) : Array.from(s);
let demo = true;
const cells: HTMLSpanElement[] = [];
const born: number[] = [];
let total = 0;
let shown = 0;
let prevCount = 0;
let stage = 0;
let lastDemoN = -1;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function paintNumber(): void {
if (!countEl) return;
countEl.textContent = String(Math.round(shown));
countEl.style.color = Math.round(shown) > WIDTH_LIMIT ? "rgb(255,158,187)" : "";
}
function update(nowMs: number): void {
if (!ta || !bar || !sr) return;
const list = graphemes(ta.value);
let cum = 0;
for (let i = 0; i < list.length; i += 1) {
if (!cells[i]) {
const c = document.createElement("span");
bar.appendChild(c);
cells.push(c);
born.push(-1);
}
const w = weightOf(list[i]);
const el = cells[i];
el.style.display = "";
el.style.left = (cum / WIDTH_LIMIT * 100).toFixed(3) + "%";
el.style.width = "calc(" + (w / WIDTH_LIMIT * 100).toFixed(3) + "% - 1px)";
const over = cum + w > WIDTH_LIMIT;
el.className = [styles.cc__cell, over ? styles["is-over"] : (w === FULL_WEIGHT ? styles["is-full"] : styles["is-half"]), list[i] === " " ? styles["is-space"] : ""].join(" ");
if (i >= prevCount && !reduce) born[i] = nowMs;
cum += w;
}
for (let j = list.length; j < cells.length; j += 1) cells[j].style.display = "none";
prevCount = list.length;
total = cum;
const next = total > WIDTH_LIMIT ? 2 : 0;
if (next !== stage) {
sr.textContent = next === 2 ? "폭 한도를 넘었어요" : "";
stage = next;
}
if (reduce) { shown = total; paintNumber(); }
}
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) prevCount = 0;
lastDemoN = n;
ta.value = CHUNKS.slice(0, n).join(" ");
update(now);
}
}
shown += (total - shown) * (1 - Math.exp(-dt * COUNT_LERP_RATE));
paintNumber();
for (let m = 0; m < born.length; m += 1) {
if (born[m] < 0) continue;
const s = popScale(now - born[m]);
cells[m].style.transform = s === 1 ? "" : "scaleY(" + s.toFixed(3) + ")";
if (s === 1) born[m] = -1;
}
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__bar} ref={barRef} aria-hidden="true"></div>
<div className={styles.cc__row}>
<div className={styles.cc__big}><span className={styles.cc__num} id="ccCount" ref={countRef}>0</span><span className={styles.cc__unit}>/ 80 폭</span></div>
<div className={styles.cc__legend}><span className={[styles.cc__chip, styles["cc__chip--half"]].join(" ")}>반각 1</span><span className={[styles.cc__chip, styles["cc__chip--full"]].join(" ")}>전각 2</span></div>
</div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}03 · 전각 2·반각 1로 세는 폭 가중 카운트 — 측정 누적면적 9.971% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function graphemes(s) {
if (!seg) return Array.from(s);
return Array.from(seg.segment(s), function (x) { return x.segment; });
}
function weightOf(g) {
var cp = g.codePointAt(0);
if (cp <= 0x7e || (cp >= 0xff61 && cp <= 0xff9f)) return HALF_WEIGHT;
return FULL_WEIGHT;
}
04조합 중인 글자까지 흔들리지 않는 IME 대응 카운터
compositionstart 에서 composing 이 켜지면 onInput 은 「바로 세기」 숫자만 갱신하고 「조합 보류」 숫자는 붙잡아 두며, compositionend 에서 확정값을 한 번에 올리고 값이 달라졌을 때만 숫자를 튀게 해 한글·일본어 입력이 많은 폼에서 숫자가 오르내리는 것을 막는다. 시연은 일본어 입력기가 n→に→にほ→にほん 처럼 조합하는 단계를 같은 핸들러로 흘려 보내는 재현이고 실제 입력기 장면은 아니며, 직접 쓸 때는 진짜 조합 이벤트가 같은 경로를 지난다. 04 항목은 누적면적 1.9%·강도 평균 121.1·23프레임 중 23프레임에서 움직였다.

<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__pair">
<div class="cc__card">
<span class="cc__cardLabel">바로 세기</span>
<span class="cc__num cc__num--raw" id="ccRaw">0</span>
</div>
<div class="cc__card cc__card--safe">
<span class="cc__cardLabel">조합 보류</span>
<span class="cc__num" id="ccCount">0</span>
</div>
<div class="cc__state">
<span class="cc__dot" id="ccDot"></span>
<span class="cc__stateText" id="ccState">확정</span>
<span class="cc__pre" id="ccPre"></span>
</div>
</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: 48px;
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__pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $sp-2;
}
.cc__card {
display: grid;
gap: $sp-1;
justify-items: center;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: rgba($stage-paper, .08);
}
.cc__card--safe { background: rgba($subject-mint, .18); }
.cc__cardLabel { font-size: 12px; line-height: 16px; font-weight: 600; color: rgba($stage-paper, .82); }
.cc__num {
font-size: 28px;
line-height: 32px;
font-weight: 700;
color: $subject-mint;
font-variant-numeric: tabular-nums;
}
.cc__num--raw { color: $subject-pink; }
.cc__state {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: $sp-2;
min-height: 16px;
}
.cc__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba($stage-paper, .4);
}
.cc__dot.is-on { background: $subject-sky; }
.cc__stateText { font-size: 12px; line-height: 16px; font-weight: 600; color: rgba($stage-paper, .82); }
.cc__pre {
font-size: 13px;
line-height: 16px;
color: $subject-sky;
text-decoration: underline;
text-underline-offset: 3px;
}
.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__num { font-size: 24px; line-height: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__num, .cc__dot { transform: none; opacity: 1; }
}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: 48px;
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__pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.cc__card {
display: grid;
gap: 4px;
justify-items: center;
padding: 4px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
}
.cc__card--safe {
background: rgba(76, 212, 166, 0.18);
}
.cc__cardLabel {
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.82);
}
.cc__num {
font-size: 28px;
line-height: 32px;
font-weight: 700;
color: #4cd4a6;
font-variant-numeric: tabular-nums;
}
.cc__num--raw {
color: #ff9ebb;
}
.cc__state {
grid-column: 1/-1;
display: flex;
align-items: center;
gap: 8px;
min-height: 16px;
}
.cc__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
}
.cc__dot.is-on {
background: #6ec8ff;
}
.cc__stateText {
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.82);
}
.cc__pre {
font-size: 13px;
line-height: 16px;
color: #6ec8ff;
text-decoration: underline;
text-underline-offset: 3px;
}
.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__num {
font-size: 24px;
line-height: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__num, .cc__dot {
transform: none;
opacity: 1;
}
}(function () {
// 기법: compositionstart 와 compositionend 이벤트로 조합 중인지 기억한다.
// 조합 중 input 은 "바로 세기" 숫자만 흔들고 "조합 보류" 숫자는 마지막 확정값에 붙잡아 둔다.
// compositionend 의 순간 한 번 새 값으로 올리고 스프링 오버슛으로 튀게 한다.
var STEP_MS = 100;
var PAUSE_MS = 220;
var START_MS = 200;
var HOLD_MS = 1200;
var BUMP_MS = 440;
// 일본어 입력기가 보여 주는 조합 단계 (로마자가 가나로 바뀌고 길이가 오르내린다)
var WORDS = [
['n', 'に', 'にh', 'にほ', 'にほn', 'にほん'],
['k', 'か', 'かu', 'かう', 'かうn', 'かうん', 'かうんt', 'かうんた'],
['m', 'も', 'もj', 'もじ'],
['s', 'す', 'すu', 'すう'],
['え', 'えn', 'えん']
];
var root = document.getElementById('ccRoot');
var ta = document.getElementById('ccText');
var rawEl = document.getElementById('ccRaw');
var safeEl = document.getElementById('ccCount');
var dot = document.getElementById('ccDot');
var stateEl = document.getElementById('ccState');
var preEl = document.getElementById('ccPre');
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;
}
var composing = false;
var rawCount = 0;
var safeCount = 0;
var bumpAt = -1;
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function paint(nowMs) {
rawEl.textContent = String(rawCount);
safeEl.textContent = String(safeCount);
stateEl.textContent = composing ? '조합 중' : '확정';
dot.className = 'cc__dot' + (composing ? ' is-on' : '');
var s = 1;
if (bumpAt >= 0 && !reduce) {
var age = nowMs - bumpAt;
s = age >= BUMP_MS ? 1 : 1 + 0.5 * Math.exp(-age / 90) * Math.cos(age / 60);
if (age >= BUMP_MS) bumpAt = -1;
}
safeEl.style.transform = s === 1 ? '' : 'scale(' + s.toFixed(3) + ')';
var pulse = composing && !reduce ? 0.5 + 0.5 * Math.sin(nowMs / 90) : 1;
dot.style.opacity = composing ? (0.45 + 0.55 * pulse).toFixed(2) : '';
}
function onCompStart() { composing = true; }
function onInput() {
rawCount = countGraphemes(ta.value);
if (!composing) safeCount = rawCount;
}
function onCompEnd(nowMs) {
composing = false;
preEl.textContent = '';
var next = countGraphemes(ta.value);
if (next !== safeCount) bumpAt = nowMs;
safeCount = next;
rawCount = next;
sr.textContent = '현재 ' + safeCount + '자';
}
function stopDemo() { root.classList.remove('is-demo'); }
ta.addEventListener('focus', stopDemo);
ta.addEventListener('keydown', stopDemo);
ta.addEventListener('compositionstart', function () { stopDemo(); onCompStart(); });
ta.addEventListener('compositionend', function () { onCompEnd(perfNow()); paint(perfNow()); });
ta.addEventListener('input', function () { stopDemo(); onInput(); paint(perfNow()); });
// 시연: 조합 단계를 같은 핸들러로 흘려 보낸다 (이벤트를 새로 만들지 않고 같은 함수를 부른다)
var timeline = [];
var base = '';
var clock = 0;
var baseAt = [];
WORDS.forEach(function (steps, w) {
baseAt.push(base);
timeline.push({ t: clock, kind: 'start', w: w });
steps.forEach(function (st, i) {
timeline.push({ t: clock + (i + 1) * STEP_MS, kind: 'step', w: w, text: st });
});
var endT = clock + (steps.length + 1) * STEP_MS;
timeline.push({ t: endT, kind: 'end', w: w, text: steps[steps.length - 1] });
base += steps[steps.length - 1];
clock = endT + PAUSE_MS;
});
var CYCLE_MS = clock + HOLD_MS;
var cursor = 0;
var cycleStart = START_MS;
function runEvent(ev, nowMs) {
if (ev.kind === 'start') {
onCompStart();
} else if (ev.kind === 'step') {
ta.value = baseAt[ev.w] + ev.text;
preEl.textContent = ev.text;
onInput();
} else {
ta.value = baseAt[ev.w] + ev.text;
onCompEnd(nowMs);
}
}
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (root.classList.contains('is-demo') && now >= START_MS) {
var local = now - cycleStart;
if (local >= CYCLE_MS) {
cycleStart += CYCLE_MS;
local = now - cycleStart;
cursor = 0;
ta.value = '';
composing = false;
rawCount = 0;
safeCount = 0;
preEl.textContent = '';
}
while (cursor < timeline.length && timeline[cursor].t <= local) {
runEvent(timeline[cursor], now);
cursor += 1;
}
}
paint(now);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = base;
rawCount = safeCount = countGraphemes(base);
paint(0);
sr.textContent = '현재 ' + safeCount + '자';
} else {
requestAnimationFrame(tick);
}
})();// ImeSafeCounter — vanilla 판(04-ime-composition-safe-counter) 의 React 포트.
// 조합 시작·끝 이벤트로 조합 중에는 숫자를 붙잡아 두고, 확정되는 순간 한 번에 올린다.
import { useEffect, useRef } from "react";
import styles from "./ImeSafeCounter.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 TimelineEvent = { t: number; kind: "start" | "step" | "end"; w: number; text: string };
const STEP_MS = 100;
const PAUSE_MS = 220;
const START_MS = 200;
const HOLD_MS = 1200;
const BUMP_MS = 440;
const WORDS: string[][] = [
["n", "に", "にh", "にほ", "にほn", "にほん"],
["k", "か", "かu", "かう", "かうn", "かうん", "かうんt", "かうんた"],
["m", "も", "もj", "もじ"],
["s", "す", "すu", "すう"],
["え", "えn", "えん"]
];
function makeSegmenter(): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity: "grapheme" }) : null;
}
export default function ImeSafeCounter() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const rawRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const safeRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const dotRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const stateRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const preRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const rawEl = rawRef.current;
const safeEl = safeRef.current;
const dot = dotRef.current;
const stateEl = stateRef.current;
const preEl = preRef.current;
const sr = srRef.current;
if (!ta || !rawEl || !safeEl || !dot || !stateEl || !preEl || !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 composing = false;
let rawCount = 0;
let safeCount = 0;
let bumpAt = -1;
let t0: number | null = null;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function paint(nowMs: number): void {
if (!rawEl || !safeEl || !dot || !stateEl) return;
rawEl.textContent = String(rawCount);
safeEl.textContent = String(safeCount);
stateEl.textContent = composing ? "조합 중" : "확정";
dot.className = [styles.cc__dot, composing ? styles["is-on"] : ""].join(" ");
let s = 1;
if (bumpAt >= 0 && !reduce) {
const age = nowMs - bumpAt;
s = age >= BUMP_MS ? 1 : 1 + 0.5 * Math.exp(-age / 90) * Math.cos(age / 60);
if (age >= BUMP_MS) bumpAt = -1;
}
safeEl.style.transform = s === 1 ? "" : "scale(" + s.toFixed(3) + ")";
const pulse = composing && !reduce ? 0.5 + 0.5 * Math.sin(nowMs / 90) : 1;
dot.style.opacity = composing ? (0.45 + 0.55 * pulse).toFixed(2) : "";
}
function onCompStart(): void { composing = true; }
function onInputCount(): void {
if (!ta) return;
rawCount = countGraphemes(ta.value);
if (!composing) safeCount = rawCount;
}
function onCompEnd(nowMs: number): void {
if (!ta || !preEl || !sr) return;
composing = false;
preEl.textContent = "";
const next = countGraphemes(ta.value);
if (next !== safeCount) bumpAt = nowMs;
safeCount = next;
rawCount = next;
sr.textContent = "현재 " + safeCount + "자";
}
function stopDemo(): void { demo = false; }
const onCompStartEvt = (): void => { stopDemo(); onCompStart(); };
const onCompEndEvt = (): void => { onCompEnd(perfNow()); paint(perfNow()); };
const onInputEvt = (): void => { stopDemo(); onInputCount(); paint(perfNow()); };
ta.addEventListener("focus", stopDemo);
ta.addEventListener("keydown", stopDemo);
ta.addEventListener("compositionstart", onCompStartEvt);
ta.addEventListener("compositionend", onCompEndEvt);
ta.addEventListener("input", onInputEvt);
const timeline: TimelineEvent[] = [];
const baseAt: string[] = [];
let base = "";
let clock = 0;
WORDS.forEach((steps, w) => {
baseAt.push(base);
timeline.push({ t: clock, kind: "start", w, text: "" });
steps.forEach((st, i) => {
timeline.push({ t: clock + (i + 1) * STEP_MS, kind: "step", w, text: st });
});
const endT = clock + (steps.length + 1) * STEP_MS;
timeline.push({ t: endT, kind: "end", w, text: steps[steps.length - 1] });
base += steps[steps.length - 1];
clock = endT + PAUSE_MS;
});
const CYCLE_MS = clock + HOLD_MS;
let cursor = 0;
let cycleStart = START_MS;
function runEvent(ev: TimelineEvent, nowMs: number): void {
if (!ta || !preEl) return;
if (ev.kind === "start") {
onCompStart();
} else if (ev.kind === "step") {
ta.value = baseAt[ev.w] + ev.text;
preEl.textContent = ev.text;
onInputCount();
} else {
ta.value = baseAt[ev.w] + ev.text;
onCompEnd(nowMs);
}
}
function tick(ts: number): void {
if (!ta || !preEl) return;
if (t0 === null) t0 = ts;
const now = ts - t0;
if (demo && now >= START_MS) {
let local = now - cycleStart;
if (local >= CYCLE_MS) {
cycleStart += CYCLE_MS;
local = now - cycleStart;
cursor = 0;
ta.value = "";
composing = false;
rawCount = 0;
safeCount = 0;
preEl.textContent = "";
}
while (cursor < timeline.length && timeline[cursor].t <= local) {
runEvent(timeline[cursor], now);
cursor += 1;
}
}
paint(now);
raf = requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = base;
rawCount = countGraphemes(base);
safeCount = rawCount;
paint(0);
sr.textContent = "현재 " + safeCount + "자";
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
ta.removeEventListener("focus", stopDemo);
ta.removeEventListener("keydown", stopDemo);
ta.removeEventListener("compositionstart", onCompStartEvt);
ta.removeEventListener("compositionend", onCompEndEvt);
ta.removeEventListener("input", onInputEvt);
};
}, []);
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__pair}>
<div className={styles.cc__card}>
<span className={styles.cc__cardLabel}>바로 세기</span>
<span className={[styles.cc__num, styles["cc__num--raw"]].join(" ")} ref={rawRef}>0</span>
</div>
<div className={[styles.cc__card, styles["cc__card--safe"]].join(" ")}>
<span className={styles.cc__cardLabel}>조합 보류</span>
<span className={styles.cc__num} id="ccCount" ref={safeRef}>0</span>
</div>
<div className={styles.cc__state}>
<span className={styles.cc__dot} ref={dotRef}></span>
<span className={styles.cc__stateText} ref={stateRef}>확정</span>
<span className={styles.cc__pre} ref={preRef}></span>
</div>
</div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}04 · 조합 중인 글자까지 흔들리지 않는 IME 대응 카운터 — 측정 누적면적 1.882% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function onCompStart() { composing = true; }
function onInput() {
rawCount = countGraphemes(ta.value);
if (!composing) safeCount = rawCount;
}
function onCompEnd(nowMs) {
composing = false;
preEl.textContent = '';
var next = countGraphemes(ta.value);
if (next !== safeCount) bumpAt = nowMs;
safeCount = next;
rawCount = next;
sr.textContent = '현재 ' + safeCount + '자';
}
05한도를 넘은 뒷부분만 표시하는 거울 레이어
입력칸 뒤에 글꼴·줄 높이·패딩·white-space: pre-wrap 이 한 벌인 거울 레이어를 겹쳐 두고, 한도 이후 글자만 <mark> 로 감싸 형광 배경을 칠하며 스크롤을 거울과 맞춘다. 입력칸 배경은 투명하고 거울 글자색도 투명이라 칠한 색만 비치며, 새로 넘친 순간에는 색이 진하게 시작해 가라앉아 어디까지가 넘친 글자인지 바로 고쳐 쓰게 하는 입력에 쓴다. 05 항목은 누적면적 14.4%·강도 평균 117.8·23프레임 중 22프레임에서 움직였다.

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<label class="cc__label" for="ccText">메모</label>
<div class="cc__field">
<div class="cc__mirror" id="ccMirror" aria-hidden="true"></div>
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
</div>
<div class="cc__row">
<div class="cc__big"><span class="cc__num" id="ccCount">0</span><span class="cc__unit">/ 60자</span></div>
<span class="cc__over" id="ccOver"></span>
</div>
<p class="cc__sr" id="ccSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 거울(.cc__mirror)과 입력칸(.cc__ta)은 글꼴·줄높이·패딩·테두리·줄바꿈 규칙이 한 벌이다.
// 하나라도 다르면 형광 칠이 글자에서 어긋난다.
@include stage($stage-paper);
$cc-font-size: 13px;
$cc-line: 18px;
.cc {
width: min(100%, 420px);
display: grid;
gap: $sp-2;
color: $subject-ink;
}
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: $subject-ink;
}
.cc__field {
position: relative;
height: 72px;
border-radius: $r-sm;
background: rgba($subject-ink, .04);
}
.cc__mirror,
.cc__ta {
box-sizing: border-box;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: $sp-2 $sp-3;
border: 1px solid rgba($subject-ink, .32);
border-radius: $r-sm;
font-family: inherit;
font-size: $cc-font-size;
line-height: $cc-line;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: normal;
}
.cc__mirror {
overflow: hidden;
color: transparent;
border-color: transparent;
pointer-events: none;
}
.cc__ta {
resize: none;
overflow-y: auto;
scrollbar-width: none;
background: transparent;
color: $subject-ink;
}
.cc__ta::-webkit-scrollbar { display: none; }
.cc__ta::placeholder { color: rgba($subject-ink, .64); }
.cc__ta:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.cc__mark {
color: transparent;
border-radius: $r-xs;
background: rgba($subject-pink, .5);
}
.cc__row { display: flex; align-items: center; justify-content: space-between; gap: $sp-3; min-height: 28px; }
.cc__big { display: flex; align-items: baseline; gap: $sp-1; }
.cc__num {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: $subject-ink;
font-variant-numeric: tabular-nums;
}
.cc__unit { font-size: 12px; font-weight: 600; color: rgba($subject-ink, .78); }
.cc__over {
padding: 0 $sp-2;
border-radius: $r-pill;
font-size: 12px;
line-height: 20px;
font-weight: 600;
color: $subject-ink;
background: transparent;
}
.cc__over.is-on { background: $subject-pink; }
.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__num { font-size: 20px; line-height: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__mark { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
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: #17141a;
}
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: #17141a;
}
.cc__field {
position: relative;
height: 72px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.04);
}
.cc__mirror,
.cc__ta {
box-sizing: border-box;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 8px 12px;
border: 1px solid rgba(23, 20, 26, 0.32);
border-radius: 8px;
font-family: inherit;
font-size: 13px;
line-height: 18px;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: normal;
}
.cc__mirror {
overflow: hidden;
color: transparent;
border-color: transparent;
pointer-events: none;
}
.cc__ta {
resize: none;
overflow-y: auto;
scrollbar-width: none;
background: transparent;
color: #17141a;
}
.cc__ta::-webkit-scrollbar {
display: none;
}
.cc__ta::placeholder {
color: rgba(23, 20, 26, 0.64);
}
.cc__ta:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cc__mark {
color: transparent;
border-radius: 4px;
background: rgba(255, 158, 187, 0.5);
}
.cc__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
}
.cc__big {
display: flex;
align-items: baseline;
gap: 4px;
}
.cc__num {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.cc__unit {
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.78);
}
.cc__over {
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
line-height: 20px;
font-weight: 600;
color: #17141a;
background: transparent;
}
.cc__over.is-on {
background: #ff9ebb;
}
.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__num {
font-size: 20px;
line-height: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__mark {
transition: none;
}
}(function () {
// 기법: 입력칸 뒤에 같은 글꼴·패딩·줄바꿈 규칙의 거울 레이어를 깔고, 거울 안에서
// 한도 이후의 글자만 mark 로 감싸 분홍 배경을 칠한다. 입력칸은 투명이라 그 위로 비쳐 보인다.
// 스크롤은 입력칸 scrollTop 을 거울에 그대로 복사해 맞추고, 새로 넘친 글자는 처음에 진하게 칠했다가 가라앉는다.
var MIRROR_LIMIT = 60;
var FLASH_MS = 520;
var START_MS = 200;
var CHUNK_MS = 130;
var HOLD_MS = 1500;
var CHUNKS = ['오늘 저녁에', '친구들과', '새로 생긴', '식당에 가서', '따뜻한 국물과', '바삭한 튀김을', '나눠 먹기로', '했다 그런데', '자리가 모자라', '한참을', '기다려야 했고', '배가 고파서', '메뉴판만', '계속', '뒤적였다', '그래도', '맛은', '기대 이상이었다', '후기를', '남기려고', '사진도', '찍어 두었다', '다음엔', '더 일찍', '와야겠다'];
var DEMO_TEXT = CHUNKS.join(' ');
var root = document.getElementById('ccRoot');
var ta = document.getElementById('ccText');
var mirror = document.getElementById('ccMirror');
var countEl = document.getElementById('ccCount');
var overEl = document.getElementById('ccOver');
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 graphemes(s) {
if (!seg) return Array.from(s);
return Array.from(seg.segment(s), function (x) { return x.segment; });
}
var mark = null;
var flashAt = -1;
var lastOver = 0;
var stage = 0;
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function syncScroll() { mirror.scrollTop = ta.scrollTop; }
function update(nowMs) {
var list = graphemes(ta.value);
var over = Math.max(0, list.length - MIRROR_LIMIT);
mirror.textContent = '';
mirror.appendChild(document.createTextNode(list.slice(0, MIRROR_LIMIT).join('')));
mark = null;
if (over > 0) {
mark = document.createElement('mark');
mark.className = 'cc__mark';
mark.textContent = list.slice(MIRROR_LIMIT).join('');
mirror.appendChild(mark);
}
mirror.appendChild(document.createTextNode(''));
syncScroll();
countEl.textContent = String(list.length);
countEl.style.color = over > 0 ? '#9a2b52' : '';
overEl.textContent = over > 0 ? '넘침 ' + over + '자' : '';
overEl.className = 'cc__over' + (over > 0 ? ' is-on' : '');
if (over > lastOver && !reduce) flashAt = nowMs;
lastOver = over;
var next = over > 0 ? 1 : 0;
if (next !== stage) {
sr.textContent = next === 1 ? '한도를 넘었어요' : '';
stage = next;
}
if (mark) paintMark(reduce ? 1e9 : nowMs);
}
function paintMark(nowMs) {
if (!mark) return;
var age = flashAt < 0 ? 1e9 : nowMs - flashAt;
var a = 0.5 + 0.5 * Math.exp(-age / (FLASH_MS / 3));
mark.style.backgroundColor = 'rgba(255,158,187,' + a.toFixed(3) + ')';
}
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()); });
ta.addEventListener('scroll', syncScroll);
var lastDemoN = -1;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
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) {
lastDemoN = n;
ta.value = CHUNKS.slice(0, n).join(' ');
ta.scrollTop = ta.scrollHeight;
update(now);
}
}
paintMark(now);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = DEMO_TEXT;
ta.scrollTop = ta.scrollHeight;
update(0);
} else {
requestAnimationFrame(tick);
}
})();// OverflowMirrorLayer — vanilla 판(05-overflow-mirror-layer-highlight) 의 React 포트.
// 입력칸 뒤 거울 레이어에서 한도 이후의 글자만 mark 로 감싸 분홍 배경을 칠한다. 스크롤은 거울에 그대로 복사한다.
import { useEffect, useRef } from "react";
import styles from "./OverflowMirrorLayer.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;
const MIRROR_LIMIT = 60;
const FLASH_MS = 520;
const START_MS = 200;
const CHUNK_MS = 130;
const HOLD_MS = 1500;
const CHUNKS = ["오늘 저녁에", "친구들과", "새로 생긴", "식당에 가서", "따뜻한 국물과", "바삭한 튀김을", "나눠 먹기로", "했다 그런데", "자리가 모자라", "한참을", "기다려야 했고", "배가 고파서", "메뉴판만", "계속", "뒤적였다", "그래도", "맛은", "기대 이상이었다", "후기를", "남기려고", "사진도", "찍어 두었다", "다음엔", "더 일찍", "와야겠다"];
const DEMO_TEXT = CHUNKS.join(" ");
function makeSegmenter(): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity: "grapheme" }) : null;
}
export default function OverflowMirrorLayer() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const mirrorRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const countRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const overRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const mirror = mirrorRef.current;
const countEl = countRef.current;
const overEl = overRef.current;
const sr = srRef.current;
if (!ta || !mirror || !countEl || !overEl || !sr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const seg = makeSegmenter();
const graphemes = (s: string): string[] =>
seg ? Array.from(seg.segment(s), (x) => x.segment) : Array.from(s);
let demo = true;
let mark: HTMLElement | null = null;
let flashAt = -1;
let lastOver = 0;
let stage = 0;
let lastDemoN = -1;
let t0: number | null = null;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function syncScroll(): void {
if (!ta || !mirror) return;
mirror.scrollTop = ta.scrollTop;
}
function paintMark(nowMs: number): void {
if (!mark) return;
const age = flashAt < 0 ? 1e9 : nowMs - flashAt;
const a = 0.5 + 0.5 * Math.exp(-age / (FLASH_MS / 3));
mark.style.backgroundColor = "rgba(255,158,187," + a.toFixed(3) + ")";
}
function update(nowMs: number): void {
if (!ta || !mirror || !countEl || !overEl || !sr) return;
const list = graphemes(ta.value);
const over = Math.max(0, list.length - MIRROR_LIMIT);
mirror.textContent = "";
mirror.appendChild(document.createTextNode(list.slice(0, MIRROR_LIMIT).join("")));
mark = null;
if (over > 0) {
mark = document.createElement("mark");
mark.className = styles.cc__mark;
mark.textContent = list.slice(MIRROR_LIMIT).join("");
mirror.appendChild(mark);
}
mirror.appendChild(document.createTextNode(""));
syncScroll();
countEl.textContent = String(list.length);
countEl.style.color = over > 0 ? "#9a2b52" : "";
overEl.textContent = over > 0 ? "넘침 " + over + "자" : "";
overEl.className = [styles.cc__over, over > 0 ? styles["is-on"] : ""].join(" ");
if (over > lastOver && !reduce) flashAt = nowMs;
lastOver = over;
const next = over > 0 ? 1 : 0;
if (next !== stage) {
sr.textContent = next === 1 ? "한도를 넘었어요" : "";
stage = next;
}
if (mark) paintMark(reduce ? 1e9 : 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);
ta.addEventListener("scroll", syncScroll);
function tick(ts: number): void {
if (!ta) return;
if (t0 === null) t0 = ts;
const now = ts - t0;
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) {
lastDemoN = n;
ta.value = CHUNKS.slice(0, n).join(" ");
ta.scrollTop = ta.scrollHeight;
update(now);
}
}
paintMark(now);
raf = requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = DEMO_TEXT;
ta.scrollTop = ta.scrollHeight;
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);
ta.removeEventListener("scroll", syncScroll);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cc}>
<label className={styles.cc__label} htmlFor="ccText">메모</label>
<div className={styles.cc__field}>
<div className={styles.cc__mirror} ref={mirrorRef} aria-hidden="true"></div>
<textarea className={styles.cc__ta} id="ccText" ref={taRef} aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
</div>
<div className={styles.cc__row}>
<div className={styles.cc__big}><span className={styles.cc__num} id="ccCount" ref={countRef}>0</span><span className={styles.cc__unit}>/ 60자</span></div>
<span className={styles.cc__over} ref={overRef}></span>
</div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}05 · 한도를 넘은 뒷부분만 표시하는 거울 레이어 — 측정 누적면적 14.367% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function update(nowMs) {
var list = graphemes(ta.value);
var over = Math.max(0, list.length - MIRROR_LIMIT);
mirror.textContent = '';
mirror.appendChild(document.createTextNode(list.slice(0, MIRROR_LIMIT).join('')));
mark = null;
if (over > 0) {
mark = document.createElement('mark');
mark.className = 'cc__mark';
mark.textContent = list.slice(MIRROR_LIMIT).join('');
mirror.appendChild(mark);
}
mirror.appendChild(document.createTextNode(''));
syncScroll();
countEl.textContent = String(list.length);
countEl.style.color = over > 0 ? '#9a2b52' : '';
overEl.textContent = over > 0 ? '넘침 ' + over + '자' : '';
overEl.className = 'cc__over' + (over > 0 ? ' is-on' : '');
if (over > lastOver && !reduce) flashAt = nowMs;
lastOver = over;
var next = over > 0 ? 1 : 0;
if (next !== stage) {
sr.textContent = next === 1 ? '한도를 넘었어요' : '';
stage = next;
}
06붙여넣기가 넘치면 잘라 내고 감쇠 진동으로 알리기
paste 이벤트를 preventDefault 로 가로채 남은 칸 room 을 계산하고, 붙여넣은 글의 앞부분만 받고 나머지는 버린 뒤 감쇠 사인으로 입력칸을 흔들며 「잘림 N자」 토스트를 올린다. 선택 구간이 있으면 그 구간을 대체하는 계산이라 중간에 붙여넣어도 맞고, 긴 글을 붙여넣는 폼에서 조용히 잘리는 것보다 잘렸다고 말해 주는 쪽이 낫다. 06 항목은 누적면적 12.0%·강도 평균 110.3·23프레임 중 22프레임에서 움직였다.

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<label class="cc__label" for="ccText">메모</label>
<div class="cc__field" id="ccField">
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
</div>
<div class="cc__meter" aria-hidden="true"><i class="cc__fill" id="ccFill"></i></div>
<div class="cc__row">
<div class="cc__big"><span class="cc__num" id="ccCount">0</span><span class="cc__unit">/ 80자</span></div>
<span class="cc__toast" id="ccToast"></span>
</div>
<p class="cc__sr" id="ccSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 흔들림은 CSS 키프레임이 아니라 JS 가 시각으로 계산한 감쇠 사인이다. --edge 가 테두리 색을 같이 가라앉힌다.
@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__field {
--edge: 0;
position: relative;
border-radius: $r-sm;
}
.cc__field::after {
content: "";
position: absolute;
inset: -3px;
border-radius: $r-card;
box-shadow: 0 0 0 3px $subject-pink;
opacity: var(--edge);
pointer-events: none;
}
.cc__ta {
box-sizing: border-box;
display: block;
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__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__row { display: flex; align-items: center; justify-content: space-between; gap: $sp-3; min-height: 28px; }
.cc__big { display: flex; align-items: baseline; gap: $sp-1; }
.cc__num {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: $subject-mint;
font-variant-numeric: tabular-nums;
}
.cc__unit { font-size: 12px; font-weight: 600; color: rgba($stage-paper, .82); }
.cc__toast {
padding: 0 $sp-2;
border-radius: $r-pill;
font-size: 12px;
line-height: 20px;
font-weight: 600;
color: $subject-ink;
background: $subject-pink;
opacity: 0;
}
.cc__toast.is-on { opacity: 1; }
.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__num { font-size: 20px; line-height: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__field { transform: none; }
.cc__field::after { opacity: 0; }
.cc__fill { transform-origin: 0 50%; }
}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__field {
--edge: 0;
position: relative;
border-radius: 8px;
}
.cc__field::after {
content: "";
position: absolute;
inset: -3px;
border-radius: 16px;
box-shadow: 0 0 0 3px #ff9ebb;
opacity: var(--edge);
pointer-events: none;
}
.cc__ta {
box-sizing: border-box;
display: block;
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__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__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
}
.cc__big {
display: flex;
align-items: baseline;
gap: 4px;
}
.cc__num {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: #4cd4a6;
font-variant-numeric: tabular-nums;
}
.cc__unit {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.82);
}
.cc__toast {
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
line-height: 20px;
font-weight: 600;
color: #17141a;
background: #ff9ebb;
opacity: 0;
}
.cc__toast.is-on {
opacity: 1;
}
.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__num {
font-size: 20px;
line-height: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__field {
transform: none;
}
.cc__field::after {
opacity: 0;
}
.cc__fill {
transform-origin: 0 50%;
}
}(function () {
// 기법: 붙여넣기 이벤트를 가로채 남은 칸만큼의 앞부분(자소 묶음 기준)만 받고 나머지는 버린다.
// 잘렸다는 것은 입력칸이 감쇠 사인으로 좌우로 흔들리다 가라앉는 것으로 알린다 (진폭 x 지수 감쇠 x 사인, 난수 없음).
// 시연은 paste 이벤트와 같은 applyPaste 함수를 직접 부른다.
var PASTE_LIMIT = 80;
var SHAKE_AMP = 7;
var SHAKE_DECAY_MS = 240;
var SHAKE_OMEGA = 0.07;
var TOAST_MS = 1500;
var START_MS = 200;
var CHUNK_MS = 120;
var CYCLE_MS = 4800;
var PASTE_AT = [1500, 2900];
var CHUNKS = ['오늘 저녁에', '친구들과', '새로 생긴', '식당에 가서', '따뜻한 국물과', '바삭한 튀김을', '나눠 먹기로', '했다 그런데'];
var PASTE_SAMPLE = '그리고 후식으로 달콤한 디저트와 차까지 시켜서 천천히 먹었다';
var root = document.getElementById('ccRoot');
var ta = document.getElementById('ccText');
var field = document.getElementById('ccField');
var fill = document.getElementById('ccFill');
var countEl = document.getElementById('ccCount');
var toast = document.getElementById('ccToast');
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 graphemes(s) {
if (!seg) return Array.from(s);
return Array.from(seg.segment(s), function (x) { return x.segment; });
}
var used = 0;
var shown = 0;
var shakeAt = -1;
var toastAt = -1;
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function refresh() {
used = graphemes(ta.value).length;
countEl.textContent = String(used);
if (reduce) { shown = used; paintFill(); }
}
function paintFill() {
fill.style.transform = 'scaleX(' + Math.min(1, shown / PASTE_LIMIT).toFixed(4) + ')';
}
function signal(dropped, nowMs) {
toast.textContent = '잘림 ' + dropped + '자';
sr.textContent = '붙여넣기가 한도를 넘어 잘렸어요';
if (reduce) { toast.className = 'cc__toast is-on'; return; }
shakeAt = nowMs;
toastAt = nowMs;
}
function applyPaste(text, nowMs) {
var start = ta.selectionStart;
var end = ta.selectionEnd;
var before = ta.value.slice(0, start);
var after = ta.value.slice(end);
var room = PASTE_LIMIT - graphemes(before + after).length;
var incoming = graphemes(text);
var take = incoming.slice(0, Math.max(0, room)).join('');
ta.value = before + take + after;
ta.setSelectionRange(start + take.length, start + take.length);
refresh();
if (incoming.length > room) signal(incoming.length - Math.max(0, room), nowMs);
}
function stopDemo() { root.classList.remove('is-demo'); }
ta.addEventListener('focus', stopDemo);
ta.addEventListener('keydown', stopDemo);
ta.addEventListener('paste', function (e) {
stopDemo();
e.preventDefault();
applyPaste(e.clipboardData ? e.clipboardData.getData('text') : '', perfNow());
});
ta.addEventListener('input', function () {
stopDemo();
var list = graphemes(ta.value);
if (list.length > PASTE_LIMIT) {
ta.value = list.slice(0, PASTE_LIMIT).join('');
signal(list.length - PASTE_LIMIT, perfNow());
}
refresh();
});
var lastN = -1;
var pasteDone = [false, false];
var cycleStart = START_MS;
function paintMotion(nowMs) {
var dx = 0;
var edge = 0;
if (shakeAt >= 0) {
var age = nowMs - shakeAt;
var amp = SHAKE_AMP * Math.exp(-age / SHAKE_DECAY_MS);
if (amp < 0.1) { shakeAt = -1; } else { dx = amp * Math.sin(age * SHAKE_OMEGA); edge = amp / SHAKE_AMP; }
}
field.style.transform = dx === 0 ? '' : 'translateX(' + dx.toFixed(2) + 'px)';
field.style.setProperty('--edge', edge.toFixed(3));
if (toastAt >= 0) {
var ta2 = nowMs - toastAt;
if (ta2 >= TOAST_MS) { toastAt = -1; toast.className = 'cc__toast'; } else {
toast.className = 'cc__toast is-on';
var rise = Math.min(1, ta2 / 160);
toast.style.transform = 'translateY(' + ((1 - rise) * 8).toFixed(2) + 'px)';
toast.style.opacity = String(rise);
}
}
}
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') && now >= START_MS) {
var local = now - cycleStart;
if (local >= CYCLE_MS) {
cycleStart += CYCLE_MS;
local = now - cycleStart;
pasteDone = [false, false];
lastN = -1;
ta.value = '';
refresh();
}
var n = Math.min(CHUNKS.length, Math.floor(local / CHUNK_MS) + 1);
if (n !== lastN && local < PASTE_AT[0]) {
lastN = n;
ta.value = CHUNKS.slice(0, n).join(' ');
refresh();
}
for (var p = 0; p < PASTE_AT.length; p += 1) {
if (!pasteDone[p] && local >= PASTE_AT[p]) {
pasteDone[p] = true;
var len = ta.value.length;
ta.setSelectionRange(len, len);
applyPaste(PASTE_SAMPLE, now);
}
}
}
shown += (used - shown) * (1 - Math.exp(-dt * 14));
paintFill();
paintMotion(now);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = CHUNKS.join(' ');
var endPos = ta.value.length;
ta.setSelectionRange(endPos, endPos);
applyPaste(PASTE_SAMPLE, 0);
} else {
requestAnimationFrame(tick);
}
})();// PasteClipShake — vanilla 판(06-paste-clip-overflow-damped-shake) 의 React 포트.
// 붙여넣기가 남은 칸을 넘으면 앞부분(자소 묶음 기준)만 받고, 감쇠 사인으로 입력칸을 흔들어 잘렸다고 알린다.
import { useEffect, useRef } from "react";
import styles from "./PasteClipShake.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;
const PASTE_LIMIT = 80;
const SHAKE_AMP = 7;
const SHAKE_DECAY_MS = 240;
const SHAKE_OMEGA = 0.07;
const TOAST_MS = 1500;
const START_MS = 200;
const CHUNK_MS = 120;
const CYCLE_MS = 4800;
const PASTE_AT = [1500, 2900];
const CHUNKS = ["오늘 저녁에", "친구들과", "새로 생긴", "식당에 가서", "따뜻한 국물과", "바삭한 튀김을", "나눠 먹기로", "했다 그런데"];
const PASTE_SAMPLE = "그리고 후식으로 달콤한 디저트와 차까지 시켜서 천천히 먹었다";
function makeSegmenter(): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity: "grapheme" }) : null;
}
export default function PasteClipShake() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const fieldRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const fillRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const countRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const toastRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const field = fieldRef.current;
const fill = fillRef.current;
const countEl = countRef.current;
const toast = toastRef.current;
const sr = srRef.current;
if (!ta || !field || !fill || !countEl || !toast || !sr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const seg = makeSegmenter();
const graphemes = (s: string): string[] =>
seg ? Array.from(seg.segment(s), (x) => x.segment) : Array.from(s);
let demo = true;
let used = 0;
let shown = 0;
let shakeAt = -1;
let toastAt = -1;
let lastN = -1;
let pasteDone = [false, false];
let cycleStart = START_MS;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function paintFill(): void {
if (!fill) return;
fill.style.transform = "scaleX(" + Math.min(1, shown / PASTE_LIMIT).toFixed(4) + ")";
}
function refresh(): void {
if (!ta || !countEl) return;
used = graphemes(ta.value).length;
countEl.textContent = String(used);
if (reduce) { shown = used; paintFill(); }
}
function signal(dropped: number, nowMs: number): void {
if (!toast || !sr) return;
toast.textContent = "잘림 " + dropped + "자";
sr.textContent = "붙여넣기가 한도를 넘어 잘렸어요";
if (reduce) { toast.className = [styles.cc__toast, styles["is-on"]].join(" "); return; }
shakeAt = nowMs;
toastAt = nowMs;
}
function applyPaste(text: string, nowMs: number): void {
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const before = ta.value.slice(0, start);
const after = ta.value.slice(end);
const room = PASTE_LIMIT - graphemes(before + after).length;
const incoming = graphemes(text);
const take = incoming.slice(0, Math.max(0, room)).join("");
ta.value = before + take + after;
ta.setSelectionRange(start + take.length, start + take.length);
refresh();
if (incoming.length > room) signal(incoming.length - Math.max(0, room), nowMs);
}
function stopDemo(): void { demo = false; }
const onPaste = (e: ClipboardEvent): void => {
stopDemo();
e.preventDefault();
applyPaste(e.clipboardData ? e.clipboardData.getData("text") : "", perfNow());
};
const onInput = (): void => {
stopDemo();
const list = graphemes(ta.value);
if (list.length > PASTE_LIMIT) {
ta.value = list.slice(0, PASTE_LIMIT).join("");
signal(list.length - PASTE_LIMIT, perfNow());
}
refresh();
};
ta.addEventListener("focus", stopDemo);
ta.addEventListener("keydown", stopDemo);
ta.addEventListener("paste", onPaste);
ta.addEventListener("input", onInput);
function paintMotion(nowMs: number): void {
if (!field || !toast) return;
let dx = 0;
let edge = 0;
if (shakeAt >= 0) {
const age = nowMs - shakeAt;
const amp = SHAKE_AMP * Math.exp(-age / SHAKE_DECAY_MS);
if (amp < 0.1) { shakeAt = -1; } else { dx = amp * Math.sin(age * SHAKE_OMEGA); edge = amp / SHAKE_AMP; }
}
field.style.transform = dx === 0 ? "" : "translateX(" + dx.toFixed(2) + "px)";
field.style.setProperty("--edge", edge.toFixed(3));
if (toastAt >= 0) {
const age2 = nowMs - toastAt;
if (age2 >= TOAST_MS) {
toastAt = -1;
toast.className = styles.cc__toast;
} else {
toast.className = [styles.cc__toast, styles["is-on"]].join(" ");
const rise = Math.min(1, age2 / 160);
toast.style.transform = "translateY(" + ((1 - rise) * 8).toFixed(2) + "px)";
toast.style.opacity = String(rise);
}
}
}
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 && now >= START_MS) {
let local = now - cycleStart;
if (local >= CYCLE_MS) {
cycleStart += CYCLE_MS;
local = now - cycleStart;
pasteDone = [false, false];
lastN = -1;
ta.value = "";
refresh();
}
const n = Math.min(CHUNKS.length, Math.floor(local / CHUNK_MS) + 1);
if (n !== lastN && local < PASTE_AT[0]) {
lastN = n;
ta.value = CHUNKS.slice(0, n).join(" ");
refresh();
}
for (let p = 0; p < PASTE_AT.length; p += 1) {
if (!pasteDone[p] && local >= PASTE_AT[p]) {
pasteDone[p] = true;
const len = ta.value.length;
ta.setSelectionRange(len, len);
applyPaste(PASTE_SAMPLE, now);
}
}
}
shown += (used - shown) * (1 - Math.exp(-dt * 14));
paintFill();
paintMotion(now);
raf = requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = CHUNKS.join(" ");
const endPos = ta.value.length;
ta.setSelectionRange(endPos, endPos);
applyPaste(PASTE_SAMPLE, 0);
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
ta.removeEventListener("focus", stopDemo);
ta.removeEventListener("keydown", stopDemo);
ta.removeEventListener("paste", onPaste);
ta.removeEventListener("input", onInput);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cc}>
<label className={styles.cc__label} htmlFor="ccText">메모</label>
<div className={styles.cc__field} ref={fieldRef}>
<textarea className={styles.cc__ta} id="ccText" ref={taRef} aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
</div>
<div className={styles.cc__meter} aria-hidden="true"><i className={styles.cc__fill} ref={fillRef}></i></div>
<div className={styles.cc__row}>
<div className={styles.cc__big}><span className={styles.cc__num} id="ccCount" ref={countRef}>0</span><span className={styles.cc__unit}>/ 80자</span></div>
<span className={styles.cc__toast} ref={toastRef}></span>
</div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}06 · 붙여넣기가 넘치면 잘라 내고 감쇠 진동으로 알리기 — 측정 누적면적 12.012% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function applyPaste(text, nowMs) {
var start = ta.selectionStart;
var end = ta.selectionEnd;
var before = ta.value.slice(0, start);
var after = ta.value.slice(end);
var room = PASTE_LIMIT - graphemes(before + after).length;
var incoming = graphemes(text);
var take = incoming.slice(0, Math.max(0, room)).join('');
ta.value = before + take + after;
ta.setSelectionRange(start + take.length, start + take.length);
refresh();
if (incoming.length > room) signal(incoming.length - Math.max(0, room), nowMs);
}
07글자·단어·바이트·읽는 시간을 한 번에 재는 계기판
같은 텍스트에서 글자는 자소 묶음, 단어는 isWordLike 조각, 바이트는 TextEncoder 의 UTF-8 길이, 읽는 시간은 글자를 READ_CHARS_PER_MIN 으로 나눈 올림 초로 재서 네 숫자가 동시에 굴러 바뀌고, 타일 배경과 밑줄 막대는 상한 대비 비율만큼 차오른다. 글쓰기 도구의 하단 바에 붙이면 수치를 한 번에 읽을 수 있다. 07 항목은 누적면적 23.0%·강도 평균 127.8·23프레임 중 23프레임에서 움직였다.

<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__tiles" id="ccCount">
<div class="cc__tile cc__tile--chars"><span class="cc__tileLabel">글자</span><span class="cc__num" id="ccChars">0</span><i class="cc__bar"></i></div>
<div class="cc__tile cc__tile--words"><span class="cc__tileLabel">단어</span><span class="cc__num" id="ccWords">0</span><i class="cc__bar"></i></div>
<div class="cc__tile cc__tile--bytes"><span class="cc__tileLabel">바이트</span><span class="cc__num" id="ccBytes">0</span><i class="cc__bar"></i></div>
<div class="cc__tile cc__tile--time"><span class="cc__tileLabel">읽는 시간</span><span class="cc__num"><b id="ccTime">0</b><small>초</small></span><i class="cc__bar"></i></div>
</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__tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: $sp-2; }
.cc__tile {
position: relative;
display: grid;
gap: $sp-1;
justify-items: center;
min-width: 0;
padding: $sp-2 $sp-1 $sp-3;
overflow: hidden;
border-radius: $r-sm;
background: rgba($stage-paper, .08);
}
.cc__tileLabel {
max-width: 100%;
overflow: hidden;
font-size: 11px;
line-height: 16px;
font-weight: 600;
white-space: nowrap;
color: rgba($stage-paper, .82);
}
.cc__num {
font-size: 20px;
line-height: 24px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cc__num b { font-weight: 700; }
.cc__num small { margin-left: $sp-1; font-size: 11px; font-weight: 600; }
.cc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
transform: scaleX(0);
transform-origin: 0 50%;
}
.cc__tile--chars { background: color-mix(in srgb, #{$subject-mint} calc(8% + var(--fill, 0) * 26%), transparent); }
.cc__tile--chars .cc__num { color: $subject-mint; }
.cc__tile--chars .cc__bar { background: $subject-mint; }
.cc__tile--words { background: color-mix(in srgb, #{$subject-sky} calc(8% + var(--fill, 0) * 26%), transparent); }
.cc__tile--words .cc__num { color: $subject-sky; }
.cc__tile--words .cc__bar { background: $subject-sky; }
.cc__tile--bytes { background: color-mix(in srgb, #{$subject-lilac} calc(8% + var(--fill, 0) * 26%), transparent); }
.cc__tile--bytes .cc__num { color: $subject-lilac; }
.cc__tile--bytes .cc__bar { background: $subject-lilac; }
.cc__tile--time { background: color-mix(in srgb, #{$subject-pink} calc(8% + var(--fill, 0) * 26%), transparent); }
.cc__tile--time .cc__num { color: $subject-pink; }
.cc__tile--time .cc__bar { background: $subject-pink; }
.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__num { font-size: 16px; line-height: 20px; }
.cc__tileLabel { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__bar { 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__tiles {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.cc__tile {
position: relative;
display: grid;
gap: 4px;
justify-items: center;
min-width: 0;
padding: 8px 4px 12px;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
}
.cc__tileLabel {
max-width: 100%;
overflow: hidden;
font-size: 11px;
line-height: 16px;
font-weight: 600;
white-space: nowrap;
color: rgba(255, 255, 255, 0.82);
}
.cc__num {
font-size: 20px;
line-height: 24px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cc__num b {
font-weight: 700;
}
.cc__num small {
margin-left: 4px;
font-size: 11px;
font-weight: 600;
}
.cc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
transform: scaleX(0);
transform-origin: 0 50%;
}
.cc__tile--chars {
background: color-mix(in srgb, #4cd4a6 calc(8% + var(--fill, 0) * 26%), transparent);
}
.cc__tile--chars .cc__num {
color: #4cd4a6;
}
.cc__tile--chars .cc__bar {
background: #4cd4a6;
}
.cc__tile--words {
background: color-mix(in srgb, #6ec8ff calc(8% + var(--fill, 0) * 26%), transparent);
}
.cc__tile--words .cc__num {
color: #6ec8ff;
}
.cc__tile--words .cc__bar {
background: #6ec8ff;
}
.cc__tile--bytes {
background: color-mix(in srgb, #b9a0ff calc(8% + var(--fill, 0) * 26%), transparent);
}
.cc__tile--bytes .cc__num {
color: #b9a0ff;
}
.cc__tile--bytes .cc__bar {
background: #b9a0ff;
}
.cc__tile--time {
background: color-mix(in srgb, #ff9ebb calc(8% + var(--fill, 0) * 26%), transparent);
}
.cc__tile--time .cc__num {
color: #ff9ebb;
}
.cc__tile--time .cc__bar {
background: #ff9ebb;
}
.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__num {
font-size: 16px;
line-height: 20px;
}
.cc__tileLabel {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__bar {
transition: none;
}
}(function () {
// 기법: 같은 텍스트에서 네 값을 한꺼번에 계산한다.
// 글자는 자소 묶음 수, 단어는 Intl.Segmenter 의 word 단위(isWordLike), 바이트는 TextEncoder 의 UTF-8 길이,
// 읽는 시간은 글자 수를 분당 READ_CHARS_PER_MIN 으로 나눈 초다. 숫자는 목표값을 향해 보간으로 굴러간다.
var READ_CHARS_PER_MIN = 500;
var NUMBER_LERP_RATE = 12;
var ANNOUNCE_EVERY = 50;
var START_MS = 200;
var CHUNK_MS = 130;
var HOLD_MS = 1500;
var BAR_CAPS = [150, 30, 450, 20];
var CHUNKS = ['오늘 저녁에', '친구들과', '새로 생긴', '식당에 가서', '따뜻한 국물과', '바삭한 튀김을', '나눠 먹기로', '했다 그런데', '자리가 모자라', '한참을', '기다려야 했고', '배가 고파서', '메뉴판만', '계속', '뒤적였다', '그래도', '맛은', '기대 이상이었다', '후기를', '남기려고', '사진도', '찍어 두었다', '다음엔', '더 일찍', '와야겠다'];
var DEMO_TEXT = CHUNKS.join(' ');
var root = document.getElementById('ccRoot');
var ta = document.getElementById('ccText');
var outs = [document.getElementById('ccChars'), document.getElementById('ccWords'), document.getElementById('ccBytes'), document.getElementById('ccTime')];
var bars = Array.prototype.slice.call(root.querySelectorAll('.cc__bar'));
var sr = document.getElementById('ccSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gSeg = (typeof Intl !== 'undefined' && Intl.Segmenter) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
var wSeg = (typeof Intl !== 'undefined' && Intl.Segmenter) ? new Intl.Segmenter(undefined, { granularity: 'word' }) : null;
var enc = new TextEncoder();
function measure(text) {
var chars = gSeg ? Array.from(gSeg.segment(text)).length : Array.from(text).length;
var words = wSeg
? Array.from(wSeg.segment(text)).filter(function (x) { return x.isWordLike; }).length
: text.split(/\s+/).filter(Boolean).length;
var bytes = enc.encode(text).length;
var secs = Math.ceil(chars / READ_CHARS_PER_MIN * 60);
return [chars, words, bytes, secs];
}
var target = [0, 0, 0, 0];
var shown = [0, 0, 0, 0];
var lastAnnounce = 0;
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function paint() {
for (var i = 0; i < 4; i += 1) {
outs[i].textContent = String(Math.round(shown[i]));
var fraction = Math.min(1, shown[i] / BAR_CAPS[i]);
bars[i].style.transform = 'scaleX(' + fraction.toFixed(4) + ')';
bars[i].parentNode.style.setProperty('--fill', fraction.toFixed(4));
}
}
function update() {
target = measure(ta.value);
var bucket = Math.floor(target[0] / ANNOUNCE_EVERY);
if (bucket !== lastAnnounce) {
lastAnnounce = bucket;
if (bucket > 0) sr.textContent = '현재 ' + target[0] + '자';
}
if (reduce) { shown = target.slice(); paint(); }
}
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(); });
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();
}
}
var k = 1 - Math.exp(-dt * NUMBER_LERP_RATE);
for (var i = 0; i < 4; i += 1) shown[i] += (target[i] - shown[i]) * k;
paint();
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = DEMO_TEXT;
update();
} else {
requestAnimationFrame(tick);
}
})();// TextStatsMeter — vanilla 판(07-chars-words-bytes-reading-time-meter) 의 React 포트.
// 같은 텍스트에서 글자·단어·바이트·읽는 시간을 함께 계산하고, 네 숫자가 목표값을 향해 굴러간다.
import { useEffect, useRef } from "react";
import styles from "./TextStatsMeter.module.scss";
type Box<T> = { current: T };
type SegItem = { segment: string; isWordLike?: boolean };
type SegmenterLike = { segment(s: string): Iterable<SegItem> };
type SegmenterCtor = new (locale: string | undefined, options: { granularity: string }) => SegmenterLike;
const READ_CHARS_PER_MIN = 500;
const NUMBER_LERP_RATE = 12;
const ANNOUNCE_EVERY = 50;
const START_MS = 200;
const CHUNK_MS = 130;
const HOLD_MS = 1500;
const BAR_CAPS = [150, 30, 450, 20];
const CHUNKS = ["오늘 저녁에", "친구들과", "새로 생긴", "식당에 가서", "따뜻한 국물과", "바삭한 튀김을", "나눠 먹기로", "했다 그런데", "자리가 모자라", "한참을", "기다려야 했고", "배가 고파서", "메뉴판만", "계속", "뒤적였다", "그래도", "맛은", "기대 이상이었다", "후기를", "남기려고", "사진도", "찍어 두었다", "다음엔", "더 일찍", "와야겠다"];
const DEMO_TEXT = CHUNKS.join(" ");
function makeSegmenter(granularity: string): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity }) : null;
}
export default function TextStatsMeter() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const outRefs = useRef<Array<HTMLElement | null>>([null, null, null, null]) as Box<Array<HTMLElement | null>>;
const barRefs = useRef<Array<HTMLElement | null>>([null, null, null, null]) as Box<Array<HTMLElement | null>>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const sr = srRef.current;
if (!ta || !sr) return;
const outs = outRefs.current;
const bars = barRefs.current;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const gSeg = makeSegmenter("grapheme");
const wSeg = makeSegmenter("word");
const enc = new TextEncoder();
function measure(text: string): number[] {
const chars = gSeg ? Array.from(gSeg.segment(text)).length : Array.from(text).length;
const words = wSeg
? Array.from(wSeg.segment(text)).filter((x) => x.isWordLike).length
: text.split(/\s+/).filter(Boolean).length;
const bytes = enc.encode(text).length;
const secs = Math.ceil(chars / READ_CHARS_PER_MIN * 60);
return [chars, words, bytes, secs];
}
let demo = true;
let target = [0, 0, 0, 0];
let shown = [0, 0, 0, 0];
let lastAnnounce = 0;
let lastN = -1;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
function paint(): void {
for (let i = 0; i < 4; i += 1) {
const o = outs[i];
const b = bars[i];
if (o) o.textContent = String(Math.round(shown[i]));
const fraction = Math.min(1, shown[i] / BAR_CAPS[i]);
if (b) {
b.style.transform = "scaleX(" + fraction.toFixed(4) + ")";
const tile = b.parentElement;
if (tile) tile.style.setProperty("--fill", fraction.toFixed(4));
}
}
}
function update(): void {
if (!ta || !sr) return;
target = measure(ta.value);
const bucket = Math.floor(target[0] / ANNOUNCE_EVERY);
if (bucket !== lastAnnounce) {
lastAnnounce = bucket;
if (bucket > 0) sr.textContent = "현재 " + target[0] + "자";
}
if (reduce) { shown = target.slice(); paint(); }
}
function stopDemo(): void { demo = false; }
const onInput = (): void => { stopDemo(); update(); };
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();
}
}
const k = 1 - Math.exp(-dt * NUMBER_LERP_RATE);
for (let i = 0; i < 4; i += 1) shown[i] += (target[i] - shown[i]) * k;
paint();
raf = requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = DEMO_TEXT;
update();
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
ta.removeEventListener("focus", stopDemo);
ta.removeEventListener("keydown", stopDemo);
ta.removeEventListener("paste", stopDemo);
ta.removeEventListener("input", onInput);
};
}, []);
function setOut(i: number) { return (el: HTMLElement | null): void => { outRefs.current[i] = el; }; }
function setBar(i: number) { return (el: HTMLElement | null): void => { barRefs.current[i] = el; }; }
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__tiles} id="ccCount">
<div className={[styles.cc__tile, styles["cc__tile--chars"]].join(" ")}><span className={styles.cc__tileLabel}>글자</span><span className={styles.cc__num} ref={setOut(0)}>0</span><i className={styles.cc__bar} ref={setBar(0)}></i></div>
<div className={[styles.cc__tile, styles["cc__tile--words"]].join(" ")}><span className={styles.cc__tileLabel}>단어</span><span className={styles.cc__num} ref={setOut(1)}>0</span><i className={styles.cc__bar} ref={setBar(1)}></i></div>
<div className={[styles.cc__tile, styles["cc__tile--bytes"]].join(" ")}><span className={styles.cc__tileLabel}>바이트</span><span className={styles.cc__num} ref={setOut(2)}>0</span><i className={styles.cc__bar} ref={setBar(2)}></i></div>
<div className={[styles.cc__tile, styles["cc__tile--time"]].join(" ")}><span className={styles.cc__tileLabel}>읽는 시간</span><span className={styles.cc__num}><b ref={setOut(3)}>0</b><small>초</small></span><i className={styles.cc__bar} ref={setBar(3)}></i></div>
</div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}07 · 글자·단어·바이트·읽는 시간을 한 번에 재는 계기판 — 측정 누적면적 22.963% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function measure(text) {
var chars = gSeg ? Array.from(gSeg.segment(text)).length : Array.from(text).length;
var words = wSeg
? Array.from(wSeg.segment(text)).filter(function (x) { return x.isWordLike; }).length
: text.split(/\s+/).filter(Boolean).length;
var bytes = enc.encode(text).length;
var secs = Math.ceil(chars / READ_CHARS_PER_MIN * 60);
return [chars, words, bytes, secs];
}
08임계 구간마다 문구와 색이 이어서 바뀌는 남은 글자 안내
사용 비율이 WARN_RATIO 를 넘으면 민트에서 앰버로, 한도를 넘으면 OVER_SPAN 안에서 핑크로 색을 선형으로 이어 주고, 남은 숫자는 1배에서 MAX_SCALE 인 1.8배까지 커진다. 문구는 여유·주의·위험 구간이 바뀔 때만 페이드로 교체되어 매 글자마다 흔들리지 않으니, 글자수 한도가 엄격한 입력에서 남은 글자를 가장 먼저 읽게 하고 싶을 때 쓴다. 08 항목은 누적면적 20.2%·강도 평균 103.7·23프레임 중 23프레임에서 움직였다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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); }
}
09낭독 폭주를 막는 aria-live 스로틀과 움직임 줄이기
숫자가 바뀔 때마다 스크린리더가 읽으면 입력을 방해하므로 ANNOUNCE_RATIOS 의 0.8 과 1 을 처음 넘는 순간에만 sr 문구를 바꾸고, 「움직임 줄이기」 버튼이나 운영체제 설정이 켜지면 보간을 모두 즉시 값으로 바꾼다. 스크린리더 사용자가 있는 폼의 기준이며, 데모의 「매번 낭독」 횟수는 비교를 위한 시연용 숫자이고 실제 aria-live 에는 넣지 않는다. 09 항목은 누적면적 4.7%·강도 평균 138.0·23프레임 중 23프레임에서 움직였다.

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<div class="cc__head">
<label class="cc__label" for="ccText">메모</label>
<button type="button" class="cc__btn" id="ccReduce" aria-pressed="false">움직임 줄이기</button>
</div>
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
<div class="cc__lanes" id="ccCount">
<div class="cc__lane">
<span class="cc__laneLabel">매번 낭독</span>
<span class="cc__laneNum cc__laneNum--raw"><b id="ccRaw">0</b>회</span>
<div class="cc__ticks" id="ccRawTicks" aria-hidden="true"></div>
</div>
<div class="cc__lane">
<span class="cc__laneLabel">임계만 낭독</span>
<span class="cc__laneNum"><b id="ccSafe">0</b>회</span>
<div class="cc__ticks" id="ccSafeTicks" aria-hidden="true"></div>
</div>
</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__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-3; }
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: rgba($stage-paper, .82);
}
.cc__btn {
padding: 0 $sp-3;
border: 1px solid rgba($stage-paper, .4);
border-radius: $r-pill;
background: transparent;
color: $stage-paper;
font-family: inherit;
font-size: 12px;
line-height: 22px;
font-weight: 600;
cursor: pointer;
}
.cc__btn[aria-pressed="true"] { background: $subject-lilac; border-color: $subject-lilac; color: $subject-ink; }
.cc__btn:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.cc__ta {
box-sizing: border-box;
width: 100%;
height: 48px;
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__lanes { display: grid; gap: $sp-2; }
.cc__lane {
display: grid;
grid-template-columns: 76px 48px 1fr;
align-items: center;
gap: $sp-2;
height: 32px;
padding: 0 $sp-3;
border-radius: $r-sm;
background: rgba($stage-paper, .08);
}
.cc__laneLabel { font-size: 12px; line-height: 16px; font-weight: 600; white-space: nowrap; color: rgba($stage-paper, .82); }
.cc__laneNum {
font-size: 12px;
font-weight: 600;
color: $subject-mint;
font-variant-numeric: tabular-nums;
}
.cc__laneNum b { font-size: 16px; line-height: 20px; font-weight: 700; }
.cc__laneNum--raw { color: $subject-pink; }
.cc__ticks { display: flex; align-items: center; gap: $sp-1; height: 20px; min-width: 0; overflow: hidden; }
.cc__ticks i {
flex: 0 0 auto;
width: 3px;
height: 12px;
border-radius: $r-xs;
background: rgba($stage-paper, .14);
}
.cc__lane:nth-child(1) .cc__ticks i.is-on { background: $subject-pink; }
.cc__lane:nth-child(2) .cc__ticks i.is-on { background: $subject-mint; }
.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__lane { grid-template-columns: 68px 40px 1fr; padding: 0 $sp-2; }
.cc__ticks i { width: 2px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__ticks i { 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__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: rgba(255, 255, 255, 0.82);
}
.cc__btn {
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 999px;
background: transparent;
color: #ffffff;
font-family: inherit;
font-size: 12px;
line-height: 22px;
font-weight: 600;
cursor: pointer;
}
.cc__btn[aria-pressed=true] {
background: #b9a0ff;
border-color: #b9a0ff;
color: #17141a;
}
.cc__btn:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.cc__ta {
box-sizing: border-box;
width: 100%;
height: 48px;
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__lanes {
display: grid;
gap: 8px;
}
.cc__lane {
display: grid;
grid-template-columns: 76px 48px 1fr;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
}
.cc__laneLabel {
font-size: 12px;
line-height: 16px;
font-weight: 600;
white-space: nowrap;
color: rgba(255, 255, 255, 0.82);
}
.cc__laneNum {
font-size: 12px;
font-weight: 600;
color: #4cd4a6;
font-variant-numeric: tabular-nums;
}
.cc__laneNum b {
font-size: 16px;
line-height: 20px;
font-weight: 700;
}
.cc__laneNum--raw {
color: #ff9ebb;
}
.cc__ticks {
display: flex;
align-items: center;
gap: 4px;
height: 20px;
min-width: 0;
overflow: hidden;
}
.cc__ticks i {
flex: 0 0 auto;
width: 3px;
height: 12px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
}
.cc__lane:nth-child(1) .cc__ticks i.is-on {
background: #ff9ebb;
}
.cc__lane:nth-child(2) .cc__ticks i.is-on {
background: #4cd4a6;
}
.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__lane {
grid-template-columns: 68px 40px 1fr;
padding: 0 8px;
}
.cc__ticks i {
width: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__ticks i {
transform: none;
}
}(function () {
// 기법: 글자가 바뀔 때마다 aria-live 영역을 갱신하면 스크린 리더가 숫자를 끝없이 읽는다 (왼쪽 줄, 시연용 가짜 횟수).
// 여기서는 사용 비율이 임계 ANNOUNCE_RATIOS 를 처음 넘는 순간에만 실제 aria-live 영역에 문구를 넣는다 (오른쪽 줄).
// "움직임 줄이기" 를 켜거나 OS 설정이 reduce 면 모든 보간과 팝 효과를 건너뛰고 값을 즉시 그린다.
var ANNOUNCE_LIMIT = 100;
var ANNOUNCE_RATIOS = [0.8, 1];
var TICK_SLOTS = 20;
var POP_MS = 380;
var NUMBER_LERP_RATE = 18;
var START_MS = 200;
var CHAR_MS = 22;
var HOLD_MS = 1600;
var CHUNKS = ['오늘 저녁에', '친구들과', '새로 생긴', '식당에 가서', '따뜻한 국물과', '바삭한 튀김을', '나눠 먹기로', '했다 그런데', '자리가 모자라', '한참을', '기다려야 했고', '배가 고파서', '메뉴판만', '계속', '뒤적였다', '그래도', '맛은', '기대 이상이었다', '후기를', '남기려고', '사진도', '찍어 두었다', '다음엔', '더 일찍', '와야겠다'];
var DEMO_CHARS = Array.from(CHUNKS.join(' '));
var root = document.getElementById('ccRoot');
var ta = document.getElementById('ccText');
var btn = document.getElementById('ccReduce');
var rawEl = document.getElementById('ccRaw');
var safeEl = document.getElementById('ccSafe');
var rawTicks = document.getElementById('ccRawTicks');
var safeTicks = document.getElementById('ccSafeTicks');
var sr = document.getElementById('ccSr');
var osReduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var forced = false;
function reduced() { return osReduce || forced; }
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 makeTicks(box) {
var list = [];
for (var i = 0; i < TICK_SLOTS; i += 1) {
var t = document.createElement('i');
box.appendChild(t);
list.push({ el: t, on: false, born: -1 });
}
return list;
}
var rawList = makeTicks(rawTicks);
var safeList = makeTicks(safeTicks);
var rawCount = 0;
var safeCount = 0;
var rawShown = 0;
var safeShown = 0;
var lastLen = 0;
var passed = [false, false];
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function addTick(list, count, nowMs) {
var idx = Math.min(count, TICK_SLOTS) - 1;
if (idx < 0) return;
list[idx].on = true;
list[idx].el.className = 'is-on';
list[idx].born = reduced() ? -1 : nowMs;
}
function clearTicks(list) {
for (var i = 0; i < list.length; i += 1) { list[i].on = false; list[i].born = -1; list[i].el.className = ''; list[i].el.style.transform = ''; }
}
function paintNumbers() {
rawEl.textContent = String(Math.round(rawShown));
safeEl.textContent = String(Math.round(safeShown));
}
function update(nowMs) {
var len = countGraphemes(ta.value);
if (len === 0) {
rawCount = 0; safeCount = 0; passed = [false, false];
clearTicks(rawList); clearTicks(safeList);
lastLen = 0;
sr.textContent = '';
if (reduced()) { rawShown = 0; safeShown = 0; paintNumbers(); }
return;
}
if (len !== lastLen) {
rawCount += 1;
addTick(rawList, rawCount, nowMs);
}
var ratio = len / ANNOUNCE_LIMIT;
for (var i = 0; i < ANNOUNCE_RATIOS.length; i += 1) {
if (!passed[i] && ratio >= ANNOUNCE_RATIOS[i]) {
passed[i] = true;
safeCount += 1;
addTick(safeList, safeCount, nowMs);
sr.textContent = i === 1 ? '한도를 넘었어요' : '곧 한도예요';
}
}
lastLen = len;
if (reduced()) { rawShown = rawCount; safeShown = safeCount; paintNumbers(); }
}
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()); });
btn.addEventListener('click', function () {
forced = !forced;
btn.setAttribute('aria-pressed', forced ? 'true' : 'false');
if (reduced()) { rawShown = rawCount; safeShown = safeCount; paintNumbers(); }
});
function popScale(age) {
if (age < 0 || age >= POP_MS) return 1;
return 1 + 1.1 * Math.exp(-age / 80) * Math.cos(age / 50);
}
function paintTicks(list, nowMs) {
for (var i = 0; i < list.length; i += 1) {
if (list[i].born < 0) continue;
var s = popScale(nowMs - list[i].born);
list[i].el.style.transform = s === 1 ? '' : 'scaleY(' + s.toFixed(3) + ')';
if (s === 1) list[i].born = -1;
}
}
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) % (DEMO_CHARS.length * CHAR_MS + HOLD_MS);
var n = now < START_MS ? 0 : Math.min(DEMO_CHARS.length, Math.floor(cyc / CHAR_MS) + 1);
while (lastN !== n) {
lastN = n < lastN ? 0 : lastN + 1;
ta.value = DEMO_CHARS.slice(0, lastN).join('');
update(now);
}
}
if (reduced()) {
rawShown = rawCount;
safeShown = safeCount;
} else {
var k = 1 - Math.exp(-dt * NUMBER_LERP_RATE);
rawShown += (rawCount - rawShown) * k;
safeShown += (safeCount - safeShown) * k;
}
paintNumbers();
paintTicks(rawList, now);
paintTicks(safeList, now);
requestAnimationFrame(tick);
}
if (osReduce) {
stopDemo();
ta.value = DEMO_CHARS.join('');
rawCount = DEMO_CHARS.length;
safeCount = ANNOUNCE_RATIOS.length;
passed = [true, true];
lastLen = DEMO_CHARS.length;
for (var c = 1; c <= TICK_SLOTS; c += 1) addTick(rawList, c, 0);
for (var d = 1; d <= safeCount; d += 1) addTick(safeList, d, 0);
rawShown = rawCount;
safeShown = safeCount;
paintNumbers();
sr.textContent = '한도를 넘었어요';
} else {
requestAnimationFrame(tick);
}
})();// AriaLiveThrottle — vanilla 판(09-aria-live-throttled-counter-reduced-motion) 의 React 포트.
// 매 입력마다 낭독하는 줄(시연용 횟수)과 임계에서만 aria-live 에 문구를 넣는 줄을 나란히 센다.
// "움직임 줄이기" 를 켜거나 OS 설정이 reduce 면 보간·팝 효과 없이 값을 즉시 그린다.
import { useEffect, useRef } from "react";
import styles from "./AriaLiveThrottle.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 Tick = { el: HTMLElement; on: boolean; born: number };
const ANNOUNCE_LIMIT = 100;
const ANNOUNCE_RATIOS = [0.8, 1];
const TICK_SLOTS = 20;
const POP_MS = 380;
const NUMBER_LERP_RATE = 18;
const START_MS = 200;
const CHAR_MS = 22;
const HOLD_MS = 1600;
const CHUNKS = ["오늘 저녁에", "친구들과", "새로 생긴", "식당에 가서", "따뜻한 국물과", "바삭한 튀김을", "나눠 먹기로", "했다 그런데", "자리가 모자라", "한참을", "기다려야 했고", "배가 고파서", "메뉴판만", "계속", "뒤적였다", "그래도", "맛은", "기대 이상이었다", "후기를", "남기려고", "사진도", "찍어 두었다", "다음엔", "더 일찍", "와야겠다"];
const DEMO_CHARS = Array.from(CHUNKS.join(" "));
function makeSegmenter(): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity: "grapheme" }) : null;
}
function popScale(age: number): number {
if (age < 0 || age >= POP_MS) return 1;
return 1 + 1.1 * Math.exp(-age / 80) * Math.cos(age / 50);
}
export default function AriaLiveThrottle() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const rawRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const safeRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const rawTicksRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const safeTicksRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const btn = btnRef.current;
const rawEl = rawRef.current;
const safeEl = safeRef.current;
const rawBox = rawTicksRef.current;
const safeBox = safeTicksRef.current;
const sr = srRef.current;
if (!ta || !btn || !rawEl || !safeEl || !rawBox || !safeBox || !sr) return;
const osReduce = 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 forced = false;
const reduced = (): boolean => osReduce || forced;
function makeTicks(box: HTMLDivElement): Tick[] {
const list: Tick[] = [];
for (let i = 0; i < TICK_SLOTS; i += 1) {
const t = document.createElement("i");
box.appendChild(t);
list.push({ el: t, on: false, born: -1 });
}
return list;
}
const rawList = makeTicks(rawBox);
const safeList = makeTicks(safeBox);
let rawCount = 0;
let safeCount = 0;
let rawShown = 0;
let safeShown = 0;
let lastLen = 0;
let passed = [false, false];
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 addTick(list: Tick[], count: number, nowMs: number): void {
const idx = Math.min(count, TICK_SLOTS) - 1;
if (idx < 0) return;
list[idx].on = true;
list[idx].el.className = styles["is-on"];
list[idx].born = reduced() ? -1 : nowMs;
}
function clearTicks(list: Tick[]): void {
for (let i = 0; i < list.length; i += 1) {
list[i].on = false;
list[i].born = -1;
list[i].el.className = "";
list[i].el.style.transform = "";
}
}
function paintNumbers(): void {
if (!rawEl || !safeEl) return;
rawEl.textContent = String(Math.round(rawShown));
safeEl.textContent = String(Math.round(safeShown));
}
function update(nowMs: number): void {
if (!ta || !sr) return;
const len = countGraphemes(ta.value);
if (len === 0) {
rawCount = 0;
safeCount = 0;
passed = [false, false];
clearTicks(rawList);
clearTicks(safeList);
lastLen = 0;
sr.textContent = "";
if (reduced()) { rawShown = 0; safeShown = 0; paintNumbers(); }
return;
}
if (len !== lastLen) {
rawCount += 1;
addTick(rawList, rawCount, nowMs);
}
const ratio = len / ANNOUNCE_LIMIT;
for (let i = 0; i < ANNOUNCE_RATIOS.length; i += 1) {
if (!passed[i] && ratio >= ANNOUNCE_RATIOS[i]) {
passed[i] = true;
safeCount += 1;
addTick(safeList, safeCount, nowMs);
sr.textContent = i === 1 ? "한도를 넘었어요" : "곧 한도예요";
}
}
lastLen = len;
if (reduced()) { rawShown = rawCount; safeShown = safeCount; paintNumbers(); }
}
function stopDemo(): void { demo = false; }
const onInput = (): void => { stopDemo(); update(perfNow()); };
const onClick = (): void => {
forced = !forced;
btn.setAttribute("aria-pressed", forced ? "true" : "false");
if (reduced()) { rawShown = rawCount; safeShown = safeCount; paintNumbers(); }
};
ta.addEventListener("focus", stopDemo);
ta.addEventListener("keydown", stopDemo);
ta.addEventListener("paste", stopDemo);
ta.addEventListener("input", onInput);
btn.addEventListener("click", onClick);
function paintTicks(list: Tick[], nowMs: number): void {
for (let i = 0; i < list.length; i += 1) {
if (list[i].born < 0) continue;
const s = popScale(nowMs - list[i].born);
list[i].el.style.transform = s === 1 ? "" : "scaleY(" + s.toFixed(3) + ")";
if (s === 1) list[i].born = -1;
}
}
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) % (DEMO_CHARS.length * CHAR_MS + HOLD_MS);
const n = now < START_MS ? 0 : Math.min(DEMO_CHARS.length, Math.floor(cyc / CHAR_MS) + 1);
while (lastN !== n) {
lastN = n < lastN ? 0 : lastN + 1;
ta.value = DEMO_CHARS.slice(0, lastN).join("");
update(now);
}
}
if (reduced()) {
rawShown = rawCount;
safeShown = safeCount;
} else {
const k = 1 - Math.exp(-dt * NUMBER_LERP_RATE);
rawShown += (rawCount - rawShown) * k;
safeShown += (safeCount - safeShown) * k;
}
paintNumbers();
paintTicks(rawList, now);
paintTicks(safeList, now);
raf = requestAnimationFrame(tick);
}
if (osReduce) {
stopDemo();
ta.value = DEMO_CHARS.join("");
rawCount = DEMO_CHARS.length;
safeCount = ANNOUNCE_RATIOS.length;
passed = [true, true];
lastLen = DEMO_CHARS.length;
for (let c = 1; c <= TICK_SLOTS; c += 1) addTick(rawList, c, 0);
for (let d = 1; d <= safeCount; d += 1) addTick(safeList, d, 0);
rawShown = rawCount;
safeShown = safeCount;
paintNumbers();
sr.textContent = "한도를 넘었어요";
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
ta.removeEventListener("focus", stopDemo);
ta.removeEventListener("keydown", stopDemo);
ta.removeEventListener("paste", stopDemo);
ta.removeEventListener("input", onInput);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cc}>
<div className={styles.cc__head}>
<label className={styles.cc__label} htmlFor="ccText">메모</label>
<button type="button" className={styles.cc__btn} ref={btnRef} aria-pressed="false">움직임 줄이기</button>
</div>
<textarea className={styles.cc__ta} id="ccText" ref={taRef} aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
<div className={styles.cc__lanes} id="ccCount">
<div className={styles.cc__lane}>
<span className={styles.cc__laneLabel}>매번 낭독</span>
<span className={[styles.cc__laneNum, styles["cc__laneNum--raw"]].join(" ")}><b ref={rawRef}>0</b>회</span>
<div className={styles.cc__ticks} ref={rawTicksRef} aria-hidden="true"></div>
</div>
<div className={styles.cc__lane}>
<span className={styles.cc__laneLabel}>임계만 낭독</span>
<span className={styles.cc__laneNum}><b ref={safeRef}>0</b>회</span>
<div className={styles.cc__ticks} ref={safeTicksRef} aria-hidden="true"></div>
</div>
</div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}09 · 낭독 폭주를 막는 aria-live 스로틀과 움직임 줄이기 — 측정 누적면적 4.685% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
if (len !== lastLen) {
rawCount += 1;
addTick(rawList, rawCount, nowMs);
}
var ratio = len / ANNOUNCE_LIMIT;
for (var i = 0; i < ANNOUNCE_RATIOS.length; i += 1) {
if (!passed[i] && ratio >= ANNOUNCE_RATIOS[i]) {
passed[i] = true;
safeCount += 1;
addTick(safeList, safeCount, nowMs);
sr.textContent = i === 1 ? '한도를 넘었어요' : '곧 한도예요';
}
}
lastLen = len;
값이 장면을 바꾸는 곳 — 한도·링 스프링·경고 비율
링이 얼마나 빨리 차고 언제 색이 바뀌는지는 항목 개수가 아니라 이 표의 수치와 세 상수가 정한다.
| 항목 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 자소 묶음 세기 | 13.4% | 64.8 | 23/23 |
| 02 스프링 링 | 30.3% | 108.5 | 23/23 |
| 03 폭 가중 카운트 | 10.0% | 150.6 | 23/23 |
| 04 IME 대응 | 1.9% | 121.1 | 23/23 |
| 05 거울 레이어 | 14.4% | 117.8 | 22/23 |
| 06 붙여넣기 잘림 | 12.0% | 110.3 | 22/23 |
| 07 계기판 | 23.0% | 127.8 | 23/23 |
| 08 안내 문구 | 20.2% | 103.7 | 23/23 |
| 09 낭독 스로틀 | 4.7% | 138.0 | 23/23 |
03 은 9.97% 로 10% 바로 아래이고 04·09 는 그보다 훨씬 낮은데, 컴포넌트 편에서는 정상이다 — 04 는 숫자 두 개와 점이 전부라 면적이 작아도 조합 단계마다 값이 바뀌어 23프레임 모두 움직이고, 09 는 눈금 몇 개가 늘어날 뿐이다. 포스터는 타이핑 장면이라 02 의 흔들림이나 04 의 숫자 튐은 움직이는 데모에서 보인다. CHAR_LIMIT 은 링이 가득 차고 흔들리는 글자 수를, RING_SPRING_STIFFNESS 는 링이 목표를 따라가는 빠르기를, WARN_RATIO 는 앰버가 시작되는 비율을 정하는데, 02 의 감쇠비는 14 를 2√220 으로 나눈 약 0.47 이라 계단 응답에서 오버슛이 약 19% 나온다.
어디서 깨지는가 — 이모지·조합 중 입력·붙여넣기 폭주
폴백이 먼저 걸린다. 01 의 graphemes 는 Intl.Segmenter 가 없으면 Array.from 으로 코드 포인트 단위로 쪼개는데, 그러면 가족 이모지는 사람 셋과 이음 기호 둘로 다시 갈라지고 국기는 둘, 결합 문자가 붙은 café 는 다섯이 되어 이 편의 핵심 장면이 사라진다. 03 의 weightOf 는 묶음의 첫 코드 포인트만 보고 0x7e 를 넘으면 전부 폭 2 로 치므로, 한글이나 악센트 붙은 라틴 글자나 이모지도 같은 2로 센다. 폭 기준 한도를 쓰는 서비스는 서비스가 정한 폭 규칙을 이 함수에 그대로 옮겨 넣어야 한다.
06 은 paste 경로와 input 경로가 따로다. 붙여넣기는 setSelectionRange 로 캐럿을 붙여넣은 끝에 두지만, 타이핑처럼 input 이벤트로 한도를 넘는 경우를 받는 경로는 value 를 80묶음까지 잘라 다시 대입할 뿐 캐럿을 되돌리는 줄이 없다. 05 의 거울 레이어는 입력칸과 글꼴·패딩·줄 높이가 한 벌이어야 하고 입력칸 스크롤바를 숨겨 폭을 맞춰 둔 구조이므로, 입력칸의 글꼴이나 패딩만 따로 바꾸면 칠한 자리가 어긋난다. 01 의 글자 띠는 STRIP_SLOTS 16칸까지만 그리고 그 뒤는 마지막 16자만 보여 준다. 이 한계까지 포함한 아홉 개 소스를 그대로 열어 보려면 내려받은 압축 파일을 풀 때 비밀번호 jg6vwkst 을 입력한다.
변형 세 가지와 접근성
| 항목 | 상수 | 값 | 느낌 |
|---|---|---|---|
| 02 | CHAR_LIMIT | 120 → 60 | 한도를 줄이면 같은 144자 시연에서 링이 절반 길이에서 이미 가득 차고 앰버는 96자째가 아니라 48자째에, 핑크 흔들림은 60자째에 시작된다 |
| 02 | RING_SPRING_STIFFNESS | 220 → 110 | 감쇠 14 를 그대로 두고 강성을 낮추면 링이 더 느리게 따라가고 감쇠비가 0.47 에서 0.67 로 올라 오버슛이 약 19% 에서 약 6% 로 줄어든다 |
| 08 | WARN_RATIO | 0.8 → 0.9 | 100자 한도에서 앰버와 「곧 한도예요」 문구가 80자째에서 90자째로 늦어져 여유 구간이 10자 길어지고 주의 구간은 20자에서 10자로 줄어든다 |
prefers-reduced-motion: reduce 분기는 matchMedia 로 아홉 소스 모두에서 한 번씩 확인하므로, 동작 줄임 설정을 켠 방문자에게는 requestAnimationFrame 루프를 시작하지 않고 시연 텍스트를 즉시 채워 최종 상태를 한 번 그리며, 이후 입력은 보간 없이 바로 그려진다. 09 는 운영체제 설정과 같은 효과를 내는 「움직임 줄이기」 버튼(aria-pressed)도 따로 둔다. 키보드로 닿는 것은 입력칸 하나라 Tab 으로 바로 도달하고, 숫자는 입력마다 바뀌므로 aria-live="polite" 는 숫자가 아니라 숨은 상태 줄 한 곳에만 있으며 구간에 들어서거나 열 글자 단위 같은 드문 순간에만 문구를 넣는다. 입력칸은 label 과 연결되고 aria-describedby 로 카운트 요소를 가리킨다.
글자수는 length 로 세면 왜 틀리는가?
ta.value.length 는 사람이 보는 글자가 아니라 내부 코드 단위의 수라서 이모지나 결합 문자가 들어오면 실제 글자보다 크게 나온다 — 01 이 두 숫자를 나란히 놓은 것이 그래서다. 이 계산 위에 붙는 장치를 고르려면 입력칸 주변 부품으로 이어서 읽으면 된다. 칩 입력은 태그 입력 ui 9가지 — 칩 UI 코드로 만들기, 입력창 자체의 모양은 입력창 디자인 9가지 — 복붙하면 되는 폼 위젯, 붙여넣기 피드백은 클립보드 복사 버튼 UI 9종 — 코드·링크·텍스트 복사 피드백까지, 입력값의 표시 방식은 비밀번호 표시 아이콘 9가지 — 입력창 속 문자가 도트로 바뀌는 계산 에서 다룬다. 검색창 쪽 글자 카운트는 CSS 입력창 검색창 포커스 애니메이션 9종 — 한 줄 복붙으로 와 비교해 볼 만하다.
글자수 카운터를 쓰기 전에 자주 묻는 질문
textarea 글자수 제한은 넘으면 입력을 막아야 하나요?
이 편의 아홉 개는 대부분 막지 않고 알린다 — 02·05·08 은 넘친 글자를 그대로 두고 링·형광칠·색으로 표시만 하고, 06 만 붙여넣기가 한도를 넘을 때 앞부분만 받고 나머지를 버린다. 막을지 표시만 할지는 서비스가 정할 일이라 카운터는 계산과 신호만 맡기고 처리는 폼 쪽에 두는 편이 고치기 쉽다.
javascript textarea 글자수 표시를 length 한 줄로 쓰면 안 되나요?
영문과 숫자만 받는 입력이라면 충분하지만 이모지나 결합 문자가 들어올 수 있다면 01 처럼 Intl.Segmenter 로 묶음 단위를 세는 쪽이 사람이 보는 숫자와 맞는다. 그 객체가 없는 환경을 대비해 Array.from 폴백을 두되, 폴백에서는 이모지가 갈라진다는 한계를 알고 쓴다.
단어 수나 읽는 시간도 같이 보여 줘야 하나요?
글자 한도가 목적이면 02 나 08 한 개로 충분하고, 글쓰기 도구라면 07 처럼 네 숫자를 한 줄에 놓는 것이 자연스럽다. 읽는 시간은 분당 READ_CHARS_PER_MIN 으로 나눈 값일 뿐이라 서비스의 글 성격에 맞게 이 상수 하나만 바꾸면 된다.









