09낭독 폭주를 막는 aria-live 스로틀과 움직임 줄이기
숫자가 바뀔 때마다 읽어 주면 입력이 방해되므로 임계 구간에 들어설 때만 알리고, 움직임 줄이기 설정이면 모든 보간을 즉시 값으로 바꾼다.
쓰임스크린리더를 쓰는 사용자
ANNOUNCE_LIMITANNOUNCE_RATIOSTICK_SLOTSNUMBER_LERP_RATEPOP_MS

<main class="stage">
<div class="cc is-demo" id="ccRoot">
<div class="cc__head">
<label class="cc__label" for="ccText">메모</label>
<button type="button" class="cc__btn" id="ccReduce" aria-pressed="false">움직임 줄이기</button>
</div>
<textarea class="cc__ta" id="ccText" aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
<div class="cc__lanes" id="ccCount">
<div class="cc__lane">
<span class="cc__laneLabel">매번 낭독</span>
<span class="cc__laneNum cc__laneNum--raw"><b id="ccRaw">0</b>회</span>
<div class="cc__ticks" id="ccRawTicks" aria-hidden="true"></div>
</div>
<div class="cc__lane">
<span class="cc__laneLabel">임계만 낭독</span>
<span class="cc__laneNum"><b id="ccSafe">0</b>회</span>
<div class="cc__ticks" id="ccSafeTicks" aria-hidden="true"></div>
</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__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-3; }
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: rgba($stage-paper, .82);
}
.cc__btn {
padding: 0 $sp-3;
border: 1px solid rgba($stage-paper, .4);
border-radius: $r-pill;
background: transparent;
color: $stage-paper;
font-family: inherit;
font-size: 12px;
line-height: 22px;
font-weight: 600;
cursor: pointer;
}
.cc__btn[aria-pressed="true"] { background: $subject-lilac; border-color: $subject-lilac; color: $subject-ink; }
.cc__btn:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.cc__ta {
box-sizing: border-box;
width: 100%;
height: 48px;
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__lanes { display: grid; gap: $sp-2; }
.cc__lane {
display: grid;
grid-template-columns: 76px 48px 1fr;
align-items: center;
gap: $sp-2;
height: 32px;
padding: 0 $sp-3;
border-radius: $r-sm;
background: rgba($stage-paper, .08);
}
.cc__laneLabel { font-size: 12px; line-height: 16px; font-weight: 600; white-space: nowrap; color: rgba($stage-paper, .82); }
.cc__laneNum {
font-size: 12px;
font-weight: 600;
color: $subject-mint;
font-variant-numeric: tabular-nums;
}
.cc__laneNum b { font-size: 16px; line-height: 20px; font-weight: 700; }
.cc__laneNum--raw { color: $subject-pink; }
.cc__ticks { display: flex; align-items: center; gap: $sp-1; height: 20px; min-width: 0; overflow: hidden; }
.cc__ticks i {
flex: 0 0 auto;
width: 3px;
height: 12px;
border-radius: $r-xs;
background: rgba($stage-paper, .14);
}
.cc__lane:nth-child(1) .cc__ticks i.is-on { background: $subject-pink; }
.cc__lane:nth-child(2) .cc__ticks i.is-on { background: $subject-mint; }
.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__lane { grid-template-columns: 68px 40px 1fr; padding: 0 $sp-2; }
.cc__ticks i { width: 2px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__ticks i { 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__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.cc__label {
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: rgba(255, 255, 255, 0.82);
}
.cc__btn {
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 999px;
background: transparent;
color: #ffffff;
font-family: inherit;
font-size: 12px;
line-height: 22px;
font-weight: 600;
cursor: pointer;
}
.cc__btn[aria-pressed=true] {
background: #b9a0ff;
border-color: #b9a0ff;
color: #17141a;
}
.cc__btn:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.cc__ta {
box-sizing: border-box;
width: 100%;
height: 48px;
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__lanes {
display: grid;
gap: 8px;
}
.cc__lane {
display: grid;
grid-template-columns: 76px 48px 1fr;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
}
.cc__laneLabel {
font-size: 12px;
line-height: 16px;
font-weight: 600;
white-space: nowrap;
color: rgba(255, 255, 255, 0.82);
}
.cc__laneNum {
font-size: 12px;
font-weight: 600;
color: #4cd4a6;
font-variant-numeric: tabular-nums;
}
.cc__laneNum b {
font-size: 16px;
line-height: 20px;
font-weight: 700;
}
.cc__laneNum--raw {
color: #ff9ebb;
}
.cc__ticks {
display: flex;
align-items: center;
gap: 4px;
height: 20px;
min-width: 0;
overflow: hidden;
}
.cc__ticks i {
flex: 0 0 auto;
width: 3px;
height: 12px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
}
.cc__lane:nth-child(1) .cc__ticks i.is-on {
background: #ff9ebb;
}
.cc__lane:nth-child(2) .cc__ticks i.is-on {
background: #4cd4a6;
}
.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__lane {
grid-template-columns: 68px 40px 1fr;
padding: 0 8px;
}
.cc__ticks i {
width: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__ticks i {
transform: none;
}
}(function () {
// 기법: 글자가 바뀔 때마다 aria-live 영역을 갱신하면 스크린 리더가 숫자를 끝없이 읽는다 (왼쪽 줄, 시연용 가짜 횟수).
// 여기서는 사용 비율이 임계 ANNOUNCE_RATIOS 를 처음 넘는 순간에만 실제 aria-live 영역에 문구를 넣는다 (오른쪽 줄).
// "움직임 줄이기" 를 켜거나 OS 설정이 reduce 면 모든 보간과 팝 효과를 건너뛰고 값을 즉시 그린다.
var ANNOUNCE_LIMIT = 100;
var ANNOUNCE_RATIOS = [0.8, 1];
var TICK_SLOTS = 20;
var POP_MS = 380;
var NUMBER_LERP_RATE = 18;
var START_MS = 200;
var CHAR_MS = 22;
var HOLD_MS = 1600;
var CHUNKS = ['오늘 저녁에', '친구들과', '새로 생긴', '식당에 가서', '따뜻한 국물과', '바삭한 튀김을', '나눠 먹기로', '했다 그런데', '자리가 모자라', '한참을', '기다려야 했고', '배가 고파서', '메뉴판만', '계속', '뒤적였다', '그래도', '맛은', '기대 이상이었다', '후기를', '남기려고', '사진도', '찍어 두었다', '다음엔', '더 일찍', '와야겠다'];
var DEMO_CHARS = Array.from(CHUNKS.join(' '));
var root = document.getElementById('ccRoot');
var ta = document.getElementById('ccText');
var btn = document.getElementById('ccReduce');
var rawEl = document.getElementById('ccRaw');
var safeEl = document.getElementById('ccSafe');
var rawTicks = document.getElementById('ccRawTicks');
var safeTicks = document.getElementById('ccSafeTicks');
var sr = document.getElementById('ccSr');
var osReduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var forced = false;
function reduced() { return osReduce || forced; }
var seg = (typeof Intl !== 'undefined' && Intl.Segmenter) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
function countGraphemes(s) {
if (!seg) return Array.from(s).length;
return Array.from(seg.segment(s)).length;
}
function makeTicks(box) {
var list = [];
for (var i = 0; i < TICK_SLOTS; i += 1) {
var t = document.createElement('i');
box.appendChild(t);
list.push({ el: t, on: false, born: -1 });
}
return list;
}
var rawList = makeTicks(rawTicks);
var safeList = makeTicks(safeTicks);
var rawCount = 0;
var safeCount = 0;
var rawShown = 0;
var safeShown = 0;
var lastLen = 0;
var passed = [false, false];
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function addTick(list, count, nowMs) {
var idx = Math.min(count, TICK_SLOTS) - 1;
if (idx < 0) return;
list[idx].on = true;
list[idx].el.className = 'is-on';
list[idx].born = reduced() ? -1 : nowMs;
}
function clearTicks(list) {
for (var i = 0; i < list.length; i += 1) { list[i].on = false; list[i].born = -1; list[i].el.className = ''; list[i].el.style.transform = ''; }
}
function paintNumbers() {
rawEl.textContent = String(Math.round(rawShown));
safeEl.textContent = String(Math.round(safeShown));
}
function update(nowMs) {
var len = countGraphemes(ta.value);
if (len === 0) {
rawCount = 0; safeCount = 0; passed = [false, false];
clearTicks(rawList); clearTicks(safeList);
lastLen = 0;
sr.textContent = '';
if (reduced()) { rawShown = 0; safeShown = 0; paintNumbers(); }
return;
}
if (len !== lastLen) {
rawCount += 1;
addTick(rawList, rawCount, nowMs);
}
var ratio = len / ANNOUNCE_LIMIT;
for (var i = 0; i < ANNOUNCE_RATIOS.length; i += 1) {
if (!passed[i] && ratio >= ANNOUNCE_RATIOS[i]) {
passed[i] = true;
safeCount += 1;
addTick(safeList, safeCount, nowMs);
sr.textContent = i === 1 ? '한도를 넘었어요' : '곧 한도예요';
}
}
lastLen = len;
if (reduced()) { rawShown = rawCount; safeShown = safeCount; paintNumbers(); }
}
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()); });
btn.addEventListener('click', function () {
forced = !forced;
btn.setAttribute('aria-pressed', forced ? 'true' : 'false');
if (reduced()) { rawShown = rawCount; safeShown = safeCount; paintNumbers(); }
});
function popScale(age) {
if (age < 0 || age >= POP_MS) return 1;
return 1 + 1.1 * Math.exp(-age / 80) * Math.cos(age / 50);
}
function paintTicks(list, nowMs) {
for (var i = 0; i < list.length; i += 1) {
if (list[i].born < 0) continue;
var s = popScale(nowMs - list[i].born);
list[i].el.style.transform = s === 1 ? '' : 'scaleY(' + s.toFixed(3) + ')';
if (s === 1) list[i].born = -1;
}
}
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) % (DEMO_CHARS.length * CHAR_MS + HOLD_MS);
var n = now < START_MS ? 0 : Math.min(DEMO_CHARS.length, Math.floor(cyc / CHAR_MS) + 1);
while (lastN !== n) {
lastN = n < lastN ? 0 : lastN + 1;
ta.value = DEMO_CHARS.slice(0, lastN).join('');
update(now);
}
}
if (reduced()) {
rawShown = rawCount;
safeShown = safeCount;
} else {
var k = 1 - Math.exp(-dt * NUMBER_LERP_RATE);
rawShown += (rawCount - rawShown) * k;
safeShown += (safeCount - safeShown) * k;
}
paintNumbers();
paintTicks(rawList, now);
paintTicks(safeList, now);
requestAnimationFrame(tick);
}
if (osReduce) {
stopDemo();
ta.value = DEMO_CHARS.join('');
rawCount = DEMO_CHARS.length;
safeCount = ANNOUNCE_RATIOS.length;
passed = [true, true];
lastLen = DEMO_CHARS.length;
for (var c = 1; c <= TICK_SLOTS; c += 1) addTick(rawList, c, 0);
for (var d = 1; d <= safeCount; d += 1) addTick(safeList, d, 0);
rawShown = rawCount;
safeShown = safeCount;
paintNumbers();
sr.textContent = '한도를 넘었어요';
} else {
requestAnimationFrame(tick);
}
})();// AriaLiveThrottle — vanilla 판(09-aria-live-throttled-counter-reduced-motion) 의 React 포트.
// 매 입력마다 낭독하는 줄(시연용 횟수)과 임계에서만 aria-live 에 문구를 넣는 줄을 나란히 센다.
// "움직임 줄이기" 를 켜거나 OS 설정이 reduce 면 보간·팝 효과 없이 값을 즉시 그린다.
import { useEffect, useRef } from "react";
import styles from "./AriaLiveThrottle.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;
type Tick = { el: HTMLElement; on: boolean; born: number };
const ANNOUNCE_LIMIT = 100;
const ANNOUNCE_RATIOS = [0.8, 1];
const TICK_SLOTS = 20;
const POP_MS = 380;
const NUMBER_LERP_RATE = 18;
const START_MS = 200;
const CHAR_MS = 22;
const HOLD_MS = 1600;
const CHUNKS = ["오늘 저녁에", "친구들과", "새로 생긴", "식당에 가서", "따뜻한 국물과", "바삭한 튀김을", "나눠 먹기로", "했다 그런데", "자리가 모자라", "한참을", "기다려야 했고", "배가 고파서", "메뉴판만", "계속", "뒤적였다", "그래도", "맛은", "기대 이상이었다", "후기를", "남기려고", "사진도", "찍어 두었다", "다음엔", "더 일찍", "와야겠다"];
const DEMO_CHARS = Array.from(CHUNKS.join(" "));
function makeSegmenter(): SegmenterLike | null {
const C = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
return C ? new C(undefined, { granularity: "grapheme" }) : null;
}
function popScale(age: number): number {
if (age < 0 || age >= POP_MS) return 1;
return 1 + 1.1 * Math.exp(-age / 80) * Math.cos(age / 50);
}
export default function AriaLiveThrottle() {
const taRef = useRef<HTMLTextAreaElement | null>(null) as Box<HTMLTextAreaElement | null>;
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const rawRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const safeRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const rawTicksRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const safeTicksRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const ta = taRef.current;
const btn = btnRef.current;
const rawEl = rawRef.current;
const safeEl = safeRef.current;
const rawBox = rawTicksRef.current;
const safeBox = safeTicksRef.current;
const sr = srRef.current;
if (!ta || !btn || !rawEl || !safeEl || !rawBox || !safeBox || !sr) return;
const osReduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const seg = makeSegmenter();
const countGraphemes = (s: string): number =>
seg ? Array.from(seg.segment(s)).length : Array.from(s).length;
let demo = true;
let forced = false;
const reduced = (): boolean => osReduce || forced;
function makeTicks(box: HTMLDivElement): Tick[] {
const list: Tick[] = [];
for (let i = 0; i < TICK_SLOTS; i += 1) {
const t = document.createElement("i");
box.appendChild(t);
list.push({ el: t, on: false, born: -1 });
}
return list;
}
const rawList = makeTicks(rawBox);
const safeList = makeTicks(safeBox);
let rawCount = 0;
let safeCount = 0;
let rawShown = 0;
let safeShown = 0;
let lastLen = 0;
let passed = [false, false];
let lastN = -1;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function addTick(list: Tick[], count: number, nowMs: number): void {
const idx = Math.min(count, TICK_SLOTS) - 1;
if (idx < 0) return;
list[idx].on = true;
list[idx].el.className = styles["is-on"];
list[idx].born = reduced() ? -1 : nowMs;
}
function clearTicks(list: Tick[]): void {
for (let i = 0; i < list.length; i += 1) {
list[i].on = false;
list[i].born = -1;
list[i].el.className = "";
list[i].el.style.transform = "";
}
}
function paintNumbers(): void {
if (!rawEl || !safeEl) return;
rawEl.textContent = String(Math.round(rawShown));
safeEl.textContent = String(Math.round(safeShown));
}
function update(nowMs: number): void {
if (!ta || !sr) return;
const len = countGraphemes(ta.value);
if (len === 0) {
rawCount = 0;
safeCount = 0;
passed = [false, false];
clearTicks(rawList);
clearTicks(safeList);
lastLen = 0;
sr.textContent = "";
if (reduced()) { rawShown = 0; safeShown = 0; paintNumbers(); }
return;
}
if (len !== lastLen) {
rawCount += 1;
addTick(rawList, rawCount, nowMs);
}
const ratio = len / ANNOUNCE_LIMIT;
for (let i = 0; i < ANNOUNCE_RATIOS.length; i += 1) {
if (!passed[i] && ratio >= ANNOUNCE_RATIOS[i]) {
passed[i] = true;
safeCount += 1;
addTick(safeList, safeCount, nowMs);
sr.textContent = i === 1 ? "한도를 넘었어요" : "곧 한도예요";
}
}
lastLen = len;
if (reduced()) { rawShown = rawCount; safeShown = safeCount; paintNumbers(); }
}
function stopDemo(): void { demo = false; }
const onInput = (): void => { stopDemo(); update(perfNow()); };
const onClick = (): void => {
forced = !forced;
btn.setAttribute("aria-pressed", forced ? "true" : "false");
if (reduced()) { rawShown = rawCount; safeShown = safeCount; paintNumbers(); }
};
ta.addEventListener("focus", stopDemo);
ta.addEventListener("keydown", stopDemo);
ta.addEventListener("paste", stopDemo);
ta.addEventListener("input", onInput);
btn.addEventListener("click", onClick);
function paintTicks(list: Tick[], nowMs: number): void {
for (let i = 0; i < list.length; i += 1) {
if (list[i].born < 0) continue;
const s = popScale(nowMs - list[i].born);
list[i].el.style.transform = s === 1 ? "" : "scaleY(" + s.toFixed(3) + ")";
if (s === 1) list[i].born = -1;
}
}
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) % (DEMO_CHARS.length * CHAR_MS + HOLD_MS);
const n = now < START_MS ? 0 : Math.min(DEMO_CHARS.length, Math.floor(cyc / CHAR_MS) + 1);
while (lastN !== n) {
lastN = n < lastN ? 0 : lastN + 1;
ta.value = DEMO_CHARS.slice(0, lastN).join("");
update(now);
}
}
if (reduced()) {
rawShown = rawCount;
safeShown = safeCount;
} else {
const k = 1 - Math.exp(-dt * NUMBER_LERP_RATE);
rawShown += (rawCount - rawShown) * k;
safeShown += (safeCount - safeShown) * k;
}
paintNumbers();
paintTicks(rawList, now);
paintTicks(safeList, now);
raf = requestAnimationFrame(tick);
}
if (osReduce) {
stopDemo();
ta.value = DEMO_CHARS.join("");
rawCount = DEMO_CHARS.length;
safeCount = ANNOUNCE_RATIOS.length;
passed = [true, true];
lastLen = DEMO_CHARS.length;
for (let c = 1; c <= TICK_SLOTS; c += 1) addTick(rawList, c, 0);
for (let d = 1; d <= safeCount; d += 1) addTick(safeList, d, 0);
rawShown = rawCount;
safeShown = safeCount;
paintNumbers();
sr.textContent = "한도를 넘었어요";
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
ta.removeEventListener("focus", stopDemo);
ta.removeEventListener("keydown", stopDemo);
ta.removeEventListener("paste", stopDemo);
ta.removeEventListener("input", onInput);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cc}>
<div className={styles.cc__head}>
<label className={styles.cc__label} htmlFor="ccText">메모</label>
<button type="button" className={styles.cc__btn} ref={btnRef} aria-pressed="false">움직임 줄이기</button>
</div>
<textarea className={styles.cc__ta} id="ccText" ref={taRef} aria-describedby="ccCount" placeholder="글을 써 보세요"></textarea>
<div className={styles.cc__lanes} id="ccCount">
<div className={styles.cc__lane}>
<span className={styles.cc__laneLabel}>매번 낭독</span>
<span className={[styles.cc__laneNum, styles["cc__laneNum--raw"]].join(" ")}><b ref={rawRef}>0</b>회</span>
<div className={styles.cc__ticks} ref={rawTicksRef} aria-hidden="true"></div>
</div>
<div className={styles.cc__lane}>
<span className={styles.cc__laneLabel}>임계만 낭독</span>
<span className={styles.cc__laneNum}><b ref={safeRef}>0</b>회</span>
<div className={styles.cc__ticks} ref={safeTicksRef} aria-hidden="true"></div>
</div>
</div>
<p className={styles.cc__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}09 낭독 폭주를 막는 aria-live 스로틀과 움직임 줄이기 — 측정 누적면적 4.685% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · textarea 글자수 카운터 9가지 — 자소 단위로 세고 한도를 링으로 보여 주는 입력창
이 데모가 속한 글
textarea 글자수 카운터 9가지 — 자소 단위로 세고 한도를 링으로 보여 주는 입력창글자수는 length 한 줄로 끝날 것 같지만, 이모지 하나가 2로 올라가고 한글 조합 중에는 숫자가 깜박인다 — 01번과 04번이 그 두 군데를 하나씩 막는다. textarea 글자수 카운터는 입력이 바뀔 때마다 이 글의 데모 전부 보기


