03전각 2·반각 1로 세는 폭 가중 카운트
글자마다 코드 포인트 범위로 폭 가중치(반각 1·전각 2)를 매겨 합산하고, 일본어 전각과 영문 반각이 섞인 입력에서 칸 너비가 두 배씩 차이 나는 막대를 80폭 한도까지 그린다.
쓰임한도가 폭 기준인 입력칸
WIDTH_LIMITHALF_WEIGHTFULL_WEIGHTPOP_MS

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<label class="cc__label" for="ccText">메모</label>
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="섞어서 입력해 보세요"></textarea>
<div class="cc__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 포함 · textarea 글자수 카운터 9가지 — 자소 단위로 세고 한도를 링으로 보여 주는 입력창
이 데모가 속한 글
textarea 글자수 카운터 9가지 — 자소 단위로 세고 한도를 링으로 보여 주는 입력창글자수는 length 한 줄로 끝날 것 같지만, 이모지 하나가 2로 올라가고 한글 조합 중에는 숫자가 깜박인다 — 01번과 04번이 그 두 군데를 하나씩 막는다. textarea 글자수 카운터는 입력이 바뀔 때마다 이 글의 데모 전부 보기


