06Group chat cycle — a label redrawing width from remaining time as typers change
When the typer in a group chat changes, the remaining-display time for the previous name is counted down, the label width shrinks in step with that value, and then the next name takes over.
Best forCommunity group chat rooms, support screens with several agents
requestAnimationFrametransform-originscaleX(

<main class="stage">
<section class="panel">
<div class="row" role="status" aria-live="polite" aria-label="Group chat typing indicator">
<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 Group chat cycle — a label redrawing width from remaining time as typers change — Measured area 0.329% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Typing Indicator CSS Patterns (Chat UI)
The post this demo lives in
9 Typing Indicator CSS Patterns (Chat UI)One of nine typing indicators slows its avatar pulse as typing runs longer, one redraws a group name tag from remaining See all demos in this post
If you like this effect - similar demos
Loaders04 Load-more button morph — a button recomputing its width from the remaining item countInfinite Scroll Loading: 9 Essential UI Patterns
Loaders03 A speed label computed from elapsed-byte deltasFile Upload Progress Bar Loader: 9 UI Demos
Loaders04 Squish ball9 Loading Screen Designs — Pretty CSS Loaders