04Avatar pulse sync — a ring recomputing its beat from typing duration
The longer typing continues, the avatar ring's beat period slows in proportion to elapsed duration, and once typing stops the period eases back to its original value.
Best forA video-call chat sidebar, participant lists in real-time collaboration tools
setPropertyclassList.togglesetInterval
<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">Typing</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 ? 'Typing' : 'Idle';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { status.textContent = 'Idle'; 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 Avatar pulse sync — a ring recomputing its beat from typing duration — Measured area 2.16% · moving frames 19/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
Loaders02 Circular percent ring computed from its own circumferenceFile Upload Progress Bar Loader: 9 UI Demos
Loaders01 Bottom spinner fade — a loading indicator computing its opacity from the remaining scroll distanceInfinite Scroll Loading: 9 Essential UI Patterns
Loaders06 ETA Recalculated From Remaining Size and SpeedBuffering Animation CSS — 9 Loading Patterns