07글자·단어·바이트·읽는 시간을 한 번에 재는 계기판
같은 텍스트에서 글자 수·단어 수·인코딩 바이트·분당 글자 수로 나눈 읽는 시간을 함께 계산해 네 개의 숫자가 동시에 굴러 바뀐다.
쓰임글쓰기 도구의 하단 바
READ_CHARS_PER_MINNUMBER_LERP_RATEANNOUNCE_EVERYBAR_CAPS

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


