05上限を超えた後ろだけを示すミラーレイヤー
入力欄の裏に同じ書体のミラーレイヤーを重ね、上限以降の文字にだけ蛍光の背景を塗り、スクロールを揃えてずれないようにする。
使いどころ超えた部分をすぐ直させる場面
MIRROR_LIMITFLASH_MSCHUNK_MS

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<label class="cc__label" for="ccText">メモ</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="書いてみてください"></textarea>
</div>
<div class="cc__row">
<div class="cc__big"><span class="cc__num" id="ccCount">0</span><span class="cc__unit">/ 60文字</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 = ['今夜', '友達と', '新しくできた', '食堂に行って', '温かいスープと', 'サクサクの揚げ物を', '分け合うことにした', 'けれど', '席が足りなくて', 'しばらく', '待たなければならず', 'お腹が空いて', 'メニューばかり', 'ずっと', 'めくっていた', 'それでも', '味は', '期待以上だった', 'レビューを', '残そうと', '写真も', '撮っておいた', '次は', 'もっと早く', '来よう'];
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 + '文字' : '';
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 ? '上限を超えました' : '';
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 上限を超えた後ろだけを示すミラーレイヤー — 計測 累積面積 14.367% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · テキストエリア 文字数カウント 9パターン — 書記素で数えて上限をリングで見せる
このデモが属する記事
テキストエリア 文字数カウント 9パターン — 書記素で数えて上限をリングで見せる文字数はlength一行で済みそうだが、絵文字が2と数えられ、変換中は数字がちらつく — 01番と04番がその二か所を一つずつ塞ぐ。テキストエリア 文字数カウンターは、入力が変わるたびに人が見る一文字ぶんを数え、残りの上限を知らせる数字です この記事のデモをすべて見る


