타이핑 인디케이터 UI 9종 — 점 3개부터 그룹 채팅까지
타이핑 인디케이터 아홉 개 중 하나는 입력 중이던 시간이 길수록 아바타 테두리가 느리게 뛰고, 하나는 그룹 채팅에서 입력자가 바뀔 때마다 남은 시간 값으로 이름표 폭을 다시 그리고, 또 하나는 진폭 값 배열로 음성 입력 표시의 막대 높이를 다시 계산합니다.
이 아홉 화면은 실제 메신저 서비스가 아니라 가상의 발신자와 자체 말풍선으로 만든 것이며, 사용자가 손으로 조작하는 리스너는 08 하나뿐이고 나머지 여덟 개는 타이머와 rAF 로 정해진 주기를 스스로 돕니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 점 3개 바운스 — 순서를 인덱스로 계산해 튀는 기본형
- 02 말풍선 모핑 — 점 세 개가 하나의 말풍선 크기로 합쳐지는 전환
- 03 다중 사용자 겹침 — 동시 입력자 수를 이름표 폭으로 계산하는 줄
- 04 아바타 펄스 동기화 — 입력 지속 시간으로 박동 주기를 다시 구하는 원
- 05 읽음 표시 병합 — 사라진 시간 차로 합쳐지는 시점을 구하는 라벨
- 06 그룹 채팅 순환 — 입력자가 바뀔 때마다 남은 시간으로 폭을 다시 그리는 라벨
- 07 음성 입력 파형 — 진폭 배열 값으로 막대 높이를 다시 그리는 표시
- 08 표시 끄기 토글 — 상태 값만큼 투명도를 보간하는 스위치
- 09 키보드 바 힌트 — 가상 키보드 높이 값으로 위치를 다시 잡는 표시
인스타 입력중 표시처럼 앱마다 이름과 생김새는 달라도, 이 아홉 화면은 특정 앱의 실제 표시를 재현한 것이 아니라 그런 검색어로 찾는 사람이 흔히 기대하는 움직임을 자체 디자인으로 구현한 것입니다. 같은 "입력 중" 표시라도 무엇을 재서 무엇으로 바꾸는지에 따라 갈래가 나뉘는데, 아래 표는 그 계산이 화면을 실제로 얼마나 흔들었는지 잰 값이고 순서는 점·말풍선·인원 표시(01~03) → 지속·잔여 시간 표시(04~06) → 진폭·상태·위치 표시(07~09) 로 채팅 화면에서 마주치는 배치와 가깝게 잡았습니다.
데모
아홉 화면은 각자의 계산 함수가 인덱스·경과 시간·인원 수·진폭 배열 같은 값 하나를 받아 저마다 다른 방식으로 표시를 새로 그립니다. 실제 메신저 서버와의 연결이나 다른 사람의 실제 타자 신호는 어디에도 없고, 그 값이 바뀌면 화면이 어떻게 달라지는지만 자동 재생 또는 08 의 클릭으로 보여줍니다. 카톡 입력중 표시처럼 이미 눈에 익은 이 표시도 뜯어보면 결국 인덱스·경과 시간·진폭 같은 값 하나를 계산해 화면에 다시 쓰는 원리로 움직이며, 실제 서비스에 붙일 때는 이 계산이 시작·끝나는 자리에 진짜 입력 이벤트 신호를 얹으면 됩니다.
실측 한눈에
| 항목 | 초점 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|---|
| 01 점 3개 바운스 | 인덱스 → 딜레이 | 0.225 | 60.4 | 15/23 |
| 02 말풍선 모핑 | 합산 너비 → 보간 폭 | 4.594 | 191.1 | 13/23 |
| 03 다중 사용자 겹침 | 인원 수 → 라벨 폭 실측 | 0.337 | 105.7 | 6/23 |
| 04 아바타 펄스 동기화 | 지속 시간 → 박동 주기 | 2.16 | 55.9 | 19/23 |
| 05 읽음 표시 병합 | 시간 차 → 병합 판정 | 3.026 | 78.3 | 9/23 |
| 06 그룹 채팅 순환 | 잔여 시간 → 폭 축소 | 0.329 | 156.8 | 23/23 |
| 07 음성 입력 파형 | 진폭 배열 → 막대 높이 | 1.029 | 187.9 | 23/23 |
| 08 표시 끄기 토글 | 상태 값 → 투명도 보간 | 6.372 | 80.5 | 11/23 |
| 09 키보드 바 힌트 | 키보드 높이 값 → 위치 | 11.546 | 106.9 | 12/23 |
01·03·06 은 점 세 개나 얇은 막대처럼 원래 몸집이 작은 요소를 움직이는 화면이라 누적면적이 1% 밑으로 잡히고, 09 는 하단 전체가 함께 밀려 올라가는 화면이라 11.5%대로 가장 큽니다. 03·05·08·09 는 인원·상태가 바뀌는 시점 사이에 값이 그대로 머무는 정지 구간이 섞여 있어 움직인 프레임 수가 상대적으로 적게 잡힌 것이지, 계산이 도중에 멈춘 결함은 아닙니다.
타이핑 인디케이터의 해부 — 아홉 화면이 계산하는 것
값을 실측해 치수로 바꾸는 축
01·02·03 은 무언가의 값을 구해 그 값을 화면의 치수로 바꿉니다. 01 은 점의 순서(index) 값에 고정 간격을 곱해 각 점의 애니메이션 시작 시점(--delay)을 다시 구하고, 02 는 점 세 개의 너비 합에서 말풍선 하나의 너비까지 보간 값(--w)을 구하며, 03 은 동시 입력자 수만큼 달라지는 라벨 텍스트의 실제 렌더 폭을 getBoundingClientRect() 로 재 배지 너비(--bw)에 반영합니다.
경과 시간을 기준 삼는 축
04·05·06 은 시간이 얼마나 흘렀는지를 기준으로 값을 다시 구합니다. 04 는 입력이 지속된 눈금(tick) 수에 감속 계수를 곱해 박동 주기를 늘리고, 05 는 표시가 사라진 시각과 메시지가 도착한 시각의 차를 재 병합 여부를 가르며, 06 은 다음 입력자로 넘어가기까지 남은 시간의 비율만큼 막대 폭을 줄입니다.
값을 비교해 갈래를 나누는 축
07·08·09 는 값 하나를 기준과 견주거나 배열에서 차례로 읽어 화면을 가릅니다. 07 은 미리 정한 진폭 배열을 인덱스 순서로 읽어 막대마다 다른 높이를 그리고, 08 은 켜짐·꺼짐 상태 값에 따라 목표 투명도를 고르며, 09 는 가상 키보드가 차지한 높이 값을 기준으로 표시와 키보드를 함께 옮깁니다.
화면별 코드
01점 3개 바운스 — 순서를 인덱스로 계산해 튀는 기본형
1:1 채팅방 하단에 흔히 붙는 기본형으로, 점 세 개가 튀어 오르는 시작 시점을 점의 순서 값으로 다시 구합니다.
인덱스 값에 간격을 곱해 지연 시점을 되구하는 계산
dots.forEach() 는 각 점의 data-i 값을 읽어 i * DOT_STAGGER_MS 값을 --delay 커스텀 프로퍼티에 반영하고, 이 값이 그대로 animation-delay 로 쓰여 튀어 오르는 시작 시점을 벌립니다. 페이지가 열리는 순간 한 번만 계산해 CSS @keyframes 에 넘길 뿐, 이후로는 별도 자바스크립트 개입이 없습니다.
(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');
});
})();

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02말풍선 모핑 — 점 세 개가 하나의 말풍선 크기로 합쳐지는 전환
챗봇이 아닌 실사용자끼리의 메신저 화면에서, 입력 표시가 끝난 상태가 되면 점 세 개가 차지하던 자리가 메시지 말풍선 크기로 넘어갑니다.
합산 너비를 말풍선 너비로 보간하는 계산
apply(t) 는 주기 시작 지점(MORPH_START)부터 MORPH_MS 동안 점 너비 합(DOT_W)에서 말풍선 너비(MSG_W)까지를 easeOut 곡선으로 보간해 --w 값을 다시 쓰고, 보간이 끝나는 지점(p >= 1)에 맞춰 점과 메시지의 투명도를 맞바꿉니다. 화면이 prefers-reduced-motion 을 감지한 상태가 되면 보간을 건너뛰고 원래 너비 값에서 멈춥니다.
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';
}

<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 · 말풍선 모핑 — 점 세 개가 하나의 말풍선 크기로 합쳐지는 전환 — 측정 누적면적 4.594% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03다중 사용자 겹침 — 동시 입력자 수를 이름표 폭으로 계산하는 줄
그룹 채팅방 상단에서 동시에 입력 중인 사람이 둘 이상인 상태가 되면 이름을 나열하는 대신 '외 N명' 배지 하나로 정리됩니다.
라벨 텍스트의 실측 폭으로 배지 너비를 되구하는 계산
widthFor(n) 은 화면 밖에 둔 측정용 요소에 '+n' 문구를 넣고 getBoundingClientRect().width 로 실제 렌더 폭을 재고, apply(t) 는 그 폭 값 사이를 COUNTS 순서대로 보간해 --bw 값과 배지 문구를 함께 바꿉니다. 인원 수가 늘어도 문구 길이만큼만 배지가 넓어지는 것은 이 실측 단계 때문입니다.
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];
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04아바타 펄스 동기화 — 입력 지속 시간으로 박동 주기를 다시 구하는 원
화상회의 채팅 사이드바처럼 입력 지속 시간이 눈에 잘 안 띄는 자리에서, 입력이 길어질수록 아바타 테두리의 박동이 눈에 띄게 느려지는 상태가 됩니다.
지속 눈금 수에 감속 계수를 곱해 박동 주기를 되구하는 계산
step() 은 150ms 마다 한 번씩 tick 값을 늘리며 BASE_PERIOD_MS + tick * PULSE_SLOWDOWN_MS_PER_SEC 값으로 --period 를 다시 쓰고, tick 이 MAX_TICKS 를 넘어 0으로 되돌아가는 상태가 되면 박동 주기도 원래 값으로 돌아갑니다. 주기 값이 CSS 지속 시간이 아니라 커스텀 프로퍼티로 들어가 있어 스크립트 쪽 계산만 바뀌어도 박동 속도가 곧바로 반영됩니다.
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 ? '입력 중' : '대기';
}
<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05읽음 표시 병합 — 사라진 시간 차로 합쳐지는 시점을 구하는 라벨
1:1 채팅의 메시지 목록 하단에서, 입력 표시가 사라진 시점과 메시지가 도착한 시점이 가까운 상태가 되면 두 표시가 한 자리로 합쳐집니다.
두 시각의 차를 기준값과 비교해 병합 여부를 가르는 계산
표시가 사라지는 순간의 Date.now() 값을 vanishAt 에 담아 두고, 메시지가 도착하는 시점의 Date.now() 값과의 차(gap)를 MERGE_GAP_MS 와 비교해 그 값이 작은 상태가 되면 is-merged 클래스를 붙입니다. 두 타이머(timer1·timer2)는 새 주기가 시작될 때마다 clearTimeout 으로 먼저 정리해 이전 값이 겹쳐 남지 않게 합니다.
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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06그룹 채팅 순환 — 입력자가 바뀔 때마다 남은 시간으로 폭을 다시 그리는 라벨
커뮤니티 단체 채팅방처럼 입력자가 자주 바뀌는 자리에서, 다음 사람으로 넘어가기 전 남은 시간만큼 이름표 폭이 함께 줄어드는 상태가 됩니다.
남은 시간 비율을 막대 폭으로 되구하는 계산
apply(t) 는 HOLD_MS 를 기준으로 지금이 몇 번째 이름 구간인지(idx)를 구하고, 그 구간 안에서 남은 비율(remain)을 scaleX() 값으로 바꿔 막대가 시간에 맞춰 줄어들게 합니다. 구간이 끝나 다음 이름으로 넘어가는 상태가 되면 막대 폭도 다시 1로 돌아가 같은 계산을 되풀이합니다.
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) + ')';
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07음성 입력 파형 — 진폭 배열 값으로 막대 높이를 다시 그리는 표시
음성 메시지 녹음 화면에서, 녹음 중인 상태가 되면 막대마다 높이가 다른 파형이 계속 다시 그려집니다.
진폭 배열을 인덱스 순서로 읽어 막대 높이를 되구하는 계산
tick() 은 80ms 마다 미리 정해 둔 AMPLITUDES 배열을 idx 기준으로 순서대로 읽어 막대마다 scaleY() 값을 다시 매기고, idx 가 배열 끝에 닿으면 나머지 연산(%)으로 다시 처음 값부터 읽습니다. 실제 마이크 입력이나 오디오 API 호출은 어디에도 없고 고정된 값 배열만 순회할 뿐입니다.
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;
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08표시 끄기 토글 — 상태 값만큼 투명도를 보간하는 스위치
알림 최소화 설정이 있는 채팅 앱처럼, 음소거 버튼을 누르면 입력 표시 카드의 투명도가 상태 값만큼 보간되어 사라지거나 나타납니다.
상태 값을 목표 투명도로 바꾸는 계산
apply() 는 muted 값이 참인 상태가 되면 카드 투명도를 OFF_OPACITY 로, 거짓인 상태가 되면 1로 맞추고 aria-pressed 값도 함께 바꾸며, 실제 전환 구간은 CSS transition 이 그 사이를 보간합니다. 아홉 화면 가운데 클릭 리스너가 붙은 것은 이 항목 하나뿐이고, 나머지 화면은 같은 apply() 계산을 타이머가 대신 불러 줍니다.
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();
});

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09키보드 바 힌트 — 가상 키보드 높이 값으로 위치를 다시 잡는 표시
모바일 채팅 앱에서 가상 키보드가 열린 상태가 되면 입력 표시가 키보드 바로 위까지 함께 따라 올라갑니다.
키보드 높이 값을 위치 값으로 되구하는 계산
heightAt(t) 는 열림·닫힘 구간마다 ease() 곡선으로 키보드 높이(KB_H)를 0부터 다시 구하고, apply(h) 는 그 값을 표시와 키보드 양쪽의 translateY() 에 반영해 둘을 같은 속도로 움직입니다. top·height 같은 레이아웃 속성은 건드리지 않고 transform 값만 다시 계산할 뿐입니다.
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)';
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 화면을 바꾸는 곳 — 경과 시간·참여자 수·진폭
경과 시간 — 지속·잔여·차이 값을 다시 재는 곳
04 는 입력이 지속된 눈금 수로 박동 주기를 늘리고, 05 는 표시가 사라진 시점과 메시지 도착 시점의 차로 병합 여부를 가르며, 06 은 다음 사람으로 넘어가기까지 남은 시간의 비율로 막대 폭을 줄입니다. 세 화면 모두 "지금까지 얼마나 지났는가" 라는 한 값을 서로 다른 결과로 바꿔 쓰는 셈입니다.
참여자 수 — 인원 값을 라벨 폭으로 바꾸는 곳
03 은 동시 입력자가 둘 이상인 상태가 되면 이름을 하나하나 나열하는 대신 앞선 이름과 나머지 인원 수만 세어 '외 N명' 문구의 실제 렌더 폭을 배지 너비에 반영합니다. 인원이 늘어도 문구 길이만큼만 배지가 넓어지는 것은 이 실측 계산 덕분입니다.
진폭 — 배열 값을 순회해 높이로 바꾸는 곳
07 은 실제 마이크 입력이 아니라 미리 정한 진폭 값 배열을 인덱스 순서로 읽어 막대마다 다른 높이를 매기고, 배열 끝에 닿으면 나머지 연산으로 처음 값부터 다시 읽습니다. 값 자체는 고정돼 있지만 읽는 위치가 매 눈금마다 하나씩 밀려 화면에서는 파형처럼 보입니다.
어디서 깨지는가 — 다중 사용자 겹침·재연결·저사양 기기
다중 사용자 겹침 — 인원이 늘어날수록 실측이 잦아진다
03 은 인원 수가 바뀔 때마다 getBoundingClientRect() 로 폭을 다시 재는데, 실제 그룹 채팅에서는 여러 사람이 거의 동시에 입력을 시작·종료하면 이 실측이 매우 짧은 간격으로 겹쳐 일어날 수 있습니다. 데모의 COUNTS 배열은 2·3·4 세 값만 순서대로 도는 고정 시나리오라 이런 겹침 빈도까지 재현하지는 않습니다.
재연결 — 시간 차 비교가 실제 지연과 같지 않다
05 가 비교하는 gap 값은 setTimeout 으로 정해 둔 고정 지연이라 항상 같은 패턴으로 병합 여부가 갈리는데, yb2bq6qt 압축 파일 안의 원본 소스를 열어 보면 이 값이 실제 네트워크 재연결 지연과는 무관하게 짜여 있다는 것을 바로 확인할 수 있습니다. 실제 서비스에서는 이 비교 기준을 재연결 뒤 실측한 지연 값으로 바꿔 넣어야 합니다.
저사양 기기 — 진폭 갱신 주기가 촘촘하면 프레임이 밀린다
07 은 AMPLITUDE_TICK_MS 값(80ms)마다 setInterval 을 다시 불러 막대 값을 갱신하는데, 이 간격이 기기 성능보다 촘촘하면 실제로는 갱신이 밀려 파형이 매끄러운 대신 뚝뚝 끊겨 보일 수 있습니다. 변형 표의 세 번째 항목처럼 이 값을 늘리면 계산 빈도는 줄지만 파형 특유의 매끄러운 느낌도 함께 옅어집니다.
변형 세 가지와 접근성
| 변형 | 바꾸는 값 | 바뀌는 느낌 |
|---|---|---|
| 점 딜레이 간격 | 01 DOT_STAGGER_MS 120ms → 260ms |
간격을 120ms 로 두면 세 점이 거의 동시에 튀어 바운스가 뭉쳐 보이고, 260ms 로 늘리면 점 사이 간격이 벌어져 물결이 느긋하게 흐르는 대신 전체 주기가 늘어져 답답하게 느껴질 수 있다 |
| 아바타 펄스 감속 계수 | 04 PULSE_SLOWDOWN_MS_PER_SEC 40ms → 120ms |
계수를 40ms 로 두면 오래 입력해도 박동이 거의 느려지지 않아 지속 시간 정보가 안 느껴지고, 120ms 로 올리면 몇 초 만에 박동이 눈에 띄게 늘어져 '한참 입력 중' 이라는 인상이 분명해진다 |
| 진폭 갱신 주기 | 07 AMPLITUDE_TICK_MS 80ms → 220ms |
주기를 80ms 로 두면 막대가 매끄럽게 이어져 실제 파형처럼 보이지만 계산이 잦고, 220ms 로 늘리면 막대가 뚝뚝 끊겨 움직여 저사양 기기 흉내에는 맞지만 파형 느낌은 옅어진다 |
접근성
아홉 화면 모두 prefers-reduced-motion 값을 확인해, 그 값이 켜진 상태가 되면 반복 재생을 시작하지 않고 정지된 값 하나만 그려 둡니다. 01·02·03·04·05·06·07·08 은 role="status" aria-live="polite" 자리를 두어 눈으로 안 보고도 상태 변화를 알 수 있게 했고, 08 은 여기에 더해 button 요소와 aria-pressed 속성을 그대로 써서 키보드 포커스와 Enter·Space 로도 같은 전환을 볼 수 있습니다. 09 는 표시 위치만 옮길 뿐 별도의 안내 문구는 두지 않았습니다.
FAQ
이 데모는 실제 메신저 앱의 타이핑 인디케이터와 똑같이 동작하나요?
아니요. 카톡 채팅 입력중 표시처럼 이름은 익숙해도 아홉 화면 모두 실제 메신저 서버나 다른 사람의 실제 타자 신호 없이 인덱스·경과 시간·진폭 배열 같은 값을 계산해 화면만 바꾸는 재현이고, 특정 메신저 앱의 내부 동작 방식을 그대로 옮긴 것은 아닙니다. 실제 서비스에 쓰려면 이 계산이 시작·끝나는 자리에 진짜 입력 이벤트 신호를 얹어야 합니다.
아홉 화면 중 실제로 눌러서 조작할 수 있는 화면은 어떤 건가요?
08 표시 끄기 토글 하나뿐입니다. 나머지 여덟 화면은 setInterval·requestAnimationFrame 이 정해진 주기를 따라 값을 자동으로 다시 계산하며, 클릭이나 입력이 없어도 화면이 계속 바뀝니다.
그룹 채팅에서 여러 명이 동시에 입력하면 실제로도 이렇게 보이나요?
03 과 06 은 인원 수·순서가 정해진 값을 순서대로 도는 고정 시나리오를 보여줄 뿐이고, 실제 그룹 채팅에서 입력자가 겹치거나 순서 없이 나타나고 사라지는 빈도는 서비스와 그 순간의 접속 상태마다 다르게 나타납니다. 데모의 COUNTS·NAMES 값을 바꾸면 다른 인원 패턴도 확인할 수 있습니다.
더 읽을거리
- AI 채팅 CSS 로딩 애니메이션 9종 — 봇의 응답 대기를 보여주는 로딩 화면 모음, 사람 간 입력 표시와는 다른 축을 다루는 편
- 파일 업로드 진행률 로더 9종 — 드래그부터 체크까지 계산 — 값을 실측해 진행률로 바꾸는 계산을 다루는 이웃 로더 편
- CSS 로딩 스피너 9종 — div 하나로 끝나는 — 대기 상태를 보여주는 가장 기본적인 로더 아홉 화면
- aria-live 로 상태 변화를 소리로 알리는 법 (MDN)
- 모션 줄이기 설정을 읽는 미디어 쿼리 (MDN)









