01자소 묶음 단위로 세는 실시간 글자수
입력이 바뀔 때마다 문자열을 사람이 보는 한 글자 단위로 쪼개 세어, 이모지·결합 문자도 한 글자로 올라가는 카운터를 만든다.
쓰임글자수 표시의 기본 장면
STRIP_SLOTSPOP_MSCOUNT_LERP_RATEANNOUNCE_EVERY

<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 포함 · textarea 글자수 카운터 9가지 — 자소 단위로 세고 한도를 링으로 보여 주는 입력창
이 데모가 속한 글
textarea 글자수 카운터 9가지 — 자소 단위로 세고 한도를 링으로 보여 주는 입력창글자수는 length 한 줄로 끝날 것 같지만, 이모지 하나가 2로 올라가고 한글 조합 중에는 숫자가 깜박인다 — 01번과 04번이 그 두 군데를 하나씩 막는다. textarea 글자수 카운터는 입력이 바뀔 때마다 이 글의 데모 전부 보기


