04IME-Safe Counter That Ignores Mid-Composition Flicker
It follows composition start and end events, holds the number during composition and bumps it once on commit, so the figure does not flicker while typing Korean or Japanese.
Best forForms with heavy Korean or Japanese input
STEP_MSPAUSE_MSBUMP_MSHOLD_MS

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<label class="cc__label" for="ccText">Note</label>
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="Type in Japanese"></textarea>
<div class="cc__pair">
<div class="cc__card">
<span class="cc__cardLabel">Raw count</span>
<span class="cc__num cc__num--raw" id="ccRaw">0</span>
</div>
<div class="cc__card cc__card--safe">
<span class="cc__cardLabel">Held count</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">Committed</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 이벤트로 조합 중인지 기억한다.
// Composing input 은 "Raw count" 숫자만 흔들고 "Held count" 숫자는 마지막 확정값에 붙잡아 둔다.
// 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 ? 'Composing' : 'Committed';
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 = 'Now ' + safeCount + ' chars';
}
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 = 'Now ' + safeCount + ' chars';
} 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-Safe Counter That Ignores Mid-Composition Flicker — Measured area 1.882% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Textarea Character Counter: 9 Grapheme-Aware UIs
The post this demo lives in
Textarea Character Counter: 9 Grapheme-Aware UIsA character count looks like one line of length, until one emoji counts as 2 and the number flickers mid-composition — s See all demos in this post


