07Meter for Characters, Words, Bytes and Reading Time
From one text it computes characters, words, encoded bytes and a reading time divided by a per-minute rate, and the four figures change together with a short tween.
Best forThe footer bar of a writing tool
READ_CHARS_PER_MINNUMBER_LERP_RATEANNOUNCE_EVERYBAR_CAPS

<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="Write something"></textarea>
<div class="cc__tiles" id="ccCount">
<div class="cc__tile cc__tile--chars"><span class="cc__tileLabel">Chars</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">Words</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">Bytes</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">Read time</span><span class="cc__num"><b id="ccTime">0</b><small>s</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 길이,
// 읽는 시간은 Chars 수를 분당 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 = ['Tonight', 'with pals', 'at the new', 'diner,', 'warm soup', 'and fries', 'to share.', 'But', 'seats ran out', 'so a long', 'wait began,', 'we got hungry', 'and just', 'kept', 'reading the menu.', 'Still,', 'the food', 'beat hopes.', 'A review', 'to write,', 'photos', 'were taken.', 'Next time', 'come earlier,', 'for sure.'];
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 = 'Now ' + target[0] + ' chars';
}
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 Meter for Characters, Words, Bytes and Reading Time — Measured area 22.963% · 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
If you like this effect - similar demos
Inputs09 Reduced motion — killing shimmer and stagger for a list that appears instantlyAutocomplete Input: 9 Search Dropdowns
Inputs05 Live validity value toggle — each time a digit value fills in, the whole number's validity value is recalculated and the display value changesCredit Card Input UI: 9 Patterns
Inputs06 Shake and clear on errorOTP Input UI: 9 States, Validation Included