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 オリジナル · SCSS と React は zip に同梱 · テキストエリア 文字数カウント 9パターン — 書記素で数えて上限をリングで見せる
このデモが属する記事
テキストエリア 文字数カウント 9パターン — 書記素で数えて上限をリングで見せる文字数はlength一行で済みそうだが、絵文字が2と数えられ、変換中は数字がちらつく — 01番と04番がその二か所を一つずつ塞ぐ。テキストエリア 文字数カウンターは、入力が変わるたびに人が見る一文字ぶんを数え、残りの上限を知らせる数字です この記事のデモをすべて見る


