04아바타 펄스 동기화 — 입력 지속 시간으로 박동 주기를 다시 구하는 원
입력 중인 시간이 길어질수록 아바타 테두리의 박동 주기가 지속 시간 값에 비례해 점점 느려지고, 입력이 멈춘 상태가 되면 주기가 원래 값으로 되돌아갑니다.
쓰임화상회의 채팅 사이드바, 실시간 협업 툴의 참여자 목록
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">입력 중</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 포함 · 타이핑 인디케이터 UI 9종 — 점 3개부터 그룹 채팅까지
이 데모가 속한 글
타이핑 인디케이터 UI 9종 — 점 3개부터 그룹 채팅까지타이핑 인디케이터 아홉 개 중 하나는 입력 중이던 시간이 길수록 아바타 테두리가 느리게 뛰고, 하나는 그룹 채팅에서 입력자가 바뀔 때마다 남은 시간 값으로 이름표 폭을 다시 그리고, 또 하나는 진폭 값 배열로 음성 이 글의 데모 전부 보기


