03複数ユーザー重なり — 同時入力者数を名札の幅で計算する行
同時に入力中の人が2人以上の状態になると、名前を並べる代わりに先頭の1人と残り人数を数え、「他N人」ラベルの幅をその数に合わせて計算し直します。
使いどころグループチャットの上部、共同編集ドキュメントの同時編集者表示
getBoundingClientRectsetPropertyrequestAnimationFrame

<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 に同梱 · タイピングインジケーターUI 9選 — 3つの点からグループチャットまで
このデモが属する記事
タイピングインジケーターUI 9選 — 3つの点からグループチャットまで「タイピングインジケーター」9選 — 9つのうち1つは入力中の時間が長いほどアバターの縁がゆっくり脈打ち、1つはグループチャットで入力者が変わるたびに残り時間の値で名札の幅を描き直し、もう1つは振幅配列の値で音声入力表示の棒の高さを計算し直 この記事のデモをすべて見る

