05Mirror Layer That Marks Only the Overflow
A mirror layer in the same font sits behind the field and paints a highlight only on characters past the limit, with its scroll kept in step so the two never drift.
Best forWhere the user must fix the overflow
MIRROR_LIMITFLASH_MSCHUNK_MS

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<label class="cc__label" for="ccText">Note</label>
<div class="cc__field">
<div class="cc__mirror" id="ccMirror" aria-hidden="true"></div>
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="Write something"></textarea>
</div>
<div class="cc__row">
<div class="cc__big"><span class="cc__num" id="ccCount">0</span><span class="cc__unit">/ 60 chars</span></div>
<span class="cc__over" id="ccOver"></span>
</div>
<p class="cc__sr" id="ccSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 거울(.cc__mirror)과 입력칸(.cc__ta)은 글꼴·줄높이·패딩·테두리·줄바꿈 규칙이 한 벌이다.
// 하나라도 다르면 형광 칠이 글자에서 어긋난다.
@include stage($stage-paper);
$cc-font-size: 13px;
$cc-line: 18px;
.cc {
width: min(100%, 420px);
display: grid;
gap: $sp-2;
color: $subject-ink;
}
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: $subject-ink;
}
.cc__field {
position: relative;
height: 72px;
border-radius: $r-sm;
background: rgba($subject-ink, .04);
}
.cc__mirror,
.cc__ta {
box-sizing: border-box;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: $sp-2 $sp-3;
border: 1px solid rgba($subject-ink, .32);
border-radius: $r-sm;
font-family: inherit;
font-size: $cc-font-size;
line-height: $cc-line;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: normal;
}
.cc__mirror {
overflow: hidden;
color: transparent;
border-color: transparent;
pointer-events: none;
}
.cc__ta {
resize: none;
overflow-y: auto;
scrollbar-width: none;
background: transparent;
color: $subject-ink;
}
.cc__ta::-webkit-scrollbar { display: none; }
.cc__ta::placeholder { color: rgba($subject-ink, .64); }
.cc__ta:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.cc__mark {
color: transparent;
border-radius: $r-xs;
background: rgba($subject-pink, .5);
}
.cc__row { display: flex; align-items: center; justify-content: space-between; gap: $sp-3; min-height: 28px; }
.cc__big { display: flex; align-items: baseline; gap: $sp-1; }
.cc__num {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: $subject-ink;
font-variant-numeric: tabular-nums;
}
.cc__unit { font-size: 12px; font-weight: 600; color: rgba($subject-ink, .78); }
.cc__over {
padding: 0 $sp-2;
border-radius: $r-pill;
font-size: 12px;
line-height: 20px;
font-weight: 600;
color: $subject-ink;
background: transparent;
}
.cc__over.is-on { 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: 20px; line-height: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__mark { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
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: #17141a;
}
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: #17141a;
}
.cc__field {
position: relative;
height: 72px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.04);
}
.cc__mirror,
.cc__ta {
box-sizing: border-box;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 8px 12px;
border: 1px solid rgba(23, 20, 26, 0.32);
border-radius: 8px;
font-family: inherit;
font-size: 13px;
line-height: 18px;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: normal;
}
.cc__mirror {
overflow: hidden;
color: transparent;
border-color: transparent;
pointer-events: none;
}
.cc__ta {
resize: none;
overflow-y: auto;
scrollbar-width: none;
background: transparent;
color: #17141a;
}
.cc__ta::-webkit-scrollbar {
display: none;
}
.cc__ta::placeholder {
color: rgba(23, 20, 26, 0.64);
}
.cc__ta:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cc__mark {
color: transparent;
border-radius: 4px;
background: rgba(255, 158, 187, 0.5);
}
.cc__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
}
.cc__big {
display: flex;
align-items: baseline;
gap: 4px;
}
.cc__num {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.cc__unit {
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.78);
}
.cc__over {
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
line-height: 20px;
font-weight: 600;
color: #17141a;
background: transparent;
}
.cc__over.is-on {
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: 20px;
line-height: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__mark {
transition: none;
}
}(function () {
// 기법: 입력칸 뒤에 같은 글꼴·패딩·줄바꿈 규칙의 거울 레이어를 깔고, 거울 안에서
// 한도 이후의 글자만 mark 로 감싸 분홍 배경을 칠한다. 입력칸은 투명이라 그 위로 비쳐 보인다.
// 스크롤은 입력칸 scrollTop 을 거울에 그대로 복사해 맞추고, 새로 넘친 글자는 처음에 진하게 칠했다가 가라앉는다.
var MIRROR_LIMIT = 60;
var FLASH_MS = 520;
var START_MS = 200;
var CHUNK_MS = 130;
var HOLD_MS = 1500;
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 mirror = document.getElementById('ccMirror');
var countEl = document.getElementById('ccCount');
var overEl = document.getElementById('ccOver');
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 mark = null;
var flashAt = -1;
var lastOver = 0;
var stage = 0;
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function syncScroll() { mirror.scrollTop = ta.scrollTop; }
function update(nowMs) {
var list = graphemes(ta.value);
var over = Math.max(0, list.length - MIRROR_LIMIT);
mirror.textContent = '';
mirror.appendChild(document.createTextNode(list.slice(0, MIRROR_LIMIT).join('')));
mark = null;
if (over > 0) {
mark = document.createElement('mark');
mark.className = 'cc__mark';
mark.textContent = list.slice(MIRROR_LIMIT).join('');
mirror.appendChild(mark);
}
mirror.appendChild(document.createTextNode(''));
syncScroll();
countEl.textContent = String(list.length);
countEl.style.color = over > 0 ? '#9a2b52' : '';
overEl.textContent = over > 0 ? 'Over ' + over + ' chars' : '';
overEl.className = 'cc__over' + (over > 0 ? ' is-on' : '');
if (over > lastOver && !reduce) flashAt = nowMs;
lastOver = over;
var next = over > 0 ? 1 : 0;
if (next !== stage) {
sr.textContent = next === 1 ? 'Over the limit' : '';
stage = next;
}
if (mark) paintMark(reduce ? 1e9 : nowMs);
}
function paintMark(nowMs) {
if (!mark) return;
var age = flashAt < 0 ? 1e9 : nowMs - flashAt;
var a = 0.5 + 0.5 * Math.exp(-age / (FLASH_MS / 3));
mark.style.backgroundColor = 'rgba(255,158,187,' + a.toFixed(3) + ')';
}
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()); });
ta.addEventListener('scroll', syncScroll);
var lastDemoN = -1;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
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) {
lastDemoN = n;
ta.value = CHUNKS.slice(0, n).join(' ');
ta.scrollTop = ta.scrollHeight;
update(now);
}
}
paintMark(now);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = DEMO_TEXT;
ta.scrollTop = ta.scrollHeight;
update(0);
} else {
requestAnimationFrame(tick);
}
})();// OverflowMirrorLayer — vanilla 판(05-overflow-mirror-layer-highlight) 의 React 포트.
// 입력칸 뒤 거울 레이어에서 한도 이후의 글자만 mark 로 감싸 분홍 배경을 칠한다. 스크롤은 거울에 그대로 복사한다.
import { useEffect, useRef } from "react";
import styles from "./OverflowMirrorLayer.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 MIRROR_LIMIT = 60;
const FLASH_MS = 520;
const START_MS = 200;
const CHUNK_MS = 130;
const HOLD_MS = 1500;
const CHUNKS = ["오늘 저녁에", "친구들과", "새로 생긴", "식당에 가서", "따뜻한 국물과", "바삭한 튀김을", "나눠 먹기로", "했다 그런데", "자리가 모자라", "한참을", "기다려야 했고", "배가 고파서", "메뉴판만", "계속", "뒤적였다", "그래도", "맛은", "기대 이상이었다", "후기를", "남기려고", "사진도", "찍어 두었다", "다음엔", "더 일찍", "와야겠다"];
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;
}
export default function OverflowMirrorLayer() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const mirrorRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const countRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const overRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const mirror = mirrorRef.current;
const countEl = countRef.current;
const overEl = overRef.current;
const sr = srRef.current;
if (!ta || !mirror || !countEl || !overEl || !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;
let mark: HTMLElement | null = null;
let flashAt = -1;
let lastOver = 0;
let stage = 0;
let lastDemoN = -1;
let t0: number | null = null;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function syncScroll(): void {
if (!ta || !mirror) return;
mirror.scrollTop = ta.scrollTop;
}
function paintMark(nowMs: number): void {
if (!mark) return;
const age = flashAt < 0 ? 1e9 : nowMs - flashAt;
const a = 0.5 + 0.5 * Math.exp(-age / (FLASH_MS / 3));
mark.style.backgroundColor = "rgba(255,158,187," + a.toFixed(3) + ")";
}
function update(nowMs: number): void {
if (!ta || !mirror || !countEl || !overEl || !sr) return;
const list = graphemes(ta.value);
const over = Math.max(0, list.length - MIRROR_LIMIT);
mirror.textContent = "";
mirror.appendChild(document.createTextNode(list.slice(0, MIRROR_LIMIT).join("")));
mark = null;
if (over > 0) {
mark = document.createElement("mark");
mark.className = styles.cc__mark;
mark.textContent = list.slice(MIRROR_LIMIT).join("");
mirror.appendChild(mark);
}
mirror.appendChild(document.createTextNode(""));
syncScroll();
countEl.textContent = String(list.length);
countEl.style.color = over > 0 ? "#9a2b52" : "";
overEl.textContent = over > 0 ? "넘침 " + over + "자" : "";
overEl.className = [styles.cc__over, over > 0 ? styles["is-on"] : ""].join(" ");
if (over > lastOver && !reduce) flashAt = nowMs;
lastOver = over;
const next = over > 0 ? 1 : 0;
if (next !== stage) {
sr.textContent = next === 1 ? "한도를 넘었어요" : "";
stage = next;
}
if (mark) paintMark(reduce ? 1e9 : nowMs);
}
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);
ta.addEventListener("scroll", syncScroll);
function tick(ts: number): void {
if (!ta) return;
if (t0 === null) t0 = ts;
const now = ts - t0;
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) {
lastDemoN = n;
ta.value = CHUNKS.slice(0, n).join(" ");
ta.scrollTop = ta.scrollHeight;
update(now);
}
}
paintMark(now);
raf = requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
ta.value = DEMO_TEXT;
ta.scrollTop = ta.scrollHeight;
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);
ta.removeEventListener("scroll", syncScroll);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cc}>
<label className={styles.cc__label} htmlFor="ccText">메모</label>
<div className={styles.cc__field}>
<div className={styles.cc__mirror} ref={mirrorRef} aria-hidden="true"></div>
<textarea className={styles.cc__ta} id="ccText" ref={taRef} aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
</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}>/ 60자</span></div>
<span className={styles.cc__over} ref={overRef}></span>
</div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}05 Mirror Layer That Marks Only the Overflow — Measured area 14.367% · moving frames 22/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
Inputs05 Payment method card that opens only the option you pickedCheckout Page Design — 9 Cart Patterns, No Library
Inputs01 Match highlight — locating the typed letters inside each suggestion and bolding that spanAutocomplete Input: 9 Search Dropdowns
Inputs06 Character counter that turns warning near the limit9 Form Validation UI Patterns — As You Type