タイピングインジケーターUI 9選 — 3つの点からグループチャットまで
「タイピングインジケーター」9選 — 9つのうち1つは入力中の時間が長いほどアバターの縁がゆっくり脈打ち、1つはグループチャットで入力者が変わるたびに残り時間の値で名札の幅を描き直し、もう1つは振幅配列の値で音声入力表示の棒の高さを計算し直します。
実際のメッセンジャーアプリのUI・ブランド・実名はどこにもなく、画面内の送信者・名前はすべて架空のものです。9項目のうち08(表示オフトグル)だけに実際のクリックリスナーがあり、残り8項目はタイマーまたはrequestAnimationFrameの固定周期による自動再生だけで実演します。すべての項目のデータ(間隔・周期・振幅配列など)は固定値のため、再生のたびに同じ場面が再現されます。
01 typing-indicator-dot-bounce-basic
02 typing-indicator-bubble-morph-in
03 typing-indicator-multi-user-stack
05 typing-indicator-read-receipt-merge
06 typing-indicator-group-chat-cycle
07 typing-indicator-voice-waveform
08 typing-indicator-mute-toggle-fade
09 typing-indicator-keyboard-bar-hint
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 3点バウンス — インデックスで順番を計算する基本形
- 02 吹き出しモーフィング — 3つの点が1つの吹き出しの大きさに合わさる転換
- 03 複数ユーザー重なり — 同時入力者数を名札の幅で計算する行
- 04 アバターパルス同期 — 入力継続時間で鼓動周期を求め直す円
- 05 既読マージ — 消えた時間差から合流の瞬間を求めるラベル
- 06 グループチャット循環 — 入力者が変わるたびに残り時間で幅を描き直すラベル
- 07 音声入力波形 — 振幅配列の値で棒の高さを描き直す表示
- 08 表示オフトグル — 状態値だけ透明度を補間するスイッチ
- 09 キーボードバーヒント — 仮想キーボードの高さ値で位置を求め直す表示
デモ
9項目とも、点の順番・経過時間・参加者数・時間差・残り時間・振幅配列・状態値・キーボード高さという値を毎フレームまたは一定周期で求め直し、遅延・幅・周期・ラベル・棒の高さ・透明度・位置という画面上の変化に変換するという同じ前提を共有しています。実測値は次のとおりです。
| 番号 | 項目 | 累積面積 | 強度平均 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 3点バウンス | 0.23% | 60.4 | 15/23 |
| 02 | 吹き出しモーフィング | 4.59% | 191.1 | 13/23 |
| 03 | 複数ユーザー重なり | 0.34% | 105.7 | 6/23 |
| 04 | アバターパルス同期 | 2.16% | 55.9 | 19/23 |
| 05 | 既読マージ | 3.03% | 78.3 | 9/23 |
| 06 | グループチャット循環 | 0.33% | 156.8 | 23/23 |
| 07 | 音声入力波形 | 1.03% | 187.9 | 23/23 |
| 08 | 表示オフトグル | 6.37% | 80.5 | 11/23 |
| 09 | キーボードバーヒント | 11.55% | 106.9 | 12/23 |
累積面積は09キーボードバーヒントが11.55%でもっとも広く、08表示オフトグル6.37%・02吹き出しモーフィング4.59%・05既読マージ3.03%・04アバターパルス同期2.16%・07音声入力波形1.03%・03複数ユーザー重なり0.34%・06グループチャット循環0.33%・01点3つバウンス0.23%の順です。01・06・03の面積が9項目中とくに小さいのは、点・ラベル・名札のように画面上で実際に占めるピクセルの場所自体が小さい要素だからで、欠陥ではありません。24フレームキャプチャ(デルタ23個)のうち動いたフレームは6(03)〜23(06・07)の間にあり、強度平均は55.9(04、もっとも低い)〜191.1(02、もっとも高い)の間で、9項目とも画面が動き続ける下限値に十分な余裕を持って収まっています。
タイピングインジケーターの解剖 — 9つの画面が計算するもの
そもそもタイピングインジケーターとは何を指すのか、この9つの画面はその答えを1つの見た目ではなく9通りの計算として示します。点が跳ねる順番・点と吹き出しの幅の差・同時入力者数・入力が続いた時間・2つの時刻の差・残り表示時間の比率・振幅配列の値・状態値・キーボードの高さという9種類の値が、毎フレームあるいは状態が切り替わった瞬間に読み直され、遅延・幅・周期・ラベル・棒の高さ・透明度・位置という画面上の変化に変換されています。
入力と出力 — 何を計算して何に変えるか
入力は点の順番の値(index)、点3つの合計幅と吹き出し1つの幅、getBoundingClientRectで測った「他N人」ラベルの実際の幅、入力が継続した時間、入力表示が消えた時刻とメッセージが届いた時刻の差、残り表示時間の比率、あらかじめ決めた振幅値の配列、クリックで切り替わる状態値(オン・オフ)、仮想キーボードの高さ値という9種類です。出力はCSSカスタムプロパティ(--delay・--w・--bw・--period)、transformのscaleX・scaleY・translateY、style.opacity、classListの切り替え、textContentです。01は--delayの値をCSSの@keyframes dotBounceへ一度渡すだけで以降JavaScriptは関与せず、04も--periodの値を@keyframes ringPulseへ渡し続けるだけで、跳ねる・脈打つ動き自体はキーフレームが担い、JavaScriptは周期や遅延の値だけを求め直しています。
01の自動再生と08だけの実際のクリックリスナー
9項目のうち01・02・03・04・05・06・07・09の8項目はタイマーまたはrequestAnimationFrameの固定周期による自動再生だけで実演されており、実際のクリック・ドラッグ・ポインターのリスナーは一つも付いていません。08(表示オフトグル)だけに実際のクリックリスナーが付いていて、押された瞬間の状態値を基準に同じ補間が最初から再生し直されます。「入力中 アニメーション」を探す人の多くはこの自動再生の8項目のような固定周期の演出を求めています。
実際のメッセンジャーAPIを使わない理由
このデモはどの項目も実際のWebSocket接続・サーバー通信・実キーを一切使いません。入力中・既読・音消しのような状態はタイマーと名前付きの定数だけで進み、実サービスに組み込む場合はこの状態遷移が起きる場所に実際のリアルタイムイベントの信号を差し込む必要があります。画面内の送信者名・アイコンはすべて架空のもので、特定のメッセンジャーアプリの名前・ロゴ・ブランドはどこにもありません。
画面別コード
「タイピングインジケーター」を探す人の多くは点3つが跳ねる基本形だけを思い浮かべますが、この9項目は吹き出しへの合流・複数人の重なり・既読との併存・グループの循環・音声波形・音消し・キーボードとの位置関係という異なる場面ごとに、実測すべき値も変わることを示しています。似た計算の考え方は、ボットの応答待ちを扱うAIチャットローディングアニメーションcss9選の再生ロジックとも軸が異なりますが、固定値だけで場面を再現する姿勢は共通しています。
013点バウンス — インデックスで順番を計算する基本形
入力中の状態になると点が3つ現れます。それぞれの点が跳ね始める時点は、点の順番の値(index)にDOT_STAGGER_MS120msという間隔を掛けて求め直し、--delayというCSSカスタムプロパティとして渡されます。

<main class="stage">
<section class="panel">
<div class="row">
<span class="avatar" aria-hidden="true"></span>
<div class="bubble" role="status" aria-live="polite" aria-label="入力中">
<span class="dot" data-i="0"></span>
<span class="dot" data-i="1"></span>
<span class="dot" data-i="2"></span>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.row {
display: flex;
align-items: center;
gap: $sp-3;
}
.avatar {
width: 32px;
height: 32px;
border-radius: $r-pill;
background: $subject-blue;
flex: none;
}
.bubble {
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: $subject-cream;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: $subject-ink;
animation-name: dotBounce;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-delay: var(--delay, 0ms);
}
@keyframes dotBounce {
0%, 60%, 100% { transform: translateY(0); opacity: .5; }
30% { transform: translateY(-6px); opacity: 1; }
}
@media (max-width: 340px) {
.bubble { padding: $sp-2 $sp-3; gap: $sp-1; }
.avatar { width: 26px; height: 26px; }
}
@media (prefers-reduced-motion: reduce) {
.dot { animation: none; opacity: .8; 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);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 999px;
background: #2f6df6;
flex: none;
}
.bubble {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 16px;
background: #fff7e6;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #17141a;
animation-name: dotBounce;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-delay: var(--delay, 0ms);
}
@keyframes dotBounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.5;
}
30% {
transform: translateY(-6px);
opacity: 1;
}
}
@media (max-width: 340px) {
.bubble {
padding: 8px 12px;
gap: 4px;
}
.avatar {
width: 26px;
height: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.dot {
animation: none;
opacity: 0.8;
transform: none;
}
}// 점 3개 바운스 — 각 점이 튀어 오르는 시작 시점을 점의 순서(index) 값에 간격을 곱해 다시 구한다.
// 계산 겹: 인덱스 × 간격(ms) → animation-delay 재계산(setProperty). 자동 재생만, 실사용자 리스너 없음.
(function () {
var DOT_STAGGER_MS = 120;
var dots = document.querySelectorAll('.dot');
if (!dots.length) return;
dots.forEach(function (dot) {
var i = Number(dot.dataset.i || 0);
dot.style.setProperty('--delay', (i * DOT_STAGGER_MS) + 'ms');
});
})();import { useEffect, useRef } from "react";
import styles from "./TypingDotBounceBasic.module.scss";
// 점 3개 바운스 — 각 점이 튀어 오르는 시작 시점을 점의 순서(index) 값에 간격을 곱해 다시 구한다.
// 계산 겹: 인덱스 × 간격(ms) → animation-delay 재계산(setProperty). 자동 재생만, 실사용자 리스너 없음.
const DOT_STAGGER_MS = 120;
export default function TypingDotBounceBasic() {
const dot0Ref = useRef<HTMLSpanElement>(null);
const dot1Ref = useRef<HTMLSpanElement>(null);
const dot2Ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const dots = [dot0Ref.current, dot1Ref.current, dot2Ref.current];
dots.forEach((dot, i) => {
if (!dot) return;
dot.style.setProperty("--delay", i * DOT_STAGGER_MS + "ms");
});
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.row}>
<span className={styles.avatar} aria-hidden="true" />
<div className={styles.bubble} role="status" aria-live="polite" aria-label="입력 중">
<span ref={dot0Ref} className={styles.dot} />
<span ref={dot1Ref} className={styles.dot} />
<span ref={dot2Ref} className={styles.dot} />
</div>
</div>
</section>
</div>
);
}01 · 3点バウンス — インデックスで順番を計算する基本形 — 計測 累積面積 0.225% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
indexに間隔を掛けてanimation-delayを求め直す基本形
dot.dataset.iから読んだ順番の値にDOT_STAGGER_MSを掛けるだけなので、点の数が2個に減っても4個に増えても間隔の計算式は変わりません。点の順番を掛けずに遅延値を固定で並べると、点の数が変わったときに間隔の計算が崩れます。
(function () {
var DOT_STAGGER_MS = 120;
var dots = document.querySelectorAll('.dot');
if (!dots.length) return;
dots.forEach(function (dot) {
var i = Number(dot.dataset.i || 0);
dot.style.setProperty('--delay', (i * DOT_STAGGER_MS) + 'ms');
});
})();
02吹き出しモーフィング — 3つの点が1つの吹き出しの大きさに合わさる転換
入力中の表示が終わった状態になると、点3つの合計幅(DOT_W56px)の値が吹き出し1つの幅(MSG_W168px)の値へeaseOutカーブで補間され、補間が終わる時点に合わせてメッセージ内容が場所を引き継ぎます。実際のメッセンジャーアプリの会話画面を想定していますが、画面内の送信者・文面はすべて架空のものです。

<main class="stage">
<section class="panel">
<div class="row">
<span class="avatar" aria-hidden="true"></span>
<div class="bubble" id="bubble" role="status" aria-live="polite">
<span class="dots" id="dots">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</span>
<span class="msg" id="msg">メッセージ受信</span>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.panel { display: flex; flex-direction: column; align-items: center; gap: $sp-4; }
.row { display: flex; align-items: center; gap: $sp-3; }
.avatar { width: 32px; height: 32px; border-radius: $r-pill; background: $subject-ink; flex: none; }
// --w 는 점 3개의 합산 폭에서 메시지 폭까지 JS 가 매 프레임 재계산해 넣는 값(px, 단위 없이 저장)
.bubble {
position: relative;
height: 44px;
width: calc(var(--w, 56) * 1px);
border-radius: $r-card;
background: $subject-ink;
overflow: hidden;
}
.dots, .msg {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: $subject-cream;
}
.msg {
color: $subject-cream;
font-size: 13px;
white-space: nowrap;
padding: 0 $sp-3;
opacity: 0;
}
@media (max-width: 340px) {
.bubble { height: 38px; }
.avatar { width: 26px; height: 26px; }
.msg { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.dots, .msg { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 999px;
background: #17141a;
flex: none;
}
.bubble {
position: relative;
height: 44px;
width: calc(var(--w, 56) * 1px);
border-radius: 16px;
background: #17141a;
overflow: hidden;
}
.dots, .msg {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff7e6;
}
.msg {
color: #fff7e6;
font-size: 13px;
white-space: nowrap;
padding: 0 12px;
opacity: 0;
}
@media (max-width: 340px) {
.bubble {
height: 38px;
}
.avatar {
width: 26px;
height: 26px;
}
.msg {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.dots, .msg {
transition: none;
}
}// 말풍선 모핑 — 점 세 개의 합산 너비 값을 말풍선 하나의 너비 값으로 보간한다.
// 계산 겹: 경과 비율 → 너비 값(px) 재계산 → CSS 커스텀 프로퍼티(--w)에 반영. 자동 재생만, 실사용자 리스너 없음.
(function () {
var bubble = document.getElementById('bubble');
var dots = document.getElementById('dots');
var msg = document.getElementById('msg');
if (!bubble || !dots || !msg) return;
var DOT_W = 56;
var MSG_W = 168;
var CYCLE_MS = 2000;
var MORPH_START = 0.10 * CYCLE_MS;
var MORPH_MS = 640;
var REVERT_AT = 0.62 * CYCLE_MS;
function easeOut(p) { return 1 - Math.pow(1 - p, 3); }
function apply(t) {
var inMorph = t >= MORPH_START && t < REVERT_AT;
var p = Math.min(Math.max((t - MORPH_START) / MORPH_MS, 0), 1);
var eased = easeOut(p);
var w = inMorph ? DOT_W + (MSG_W - DOT_W) * eased : DOT_W;
bubble.style.setProperty('--w', w.toFixed(1));
var done = inMorph && p >= 1;
dots.style.opacity = done ? '0' : '1';
msg.style.opacity = done ? '1' : '0';
}
apply(0);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { bubble.style.setProperty('--w', DOT_W); return; }
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./TypingBubbleMorphIn.module.scss";
// 말풍선 모핑 — 점 세 개의 합산 너비 값을 말풍선 하나의 너비 값으로 보간한다.
// 계산 겹: 경과 비율 → 너비 값(px) 재계산 → CSS 커스텀 프로퍼티(--w)에 반영. 자동 재생만, 실사용자 리스너 없음.
const DOT_W = 56;
const MSG_W = 168;
const CYCLE_MS = 2000;
const MORPH_START = 0.10 * CYCLE_MS;
const MORPH_MS = 640;
const REVERT_AT = 0.62 * CYCLE_MS;
function easeOut(p: number) {
return 1 - Math.pow(1 - p, 3);
}
export default function TypingBubbleMorphIn() {
const bubbleRef = useRef<HTMLDivElement>(null);
const dotsRef = useRef<HTMLSpanElement>(null);
const msgRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const bubble = bubbleRef.current;
const dots = dotsRef.current;
const msg = msgRef.current;
if (!bubble || !dots || !msg) return;
function apply(t: number) {
const inMorph = t >= MORPH_START && t < REVERT_AT;
const p = Math.min(Math.max((t - MORPH_START) / MORPH_MS, 0), 1);
const eased = easeOut(p);
const w = inMorph ? DOT_W + (MSG_W - DOT_W) * eased : DOT_W;
bubble!.style.setProperty("--w", w.toFixed(1));
const done = inMorph && p >= 1;
dots!.style.opacity = done ? "0" : "1";
msg!.style.opacity = done ? "1" : "0";
}
apply(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
bubble.style.setProperty("--w", String(DOT_W));
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.row}>
<span className={styles.avatar} aria-hidden="true" />
<div ref={bubbleRef} className={styles.bubble} role="status" aria-live="polite">
<span ref={dotsRef} className={styles.dots}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</span>
<span ref={msgRef} className={styles.msg}>메시지 도착</span>
</div>
</div>
</section>
</div>
);
}02 · 吹き出しモーフィング — 3つの点が1つの吹き出しの大きさに合わさる転換 — 計測 累積面積 4.594% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
経過比率をeaseOutで曲げてから幅の値を求め直す
CYCLE_MS2000msの周期のうちMORPH_START200msの位置で補間が始まり、MORPH_MS640msの間で--wが56から168へ進み、REVERT_AT1240msの位置で最初の状態へ戻ります。点や吹き出しの幅を求めずにdisplay:noneなどで即座に切り替えると、モーフィングの存在意義が消えてしまいます。
var DOT_W = 56;
var MSG_W = 168;
var CYCLE_MS = 2000;
var MORPH_START = 0.10 * CYCLE_MS;
var MORPH_MS = 640;
var REVERT_AT = 0.62 * CYCLE_MS;
function easeOut(p) { return 1 - Math.pow(1 - p, 3); }
function apply(t) {
var inMorph = t >= MORPH_START && t < REVERT_AT;
var p = Math.min(Math.max((t - MORPH_START) / MORPH_MS, 0), 1);
var eased = easeOut(p);
var w = inMorph ? DOT_W + (MSG_W - DOT_W) * eased : DOT_W;
bubble.style.setProperty('--w', w.toFixed(1));
var done = inMorph && p >= 1;
dots.style.opacity = done ? '0' : '1';
msg.style.opacity = done ? '1' : '0';
}
03複数ユーザー重なり — 同時入力者数を名札の幅で計算する行
同時に入力中の人が2人以上の状態になると、名前を並べる代わりに「+N」ラベルのテキストをmeasure要素に流し込み、getBoundingClientRectで実際の描画幅を人数ごとに実測して--bwへ反映します。

<main class="stage">
<section class="panel">
<div class="row" role="status" aria-live="polite" aria-label="複数人が入力中">
<div class="avatars">
<span class="avatar avatar--1" aria-hidden="true"></span>
<span class="avatar avatar--2" aria-hidden="true"></span>
<span class="avatar avatar--3" aria-hidden="true"></span>
</div>
<span class="badge" id="badge">+2</span>
</div>
<span class="measure" id="measure" aria-hidden="true">+2</span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.panel { display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.row { display: flex; align-items: center; gap: $sp-2; }
.avatars { display: flex; }
.avatar {
width: 28px;
height: 28px;
border-radius: $r-pill;
background: $subject-cream;
border: 2px solid $stage-orange;
margin-left: -$sp-2;
}
.avatar--1 { margin-left: 0; }
// --bw 는 배지 텍스트("+N")의 실측 너비(px, 단위 없이 저장)에서 다음 단계 너비로 보간되는 값
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
width: calc((var(--bw, 20) * 1px) + #{$sp-3 * 2});
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-size: 12px;
font-weight: 600;
}
.measure {
position: absolute;
visibility: hidden;
pointer-events: none;
white-space: nowrap;
font-size: 12px;
font-weight: 600;
}
@media (max-width: 340px) {
.avatar { width: 22px; height: 22px; }
.badge { height: 18px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.badge { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.avatars {
display: flex;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 999px;
background: #fff7e6;
border: 2px solid #ff4d1f;
margin-left: -8px;
}
.avatar--1 {
margin-left: 0;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
width: calc(var(--bw, 20) * 1px + 24px);
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-size: 12px;
font-weight: 600;
}
.measure {
position: absolute;
visibility: hidden;
pointer-events: none;
white-space: nowrap;
font-size: 12px;
font-weight: 600;
}
@media (max-width: 340px) {
.avatar {
width: 22px;
height: 22px;
}
.badge {
height: 18px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.badge {
transition: none;
}
}// 다중 사용자 겹침 — 동시 입력자 수가 바뀔 때마다 라벨 텍스트의 실제 렌더 너비를 재고 그 값으로 배지 너비를 다시 그린다.
// 계산 겹: 인원 수 → 라벨 텍스트 → getBoundingClientRect 실측 폭 → 배지 너비 보간. 자동 재생만, 실사용자 리스너 없음.
(function () {
var badge = document.getElementById('badge');
var measure = document.getElementById('measure');
if (!badge || !measure) return;
var COUNTS = [2, 3, 4];
var HOLD_MS = 620;
var MORPH_MS = 300;
function widthFor(n) {
measure.textContent = '+' + n;
return measure.getBoundingClientRect().width;
}
var widths = COUNTS.map(widthFor);
var CYCLE_MS = HOLD_MS * COUNTS.length;
function apply(t) {
var phase = Math.floor(t / HOLD_MS) % COUNTS.length;
var next = (phase + 1) % COUNTS.length;
var phaseT = t % HOLD_MS;
var p = Math.min(phaseT / MORPH_MS, 1);
var w = widths[phase] + (widths[next] - widths[phase]) * p;
badge.style.setProperty('--bw', w.toFixed(1));
badge.textContent = '+' + COUNTS[phase];
}
apply(0);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { badge.style.setProperty('--bw', widths[0]); badge.textContent = '+' + COUNTS[0]; return; }
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./TypingMultiUserStack.module.scss";
// 다중 사용자 겹침 — 동시 입력자 수가 바뀔 때마다 라벨 텍스트의 실제 렌더 너비를 재고 그 값으로 배지 너비를 다시 그린다.
// 계산 겹: 인원 수 → 라벨 텍스트 → getBoundingClientRect 실측 폭 → 배지 너비 보간. 자동 재생만, 실사용자 리스너 없음.
const COUNTS = [2, 3, 4];
const HOLD_MS = 620;
const MORPH_MS = 300;
export default function TypingMultiUserStack() {
const badgeRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const badge = badgeRef.current;
const measure = measureRef.current;
if (!badge || !measure) return;
function widthFor(n: number) {
measure!.textContent = "+" + n;
return measure!.getBoundingClientRect().width;
}
const widths = COUNTS.map(widthFor);
const CYCLE_MS = HOLD_MS * COUNTS.length;
function apply(t: number) {
const phase = Math.floor(t / HOLD_MS) % COUNTS.length;
const next = (phase + 1) % COUNTS.length;
const phaseT = t % HOLD_MS;
const p = Math.min(phaseT / MORPH_MS, 1);
const w = widths[phase] + (widths[next] - widths[phase]) * p;
badge!.style.setProperty("--bw", w.toFixed(1));
badge!.textContent = "+" + COUNTS[phase];
}
apply(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
badge.style.setProperty("--bw", String(widths[0]));
badge.textContent = "+" + COUNTS[0];
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.row} role="status" aria-live="polite" aria-label="동시 입력 중">
<div className={styles.avatars}>
<span className={`${styles.avatar} ${styles["avatar--1"]}`} aria-hidden="true" />
<span className={`${styles.avatar} ${styles["avatar--2"]}`} aria-hidden="true" />
<span className={`${styles.avatar} ${styles["avatar--3"]}`} aria-hidden="true" />
</div>
<span ref={badgeRef} className={styles.badge}>+2</span>
</div>
<span ref={measureRef} className={styles.measure} aria-hidden="true">+2</span>
</section>
</div>
);
}03 · 複数ユーザー重なり — 同時入力者数を名札の幅で計算する行 — 計測 累積面積 0.337% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
人数ごとに実測した幅の値を補間して描き直す
COUNTS[2,3,4]をHOLD_MS620msごとに巡り、MORPH_MS300msの間だけ次の人数の幅へ補間します。同時入力者数を数えずに名前をすべて並べると、人数が増えたときに行があふれたり文字が切れたりします。
function widthFor(n) {
measure.textContent = '+' + n;
return measure.getBoundingClientRect().width;
}
var widths = COUNTS.map(widthFor);
var CYCLE_MS = HOLD_MS * COUNTS.length;
function apply(t) {
var phase = Math.floor(t / HOLD_MS) % COUNTS.length;
var next = (phase + 1) % COUNTS.length;
var phaseT = t % HOLD_MS;
var p = Math.min(phaseT / MORPH_MS, 1);
var w = widths[phase] + (widths[next] - widths[phase]) * p;
badge.style.setProperty('--bw', w.toFixed(1));
badge.textContent = '+' + COUNTS[phase];
}
04アバターパルス同期 — 入力継続時間で鼓動周期を求め直す円
入力中の時間が長くなるほど、アバターの縁の鼓動周期(--period)が経過時間の値に比例して徐々に遅くなり、入力が止まった状態になると周期がBASE_PERIOD_MS900msという元の値に戻ります。
<main class="stage">
<section class="panel">
<div class="avatarWrap">
<span class="avatar" aria-hidden="true"></span>
<span class="ring" id="ring" aria-hidden="true"></span>
</div>
<span class="status" id="status" role="status" aria-live="polite">入力中</span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.panel { display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.avatarWrap { position: relative; width: 48px; height: 48px; }
.avatar {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: $subject-blue;
}
// --period 는 입력 지속 시간(초)에 비례해 setInterval 이 다시 계산해 넣는 박동 주기 값
.ring {
position: absolute;
inset: -6px;
border-radius: $r-pill;
border: 2px solid $subject-blue;
opacity: 0;
animation-duration: var(--period, 900ms);
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.ring.is-typing { animation-name: ringPulse; }
@keyframes ringPulse {
0% { transform: scale(1); opacity: .9; }
100% { transform: scale(1.4); opacity: 0; }
}
.status {
font-size: 13px;
color: $subject-ink;
}
@media (max-width: 340px) {
.avatarWrap { width: 40px; height: 40px; }
}
@media (prefers-reduced-motion: reduce) {
.ring { animation: none; opacity: 0; }
}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);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.avatarWrap {
position: relative;
width: 48px;
height: 48px;
}
.avatar {
position: absolute;
inset: 0;
border-radius: 999px;
background: #2f6df6;
}
.ring {
position: absolute;
inset: -6px;
border-radius: 999px;
border: 2px solid #2f6df6;
opacity: 0;
animation-duration: var(--period, 900ms);
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ring.is-typing {
animation-name: ringPulse;
}
@keyframes ringPulse {
0% {
transform: scale(1);
opacity: 0.9;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
.status {
font-size: 13px;
color: #17141a;
}
@media (max-width: 340px) {
.avatarWrap {
width: 40px;
height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.ring {
animation: none;
opacity: 0;
}
}// 아바타 펄스 동기화 — 입력이 지속된 시간이 길수록 박동 주기를 지속 시간 값에 비례해 다시 구한다.
// 계산 겹: 지속 시간(초, 데모 배속) → 주기(ms) 재계산 → setInterval 로 --period 갱신. 자동 재생만, 실사용자 리스너 없음.
(function () {
var ring = document.getElementById('ring');
var status = document.getElementById('status');
if (!ring || !status) return;
var BASE_PERIOD_MS = 900;
var PULSE_SLOWDOWN_MS_PER_SEC = 40;
var TICK_MS = 150;
var MAX_TICKS = 8;
var tick = 0;
ring.style.setProperty('--period', BASE_PERIOD_MS + 'ms');
function step() {
tick = (tick + 1) % (MAX_TICKS + 1);
var typing = tick > 0;
var period = BASE_PERIOD_MS + tick * PULSE_SLOWDOWN_MS_PER_SEC;
ring.style.setProperty('--period', period + 'ms');
ring.classList.toggle('is-typing', typing);
status.textContent = typing ? '入力中' : '待機';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { status.textContent = '待機'; return; }
setInterval(step, TICK_MS);
})();import { useEffect, useRef } from "react";
import styles from "./TypingAvatarPulseSync.module.scss";
// 아바타 펄스 동기화 — 입력이 지속된 시간이 길수록 박동 주기를 지속 시간 값에 비례해 다시 구한다.
// 계산 겹: 지속 시간(초, 데모 배속) → 주기(ms) 재계산 → setInterval 로 --period 갱신. 자동 재생만, 실사용자 리스너 없음.
const BASE_PERIOD_MS = 900;
const PULSE_SLOWDOWN_MS_PER_SEC = 40;
const TICK_MS = 150;
const MAX_TICKS = 8;
export default function TypingAvatarPulseSync() {
const ringRef = useRef<HTMLSpanElement>(null);
const statusRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const ring = ringRef.current;
const status = statusRef.current;
if (!ring || !status) return;
let tick = 0;
ring.style.setProperty("--period", BASE_PERIOD_MS + "ms");
function step() {
tick = (tick + 1) % (MAX_TICKS + 1);
const typing = tick > 0;
const period = BASE_PERIOD_MS + tick * PULSE_SLOWDOWN_MS_PER_SEC;
ring!.style.setProperty("--period", period + "ms");
ring!.classList.toggle(styles["is-typing"], typing);
status!.textContent = typing ? "입력 중" : "대기";
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
status.textContent = "대기";
return;
}
const id = setInterval(step, TICK_MS);
return () => clearInterval(id);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.avatarWrap}>
<span className={styles.avatar} aria-hidden="true" />
<span ref={ringRef} className={styles.ring} aria-hidden="true" />
</div>
<span ref={statusRef} className={styles.status} role="status" aria-live="polite">입력 중</span>
</section>
</div>
);
}04 · アバターパルス同期 — 入力継続時間で鼓動周期を求め直す円 — 計測 累積面積 2.16% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
tickの値にPULSE_SLOWDOWN_MS_PER_SECを掛けて周期を求め直す
TICK_MS150msごとにtickを1ずつ進め、period = BASE_PERIOD_MS + tick * PULSE_SLOWDOWN_MS_PER_SEC(40ms)で周期を求め直し、MAX_TICKS8で一周してから0に戻ります。入力継続時間を測らずに周期を固定値のままにすると、「長く入力中」という情報が画面から消えてしまいます。
var BASE_PERIOD_MS = 900;
var PULSE_SLOWDOWN_MS_PER_SEC = 40;
var TICK_MS = 150;
var MAX_TICKS = 8;
var tick = 0;
ring.style.setProperty('--period', BASE_PERIOD_MS + 'ms');
function step() {
tick = (tick + 1) % (MAX_TICKS + 1);
var typing = tick > 0;
var period = BASE_PERIOD_MS + tick * PULSE_SLOWDOWN_MS_PER_SEC;
ring.style.setProperty('--period', period + 'ms');
ring.classList.toggle('is-typing', typing);
status.textContent = typing ? '入力中' : '待機';
}
05既読マージ — 消えた時間差から合流の瞬間を求めるラベル
入力表示が消えた時点とメッセージが届いた時点の時間差(gap)をDate.now()の差で求め、その値がMERGE_GAP_MS400msという基準より小さい状態になると、入力表示と既読ラベルを1つの場所にまとめます。

<main class="stage">
<section class="panel">
<div class="thread">
<div class="dots" id="dots">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</div>
<div class="msgRow" id="msgRow">
<span class="msg">メッセージ受信</span>
<span class="receipt" id="receipt">既読</span>
</div>
</div>
<span class="sr" id="live" role="status" aria-live="polite"></span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.panel { display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.thread { display: flex; flex-direction: column; align-items: flex-end; gap: $sp-2; min-height: 40px; }
.dots {
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-card;
background: $subject-cream;
transition: opacity $dur-base $ease-out;
}
.dot { width: 6px; height: 6px; border-radius: 50%; background: $subject-ink; }
.msgRow {
display: inline-flex;
align-items: center;
gap: $sp-2;
opacity: 0;
transition: opacity $dur-base $ease-out;
}
.msg {
padding: $sp-2 $sp-3;
border-radius: $r-card;
background: $subject-blue-deep;
color: $subject-cream;
font-size: 13px;
}
.receipt {
font-size: 11px;
color: $subject-blue-light;
}
.receipt.is-merged {
margin-left: -$sp-1;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.msg { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.dots, .msgRow { 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);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.thread {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
min-height: 40px;
}
.dots {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 16px;
background: #fff7e6;
transition: opacity 300ms ease-out;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #17141a;
}
.msgRow {
display: inline-flex;
align-items: center;
gap: 8px;
opacity: 0;
transition: opacity 300ms ease-out;
}
.msg {
padding: 8px 12px;
border-radius: 16px;
background: #0a4fe8;
color: #fff7e6;
font-size: 13px;
}
.receipt {
font-size: 11px;
color: #5b8cf8;
}
.receipt.is-merged {
margin-left: -4px;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.msg {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.dots, .msgRow {
transition: none;
}
}// 既読 표시 병합 — 입력 표시가 사라진 시각과 메시지가 도착한 시각의 차를 재어 병합 여부를 판정한다.
// 계산 겹: Date.now() 두 시점의 차 → 기준값과 비교 → classList 로 병합 상태 반영. 자동 재생만, 실사용자 리스너 없음.
(function () {
var dots = document.getElementById('dots');
var msgRow = document.getElementById('msgRow');
var receipt = document.getElementById('receipt');
var live = document.getElementById('live');
if (!dots || !msgRow || !receipt || !live) return;
var MERGE_GAP_MS = 400;
var CYCLE_MS = 2000;
var VANISH_AT = 900;
var ARRIVE_DELAY_MS = 180;
var timer1 = null, timer2 = null;
function runCycle() {
dots.style.opacity = '1';
msgRow.style.opacity = '0';
receipt.classList.remove('is-merged');
timer1 = setTimeout(function () {
dots.style.opacity = '0';
var vanishAt = Date.now();
timer2 = setTimeout(function () {
var arrivedAt = Date.now();
var gap = arrivedAt - vanishAt;
var merged = gap < MERGE_GAP_MS;
msgRow.style.opacity = '1';
receipt.classList.toggle('is-merged', merged);
live.textContent = merged ? '統合表示' : '個別表示';
}, ARRIVE_DELAY_MS);
}, VANISH_AT);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
dots.style.opacity = '0';
msgRow.style.opacity = '1';
receipt.classList.add('is-merged');
return;
}
runCycle();
setInterval(function () {
clearTimeout(timer1);
clearTimeout(timer2);
runCycle();
}, CYCLE_MS);
})();import { useEffect, useRef } from "react";
import styles from "./TypingReadReceiptMerge.module.scss";
// 읽음 표시 병합 — 입력 표시가 사라진 시각과 메시지가 도착한 시각의 차를 재어 병합 여부를 판정한다.
// 계산 겹: Date.now() 두 시점의 차 → 기준값과 비교 → classList 로 병합 상태 반영. 자동 재생만, 실사용자 리스너 없음.
const MERGE_GAP_MS = 400;
const CYCLE_MS = 2000;
const VANISH_AT = 900;
const ARRIVE_DELAY_MS = 180;
export default function TypingReadReceiptMerge() {
const dotsRef = useRef<HTMLDivElement>(null);
const msgRowRef = useRef<HTMLDivElement>(null);
const receiptRef = useRef<HTMLSpanElement>(null);
const liveRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const dots = dotsRef.current;
const msgRow = msgRowRef.current;
const receipt = receiptRef.current;
const live = liveRef.current;
if (!dots || !msgRow || !receipt || !live) return;
let timer1: ReturnType<typeof setTimeout> | null = null;
let timer2: ReturnType<typeof setTimeout> | null = null;
function runCycle() {
dots!.style.opacity = "1";
msgRow!.style.opacity = "0";
receipt!.classList.remove(styles["is-merged"]);
timer1 = setTimeout(function () {
dots!.style.opacity = "0";
const vanishAt = Date.now();
timer2 = setTimeout(function () {
const arrivedAt = Date.now();
const gap = arrivedAt - vanishAt;
const merged = gap < MERGE_GAP_MS;
msgRow!.style.opacity = "1";
receipt!.classList.toggle(styles["is-merged"], merged);
live!.textContent = merged ? "합쳐진 표시" : "별도 표시";
}, ARRIVE_DELAY_MS);
}, VANISH_AT);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
dots.style.opacity = "0";
msgRow.style.opacity = "1";
receipt.classList.add(styles["is-merged"]);
return;
}
runCycle();
const id = setInterval(function () {
if (timer1) clearTimeout(timer1);
if (timer2) clearTimeout(timer2);
runCycle();
}, CYCLE_MS);
return () => {
clearInterval(id);
if (timer1) clearTimeout(timer1);
if (timer2) clearTimeout(timer2);
};
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.thread}>
<div ref={dotsRef} className={styles.dots}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
<div ref={msgRowRef} className={styles.msgRow}>
<span className={styles.msg}>메시지 도착</span>
<span ref={receiptRef} className={styles.receipt}>읽음</span>
</div>
</div>
<span ref={liveRef} className={styles.sr} role="status" aria-live="polite" />
</section>
</div>
);
}05 · 既読マージ — 消えた時間差から合流の瞬間を求めるラベル — 計測 累積面積 3.026% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
2つの時刻の差を基準値と比較して合流を判定する
入力表示が消えてからARRIVE_DELAY_MS180ms後にメッセージが届く設定のためgapはつねに180前後になりますが、値そのものは毎周期Date.now()から求め直しています。時間差を測らずに表示をつねに別々にする、またはつねにまとめると、合流判定という計算の意味が失われます。
function runCycle() {
dots.style.opacity = '1';
msgRow.style.opacity = '0';
receipt.classList.remove('is-merged');
timer1 = setTimeout(function () {
dots.style.opacity = '0';
var vanishAt = Date.now();
timer2 = setTimeout(function () {
var arrivedAt = Date.now();
var gap = arrivedAt - vanishAt;
var merged = gap < MERGE_GAP_MS;
msgRow.style.opacity = '1';
receipt.classList.toggle('is-merged', merged);
live.textContent = merged ? '統合表示' : '個別表示';
}, ARRIVE_DELAY_MS);
}, VANISH_AT);
}
06グループチャット循環 — 入力者が変わるたびに残り時間で幅を描き直すラベル
グループチャットで入力中の人がNAMES(A・B・C)の間で切り替わるたびに、前の人の名前が残る時間の比率(remain)を毎フレーム求め直し、fillのscaleXへ反映してから次の人の名前へ切り替えます。

<main class="stage">
<section class="panel">
<div class="row" role="status" aria-live="polite" aria-label="グループチャット入力表示">
<span class="who" id="who">A</span>
<span class="track"><span class="fill" id="fill"></span></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.panel { display: flex; align-items: center; }
.row {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: $subject-ink;
}
.who {
color: $subject-cream;
font-size: 14px;
font-weight: 600;
width: 18px;
}
.track {
position: relative;
width: 96px;
height: 4px;
border-radius: $r-xs;
background: rgba($subject-cream, .28);
overflow: hidden;
}
.fill {
position: absolute;
inset: 0;
border-radius: $r-xs;
background: $subject-cream;
transform-origin: left center;
}
@media (max-width: 340px) {
.track { width: 72px; }
}
@media (prefers-reduced-motion: reduce) {
.fill { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
align-items: center;
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 16px;
background: #17141a;
}
.who {
color: #fff7e6;
font-size: 14px;
font-weight: 600;
width: 18px;
}
.track {
position: relative;
width: 96px;
height: 4px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.28);
overflow: hidden;
}
.fill {
position: absolute;
inset: 0;
border-radius: 4px;
background: #fff7e6;
transform-origin: left center;
}
@media (max-width: 340px) {
.track {
width: 72px;
}
}
@media (prefers-reduced-motion: reduce) {
.fill {
transition: none;
}
}// 그룹 채팅 순환 — 입력자가 바뀔 때마다 이전 이름이 남아 있을 시간 값을 세어 그 값만큼 막대를 줄인다.
// 계산 겹: 남은 시간 비율 재계산 → scaleX 로 반영, 다음 입력자로 교체. 자동 재생만, 실사용자 리스너 없음.
(function () {
var who = document.getElementById('who');
var fill = document.getElementById('fill');
if (!who || !fill) return;
var NAMES = ['A', 'B', 'C'];
var HOLD_MS = 640;
var CYCLE_MS = HOLD_MS * NAMES.length;
function apply(t) {
var idx = Math.floor(t / HOLD_MS) % NAMES.length;
var phaseT = t % HOLD_MS;
var remain = 1 - phaseT / HOLD_MS;
who.textContent = NAMES[idx];
fill.style.transform = 'scaleX(' + remain.toFixed(3) + ')';
}
apply(0);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { fill.style.transform = 'scaleX(1)'; return; }
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./TypingGroupChatCycle.module.scss";
// 그룹 채팅 순환 — 입력자가 바뀔 때마다 이전 이름이 남아 있을 시간 값을 세어 그 값만큼 막대를 줄인다.
// 계산 겹: 남은 시간 비율 재계산 → scaleX 로 반영, 다음 입력자로 교체. 자동 재생만, 실사용자 리스너 없음.
const NAMES = ["A", "B", "C"];
const HOLD_MS = 640;
export default function TypingGroupChatCycle() {
const whoRef = useRef<HTMLSpanElement>(null);
const fillRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const who = whoRef.current;
const fill = fillRef.current;
if (!who || !fill) return;
function apply(t: number) {
const idx = Math.floor(t / HOLD_MS) % NAMES.length;
const phaseT = t % HOLD_MS;
const remain = 1 - phaseT / HOLD_MS;
who!.textContent = NAMES[idx];
fill!.style.transform = "scaleX(" + remain.toFixed(3) + ")";
}
apply(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
fill.style.transform = "scaleX(1)";
return;
}
let start: number | null = null;
let raf = 0;
const CYCLE_MS = HOLD_MS * NAMES.length;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.row} role="status" aria-live="polite" aria-label="그룹 채팅 입력 표시">
<span ref={whoRef} className={styles.who}>A</span>
<span className={styles.track}>
<span ref={fillRef} className={styles.fill} />
</span>
</div>
</section>
</div>
);
}06 · グループチャット循環 — 入力者が変わるたびに残り時間で幅を描き直すラベル — 計測 累積面積 0.329% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
remainの値をscaleXへ反映して次の人へ切り替える
HOLD_MS640msごとにNAMESを巡回し、phaseTが0から640msへ進む間にremainが1から0へ減っていきます。残り表示時間を測らずにラベル幅を固定値のままにすると、次の人に切り替わるタイミングが不自然になります。
var NAMES = ['A', 'B', 'C'];
var HOLD_MS = 640;
var CYCLE_MS = HOLD_MS * NAMES.length;
function apply(t) {
var idx = Math.floor(t / HOLD_MS) % NAMES.length;
var phaseT = t % HOLD_MS;
var remain = 1 - phaseT / HOLD_MS;
who.textContent = NAMES[idx];
fill.style.transform = 'scaleX(' + remain.toFixed(3) + ')';
}
07音声入力波形 — 振幅配列の値で棒の高さを描き直す表示
音声メッセージを録音中の状態になると、あらかじめ決めた16個の振幅値の配列(AMPLITUDES)をAMPLITUDE_TICK_MS80msごとに順に読み、棒ごとの高さ(scaleY)を求め直します。

<main class="stage">
<section class="panel">
<div class="waveform" role="status" aria-live="polite" aria-label="音声メッセージ録音中">
<span class="bar"></span><span class="bar"></span><span class="bar"></span><span class="bar"></span>
<span class="bar"></span><span class="bar"></span><span class="bar"></span><span class="bar"></span>
<span class="bar"></span><span class="bar"></span><span class="bar"></span><span class="bar"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.panel { display: flex; align-items: center; justify-content: center; }
.waveform {
display: flex;
align-items: center;
gap: $sp-1;
height: 40px;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: $subject-cream;
}
.bar {
width: 4px;
height: 100%;
border-radius: $r-xs;
background: $subject-ink;
transform: scaleY(.3);
transform-origin: center;
}
@media (max-width: 340px) {
.waveform { height: 32px; padding: $sp-2 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bar { transition: none; transform: scaleY(.5); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
align-items: center;
justify-content: center;
}
.waveform {
display: flex;
align-items: center;
gap: 4px;
height: 40px;
padding: 12px 16px;
border-radius: 16px;
background: #fff7e6;
}
.bar {
width: 4px;
height: 100%;
border-radius: 4px;
background: #17141a;
transform: scaleY(0.3);
transform-origin: center;
}
@media (max-width: 340px) {
.waveform {
height: 32px;
padding: 8px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bar {
transition: none;
transform: scaleY(0.5);
}
}// 음성 입력 파형 — 미리 정한 진폭 값 배열을 순서대로 읽어 막대마다 높이를 다시 계산한다.
// 계산 겹: 진폭 배열 인덱스 순회 → scaleY 재계산(고정 배열, 오디오 API 미호출). 자동 재생만, 실사용자 리스너 없음.
(function () {
var bars = document.querySelectorAll('.bar');
if (!bars.length) return;
var AMPLITUDE_TICK_MS = 80;
var AMPLITUDES = [.25, .55, .82, .38, .68, .92, .30, .60, .78, .42, .58, .88, .34, .64, .48, .70];
var idx = 0;
function tick() {
bars.forEach(function (bar, i) {
var v = AMPLITUDES[(idx + i) % AMPLITUDES.length];
bar.style.transform = 'scaleY(' + v.toFixed(2) + ')';
});
idx = (idx + 1) % AMPLITUDES.length;
}
tick();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
setInterval(tick, AMPLITUDE_TICK_MS);
})();import { useEffect, useRef } from "react";
import styles from "./TypingVoiceWaveform.module.scss";
// 음성 입력 파형 — 미리 정한 진폭 값 배열을 순서대로 읽어 막대마다 높이를 다시 계산한다.
// 계산 겹: 진폭 배열 인덱스 순회 → scaleY 재계산(고정 배열, 오디오 API 미호출). 자동 재생만, 실사용자 리스너 없음.
const AMPLITUDE_TICK_MS = 80;
const AMPLITUDES = [.25, .55, .82, .38, .68, .92, .30, .60, .78, .42, .58, .88, .34, .64, .48, .70];
const BAR_COUNT = 12;
export default function TypingVoiceWaveform() {
const barsRef = useRef<(HTMLSpanElement | null)[]>([]);
useEffect(() => {
let idx = 0;
function tick() {
barsRef.current!.forEach((bar, i) => {
if (!bar) return;
const v = AMPLITUDES[(idx + i) % AMPLITUDES.length];
bar.style.transform = "scaleY(" + v.toFixed(2) + ")";
});
idx = (idx + 1) % AMPLITUDES.length;
}
tick();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const id = setInterval(tick, AMPLITUDE_TICK_MS);
return () => clearInterval(id);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.waveform} role="status" aria-live="polite" aria-label="음성 메시지 녹음 중">
{Array.from({ length: BAR_COUNT }).map((_, i) => (
<span
key={i}
ref={(el: HTMLSpanElement | null) => { barsRef.current![i] = el; }}
className={styles.bar}
/>
))}
</div>
</section>
</div>
);
}07 · 音声入力波形 — 振幅配列の値で棒の高さを描き直す表示 — 計測 累積面積 1.029% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
配列のインデックスをずらして棒ごとの高さを求め直す
idxを1ずつ進めながら(idx + i) % AMPLITUDES.lengthで棒ごとに違う位置の振幅値を読むため、12本の棒が同じ配列から少しずつずれた高さを表示します。振幅配列を順に読まずに棒の高さをランダムや固定値のままにすると、波形らしく見えず再現性も失われます。
var AMPLITUDE_TICK_MS = 80;
var AMPLITUDES = [.25, .55, .82, .38, .68, .92, .30, .60, .78, .42, .58, .88, .34, .64, .48, .70];
var idx = 0;
function tick() {
bars.forEach(function (bar, i) {
var v = AMPLITUDES[(idx + i) % AMPLITUDES.length];
bar.style.transform = 'scaleY(' + v.toFixed(2) + ')';
});
idx = (idx + 1) % AMPLITUDES.length;
}
08表示オフトグル — 状態値だけ透明度を補間するスイッチ
表示がオフの状態になると、入力表示カードの透明度がOFF_OPACITY0.3という値まで補間されて薄くなり、aria-pressedと読み上げ用のラベルが同時に切り替わります。「タイピングインジケーター オフ」を探す人が求めているのはこの状態値と透明度の対応です。

<main class="stage">
<section class="panel">
<div class="card" id="card">
<div class="dots"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
<span class="label">入力中</span>
</div>
<button class="muteBtn" id="muteBtn" type="button" aria-pressed="false" aria-label="入力表示のミュート切替">
ミュート
</button>
<span class="badge" id="muteBadge" hidden>表示オフ</span>
<span class="sr" id="live" role="status" aria-live="polite"></span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.panel {
display: flex;
align-items: center;
gap: $sp-4;
}
.card {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: $subject-ink;
transition: opacity $dur-base $ease-out;
}
.dots { display: flex; gap: $sp-1; }
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: $subject-cream;
}
.label {
font-size: 13px;
color: $subject-cream;
}
.muteBtn {
padding: $sp-2 $sp-3;
border-radius: $r-control;
border: 1px solid $subject-ink;
background: transparent;
color: $subject-ink;
font-size: 13px;
cursor: pointer;
}
.muteBtn[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.badge {
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: $error-soft;
color: $error;
font-size: 12px;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
@media (prefers-reduced-motion: reduce) {
.card { 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);
}
.panel {
display: flex;
align-items: center;
gap: 16px;
}
.card {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 16px;
background: #17141a;
transition: opacity 300ms ease-out;
}
.dots {
display: flex;
gap: 4px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #fff7e6;
}
.label {
font-size: 13px;
color: #fff7e6;
}
.muteBtn {
padding: 8px 12px;
border-radius: 12px;
border: 1px solid #17141a;
background: transparent;
color: #17141a;
font-size: 13px;
cursor: pointer;
}
.muteBtn[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.badge {
padding: 4px 8px;
border-radius: 4px;
background: #ffe0d8;
color: #c2361a;
font-size: 12px;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}// 표시 끄기 토글 — 상태 값(켜짐/꺼짐)만큼 카드의 투명도를 보간하고, 꺼짐 표시는 별도 배지로 대비를 지킨다.
// 계산 겹: 상태 값 → 목표 투명도 재계산 → transition 으로 보간. 클릭 리스너 1개(이 항목만).
(function () {
var btn = document.getElementById('muteBtn');
var card = document.getElementById('card');
var badge = document.getElementById('muteBadge');
var live = document.getElementById('live');
if (!btn || !card || !badge || !live) return;
var OFF_OPACITY = 0.3;
var AUTO_CYCLE_MS = 700;
var muted = false;
function apply() {
card.style.opacity = muted ? String(OFF_OPACITY) : '1';
badge.hidden = !muted;
btn.setAttribute('aria-pressed', String(muted));
live.textContent = muted ? '表示オフ' : '表示オン';
}
btn.addEventListener('click', function () {
muted = !muted;
apply();
});
apply();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
setInterval(function () {
muted = !muted;
apply();
}, AUTO_CYCLE_MS);
})();import { useEffect, useRef, useState } from "react";
import styles from "./TypingMuteToggleFade.module.scss";
// 표시 끄기 토글 — 상태 값(켜짐/꺼짐)만큼 카드의 투명도를 보간하고, 꺼짐 표시는 별도 배지로 대비를 지킨다.
// 계산 겹: 상태 값 → 목표 투명도 재계산 → transition 으로 보간. 클릭 리스너 1개(이 항목만).
const OFF_OPACITY = 0.3;
const AUTO_CYCLE_MS = 700;
export default function TypingMuteToggleFade() {
const [muted, setMuted] = useState(false);
const liveRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
if (liveRef.current) {
liveRef.current.textContent = muted ? "표시 꺼짐" : "표시 켜짐";
}
}, [muted]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const id = setInterval(() => setMuted((m) => !m), AUTO_CYCLE_MS);
return () => clearInterval(id);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div
className={styles.card}
style={{ opacity: muted ? OFF_OPACITY : 1 }}
>
<div className={styles.dots}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
<span className={styles.label}>입력 중</span>
</div>
<button
type="button"
className={styles.muteBtn}
aria-pressed={muted}
aria-label="입력 표시 음소거 전환"
onClick={() => setMuted((m) => !m)}
>
음소거
</button>
{muted && <span className={styles.badge}>표시 꺼짐</span>}
<span ref={liveRef} className={styles.sr} role="status" aria-live="polite" />
</section>
</div>
);
}08 · 表示オフトグル — 状態値だけ透明度を補間するスイッチ — 計測 累積面積 6.372% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
クリックされた状態値を基準にopacityを補間する
btnに付いた実際のクリックリスナーがmutedを反転させ、apply()がcard.style.opacityとbadge.hiddenとaria-pressedをまとめて更新します。状態値を補間せずに透明度を即座に0と1へ切り替えると、オン・オフの切り替えがぶつっと途切れて見えます。
var OFF_OPACITY = 0.3;
var AUTO_CYCLE_MS = 700;
var muted = false;
function apply() {
card.style.opacity = muted ? String(OFF_OPACITY) : '1';
badge.hidden = !muted;
btn.setAttribute('aria-pressed', String(muted));
live.textContent = muted ? '表示オフ' : '表示オン';
}
btn.addEventListener('click', function () {
muted = !muted;
apply();
});
09キーボードバーヒント — 仮想キーボードの高さ値で位置を求め直す表示
モバイル画面で仮想キーボードが開いた状態になると、キーボードが占める高さの値(heightAt)をイージングで毎フレーム求め直し、その値だけ入力表示の下側の位置(translateY)を求め直してキーボードバーのすぐ上に付けます。

<main class="stage">
<section class="panel">
<div class="screen">
<span class="indicator" id="indicator">入力中…</span>
<div class="keyboard" id="keyboard"></div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.panel { display: flex; align-items: center; justify-content: center; }
.screen {
position: relative;
width: 220px;
height: 140px;
overflow: hidden;
border-radius: $r-card;
background: $subject-blue-deep;
}
.indicator {
position: absolute;
left: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-size: 12px;
will-change: transform;
}
.keyboard {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 64px;
background: $subject-blue;
border-top: 1px solid $subject-cream;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.indicator, .keyboard { 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);
}
.panel {
display: flex;
align-items: center;
justify-content: center;
}
.screen {
position: relative;
width: 220px;
height: 140px;
overflow: hidden;
border-radius: 16px;
background: #0a4fe8;
}
.indicator {
position: absolute;
left: 12px;
bottom: 12px;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-size: 12px;
will-change: transform;
}
.keyboard {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 64px;
background: #2f6df6;
border-top: 1px solid #fff7e6;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.indicator, .keyboard {
transition: none;
}
}// 키보드 열림 표시 이동 — 키보드 높이 값을 시간에 따라 재계산해 표시줄과 키보드를 함께 translateY 로 옮긴다.
// 계산 겹: ease-in-out 진행률 → 키보드 높이 h 재계산 → translateY(둘 다 transform 만 사용, top/height 미변경).
(function () {
var keyboard = document.getElementById('keyboard');
var indicator = document.getElementById('indicator');
if (!keyboard || !indicator) return;
var KB_H = 64;
var CYCLE_MS = 2000;
var OPEN_START = 0.10 * CYCLE_MS;
var OPEN_MS = 360;
var CLOSE_START = 0.62 * CYCLE_MS;
var CLOSE_MS = 360;
function ease(p) {
return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
}
function heightAt(t) {
if (t < OPEN_START) return 0;
if (t < OPEN_START + OPEN_MS) {
return KB_H * ease((t - OPEN_START) / OPEN_MS);
}
if (t < CLOSE_START) return KB_H;
if (t < CLOSE_START + CLOSE_MS) {
return KB_H * (1 - ease((t - CLOSE_START) / CLOSE_MS));
}
return 0;
}
function apply(h) {
keyboard.style.transform = 'translateY(' + (KB_H - h) + 'px)';
indicator.style.transform = 'translateY(' + (-h) + 'px)';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
apply(KB_H);
return;
}
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(heightAt(t));
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef } from "react";
import styles from "./TypingKeyboardBarHint.module.scss";
// 키보드 열림 표시 이동 — 키보드 높이 값을 시간에 따라 재계산해 표시줄과 키보드를 함께 translateY 로 옮긴다.
// 계산 겹: ease-in-out 진행률 → 키보드 높이 h 재계산 → translateY(둘 다 transform 만 사용, top/height 미변경).
const KB_H = 64;
const CYCLE_MS = 2000;
const OPEN_START = 0.10 * CYCLE_MS;
const OPEN_MS = 360;
const CLOSE_START = 0.62 * CYCLE_MS;
const CLOSE_MS = 360;
function ease(p: number): number {
return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
}
function heightAt(t: number): number {
if (t < OPEN_START) return 0;
if (t < OPEN_START + OPEN_MS) {
return KB_H * ease((t - OPEN_START) / OPEN_MS);
}
if (t < CLOSE_START) return KB_H;
if (t < CLOSE_START + CLOSE_MS) {
return KB_H * (1 - ease((t - CLOSE_START) / CLOSE_MS));
}
return 0;
}
export default function TypingKeyboardBarHint() {
const keyboardRef = useRef<HTMLDivElement | null>(null);
const indicatorRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const keyboard = keyboardRef.current;
const indicator = indicatorRef.current;
if (!keyboard || !indicator) return;
function apply(h: number) {
keyboard!.style.transform = "translateY(" + (KB_H - h) + "px)";
indicator!.style.transform = "translateY(" + -h + "px)";
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
apply(KB_H);
return;
}
let start: number | null = null;
let raf = 0;
function frame(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(heightAt(t));
raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.screen}>
<span ref={indicatorRef} className={styles.indicator}>입력 중…</span>
<div ref={keyboardRef} className={styles.keyboard} />
</div>
</section>
</div>
);
}09 · キーボードバーヒント — 仮想キーボードの高さ値で位置を求め直す表示 — 計測 累積面積 11.546% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
heightAtの値をtranslateYへ反映して位置を求め直す
KB_H64pxをCYCLE_MS2000msの周期の中でOPEN_MS360msかけて開き、CLOSE_MS360msかけて閉じ、開いている間はkeyboardとindicatorの両方が同じ高さの値だけ動きます。キーボードの高さを計算せずに入力表示の位置を固定値のままにすると、キーボードが開いても表示が隠れたり浮いたりします。
function heightAt(t) {
if (t < OPEN_START) return 0;
if (t < OPEN_START + OPEN_MS) {
return KB_H * ease((t - OPEN_START) / OPEN_MS);
}
if (t < CLOSE_START) return KB_H;
if (t < CLOSE_START + CLOSE_MS) {
return KB_H * (1 - ease((t - CLOSE_START) / CLOSE_MS));
}
return 0;
}
function apply(h) {
keyboard.style.transform = 'translateY(' + (KB_H - h) + 'px)';
indicator.style.transform = 'translateY(' + (-h) + 'px)';
}
数値が画面を変える場所 — 経過時間・参加者数・振幅
9項目の計算層を経過時間・参加者数・振幅という3つの軸でまとめ直すと、同じ「値を求め直す」という動作が違う入力に対して繰り返されていることが分かります。
経過時間が周期・時間差・位置に変わる場所
04では経過時間(tick)が鼓動周期に、05では2つの時刻の差が合流判定に、09では周期内の経過時間がイージングを通じてキーボードの高さに変わります。同じ「時間を測る」という動作でも、比例させるか閾値と比べるかイージングにかけるかで画面上の変化はまったく違う形になります。
参加者数が名札の幅とラベルの切り替えに変わる場所
03では同時入力者数が「+N」ラベルの実測幅に、06では入力者の切り替わりが残り時間の比率とラベルのscaleXに変わります。人数や順番という整数の値をそのままテキストにせず、実測した幅や比率という連続的な値に変換している点が共通しています。
振幅配列と状態値が棒の高さと透明度に変わる場所
07では固定の振幅配列が棒ごとのscaleYに、08ではクリックで切り替わる状態値がopacityの補間先に変わります。どちらも配列や真偽値という離散的な値を、requestAnimationFrameまたはtransitionを通じて連続的な見た目の変化へつなぎ直しています。
どこで壊れるか — 複数ユーザーの重なり・再接続・低スペック端末
ここまでの9項目は固定値の再生を前提にしていますが、実サービスに組み込む際に値が想定より大きくなったり順序が乱れたりする場面を考えておく必要があります。進捗の割合を幅に変える計算という点では、アップロードプログレスバーローダー9種が扱う再開・失敗時の幅の巻き戻しとも近い注意点です。
複数ユーザーが同時に入力するとどうなるか
03のwidthForはCOUNTS[2,3,4]という固定の人数しか実測していないため、実サービスで人数が5人・10人と増えた場合は同じ関数をその都度呼び直して幅を実測し直す必要があります。実測せずに人数だけをテキストに反映すると、ラベルの幅が足りずに文字が切れる場合があります。
再接続でタイマーが重なるとどうなるか
05はsetIntervalとsetTimeoutを組み合わせて1つの周期を作っているため、実サービスで接続が切れて再接続した瞬間にタイマーを止めずに新しいタイマーを重ねると、vanishAtとarrivedAtの対応がずれてgapの値が実際の時間差と合わなくなります。
低スペック端末で振幅更新が追いつかないとどうなるか
07はAMPLITUDE_TICK_MS80msごとにscaleYを書き換えていますが、低スペック端末でこの間隔のまま描画が追いつかないと棒の動きが飛び飛びに見えることがあります。バリエーションの節にある220ms版のように間隔を広げるのは、この状況に対する実際の対処の1つです。
3つのバリエーションとアクセシビリティ
同じ計算式でも定数の値を変えるだけで印象が変わることを、3つの項目で確かめられます。9種のタイピングインジケーターUIはすべてyb2bq6qtで開くzipにまとめられており、vanillaフォルダには9項目それぞれのindex.htmlが、reactフォルダには同じ計算をコンポーネント化したtsxファイルが入っています。
点の遅延間隔 — 120msと260ms
01のDOT_STAGGER_MSを120msのままにすると3つの点がほぼ同時に跳ねてバウンスがひとまとまりに見え、260msへ延ばすと点の間隔が広がって波がゆったり流れる代わりに全体の周期が伸びて間延びした印象になり得ます。このzipには120ms版が入っています。
アバターパルス減速係数 — 40msと120ms
04のPULSE_SLOWDOWN_MS_PER_SECを40msのままにすると長く入力しても鼓動がほとんど遅くならず、120msへ上げると数秒で鼓動が目立って伸びて「長く入力中」という印象がはっきりします。このzipには40ms版が入っています。
振幅更新周期 — 80msと220ms
07のAMPLITUDE_TICK_MSを80msのままにすると棒が滑らかにつながって実際の波形のように見え、220msへ延ばすと棒が途切れて動くため低スペック端末の見え方に近づきます。このzipには80ms版が入っています。
prefers-reduced-motionをどう扱うか
matchMedia('(prefers-reduced-motion: reduce)')で検知した場合、9項目とも繰り返し再生(自動ループ)だけを止め、その時点までの計算結果(遅延値・補間幅・鼓動周期・合流判定・ラベル幅・棒の高さ・透明度・位置)はそのまま見せています。計算そのものを消してしまうと、利用者が減速を求めた理由と違う体験になってしまいます。
role=statusとaria-pressedをどう更新するか
入力中・既読のような状態文言にはrole="status"とaria-live="polite"を付けてスクリーンリーダーに知らせており、この設計は01から07までの7項目に共通しています。08のトグルボタンはbutton要素とaria-pressed属性で押された状態をそのまま伝え、09はキーボードの高さに合わせて表示位置を動かすだけで、読み上げ用の案内文は別途置いていません。
FAQ
実際のメッセンジャーアプリのAPIやWebSocketを使っていますか
いいえ。9項目のどれも実際のWebSocket接続・サーバー通信・実キーを呼び出していません。入力中・既読・音消しの状態はすべてタイマーと名前付きの定数によるシミュレーションです。
タイピングインジケーター インスタ のようにアプリ名で検索しても使えますか
「タイピングインジケーター インスタ」のように特定のアプリ名を添えて探す人もいますが、この記事のデモはどのアプリの実装仕様書でもなく、パターンとしての計算方法を示すものです。画面内の送信者名・アイコンはすべて架空のもので、特定のブランドのUIを再現したものではありません。
08の音消しトグル以外にクリックで操作できる項目はありますか
ありません。01・02・03・04・05・06・07・09はクリック・ドラッグ・ポインターのリスナーを一つも持たず、タイマーまたはrequestAnimationFrameの固定周期だけで自動再生されます。実際のユーザー操作を前提とした状態遷移は08だけです。









