05정지 감지 후 흔들리는 재시도 아이콘
재생이 멈춘 채로 일정 시간(300ms)이 지나면 상태 문구가 '정지됨'에서 '연결이 끊겨 다시 시도하는 중'으로 바뀌며 재시도 아이콘이 감쇠하는 사인파로 짧게 흔들리다 가라앉고, 버튼을 누르면 즉시 재생 재개로 돌아간다.
쓰임불안정한 네트워크 환경의 영상·오디오 플레이어
translateXsetIntervalis-stalled

<main class="stage">
<div class="stallCard is-demo" id="stallCard">
<div class="iconWrap" id="iconWrap" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/>
<path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0"/>
</g>
</svg>
</div>
<p class="status" id="status" aria-live="polite">정상 재생</p>
<button class="retryBtn" id="retryBtn" type="button">지금 다시 시도</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-orange-deep;
// 계산 겹: index.html 의 shakeOffset() 이 정지 지속 시간을 감쇠 사인파(진폭 exp 감쇠 × sin)로 바꿔
// translateX 오프셋을 매 틱 계산한다 — 고정 keyframe 흔들림이 아니라 물리 감쇠를 흉내낸 값.
@include stage($stage-yellow);
.stallCard {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: rgba($subject-ink, .06);
}
.iconWrap {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 50%;
color: $subject-ink;
background: rgba($subject-cream, .5);
}
.is-stalled .iconWrap {
color: $color;
background: rgba($subject-orange-deep, .16);
}
.status {
margin: 0;
font-size: 13px;
font-weight: 600;
color: $subject-ink;
text-align: center;
}
.retryBtn {
appearance: none;
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
font-size: 11px;
font-weight: 600;
color: $subject-cream;
background: $subject-ink;
cursor: pointer;
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
@media (max-width: 340px) {
.stallCard { padding: $sp-3; gap: $sp-2; }
.iconWrap { width: 44px; height: 44px; }
.status, .retryBtn { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 흔들림 오프셋은 JS 가 이미 0 으로 고정하므로 scss 쪽은 별도 전환이 없다
@media (prefers-reduced-motion: reduce) {
.iconWrap { 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);
}
.stallCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
}
.iconWrap {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 50%;
color: #17141a;
background: rgba(255, 247, 230, 0.5);
}
.is-stalled .iconWrap {
color: #c93000;
background: rgba(201, 48, 0, 0.16);
}
.status {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #17141a;
text-align: center;
}
.retryBtn {
appearance: none;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #fff7e6;
background: #17141a;
cursor: pointer;
}
.retryBtn:focus-visible {
outline: 2px solid #c93000;
outline-offset: 2px;
}
@media (max-width: 340px) {
.stallCard {
padding: 12px;
gap: 8px;
}
.iconWrap {
width: 44px;
height: 44px;
}
.status, .retryBtn {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.iconWrap {
transition: none;
}
}(function () {
var LOOP_MS = 2000;
var TICK_MS = 30;
var NORMAL_FRAC = 0.15; // 이 지점까지는 정상 재생
var STALL_FRAC = 0.92; // 이 지점부터 재생 재개
var STALL_THRESHOLD_MS = 300; // 정지가 이만큼 지속돼야 흔들림이 시작된다
var SHAKE_DECAY = 0.0018; // 감쇠율 — 클수록 빨리 잦아든다
var SHAKE_FREQ = 0.05; // 각속도
var SHAKE_AMP_PX = 9;
var card = document.getElementById('stallCard');
var iconWrap = document.getElementById('iconWrap');
var status = document.getElementById('status');
var retryBtn = document.getElementById('retryBtn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function setStatus(text) {
if (status.textContent !== text) status.textContent = text;
}
// 정지 지속 시간(ms) 을 감쇠 사인파의 위상으로 바꿔 흔들림 폭을 계산한다 — CSS keyframe 이 아니라 매 틱 산술
function shakeOffset(stallOverMs) {
var amp = SHAKE_AMP_PX * Math.exp(-SHAKE_DECAY * stallOverMs);
return amp * Math.sin(stallOverMs * SHAKE_FREQ);
}
var manualRecovered = null;
function tick(elapsedMs) {
if (manualRecovered !== null) return;
var loopPos = elapsedMs % LOOP_MS;
var normalEnd = LOOP_MS * NORMAL_FRAC;
var stallEnd = LOOP_MS * STALL_FRAC;
if (loopPos < normalEnd) {
setStatus('정상 재생');
iconWrap.style.transform = 'translateX(0px)';
card.classList.remove('is-stalled');
} else if (loopPos < stallEnd) {
var stallElapsed = loopPos - normalEnd;
card.classList.add('is-stalled');
if (stallElapsed > STALL_THRESHOLD_MS) {
setStatus('연결이 끊겨 다시 시도하는 중');
var offset = shakeOffset(stallElapsed - STALL_THRESHOLD_MS);
iconWrap.style.transform = 'translateX(' + offset.toFixed(2) + 'px)';
} else {
setStatus('정지됨');
iconWrap.style.transform = 'translateX(0px)';
}
} else {
setStatus('재생 재개');
iconWrap.style.transform = 'translateX(0px)';
card.classList.remove('is-stalled');
}
}
var timer = null;
var start = 0;
function loop() { tick(performance.now() - start); }
function human() {
if (card.classList.contains('is-demo')) {
card.classList.remove('is-demo');
window.clearInterval(timer);
timer = null;
}
}
retryBtn.addEventListener('click', function () {
human();
manualRecovered = true;
card.classList.remove('is-stalled');
iconWrap.style.transform = 'translateX(0px)';
setStatus('재생 재개');
});
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 흔들림(진동)만 멈추고 정지 상태·문구는 그대로 전달한다
card.classList.add('is-stalled');
setStatus('정지됨');
}
})();import { useEffect, useRef } from 'react';
import styles from './BufferingStallRetryShake.module.scss';
const LOOP_MS = 2000;
const TICK_MS = 30;
const NORMAL_FRAC = 0.15; // 이 지점까지는 정상 재생
const STALL_FRAC = 0.92; // 이 지점부터 재생 재개
const STALL_THRESHOLD_MS = 300; // 정지가 이만큼 지속돼야 흔들림이 시작된다
const SHAKE_DECAY = 0.0018; // 감쇠율 — 클수록 빨리 잦아든다
const SHAKE_FREQ = 0.05; // 각속도
const SHAKE_AMP_PX = 9;
// 정지 지속 시간(ms) 을 감쇠 사인파의 위상으로 바꿔 흔들림 폭을 계산한다 — CSS keyframe 이 아니라 매 틱 산술
function shakeOffset(stallOverMs: number) {
const amp = SHAKE_AMP_PX * Math.exp(-SHAKE_DECAY * stallOverMs);
return amp * Math.sin(stallOverMs * SHAKE_FREQ);
}
const ICON_PATHS = [
'M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88',
'M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0',
];
export default function BufferingStallRetryShake() {
const cardRef = useRef<HTMLDivElement>(null);
const iconWrapRef = useRef<HTMLDivElement>(null);
const statusRef = useRef<HTMLParagraphElement>(null);
const retryBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const card = cardRef.current;
const iconWrap = iconWrapRef.current;
const status = statusRef.current;
const retryBtn = retryBtnRef.current;
if (!card || !iconWrap || !status || !retryBtn) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function setStatus(text: string) {
if (status && status.textContent !== text) status.textContent = text;
}
let manualRecovered: boolean | null = null;
function tick(elapsedMs: number) {
if (manualRecovered !== null || !card || !iconWrap) return;
const loopPos = elapsedMs % LOOP_MS;
const normalEnd = LOOP_MS * NORMAL_FRAC;
const stallEnd = LOOP_MS * STALL_FRAC;
if (loopPos < normalEnd) {
setStatus('정상 재생');
iconWrap.style.transform = 'translateX(0px)';
card.classList.remove(styles.isStalled);
} else if (loopPos < stallEnd) {
const stallElapsed = loopPos - normalEnd;
card.classList.add(styles.isStalled);
if (stallElapsed > STALL_THRESHOLD_MS) {
setStatus('연결이 끊겨 다시 시도하는 중');
const offset = shakeOffset(stallElapsed - STALL_THRESHOLD_MS);
iconWrap.style.transform = 'translateX(' + offset.toFixed(2) + 'px)';
} else {
setStatus('정지됨');
iconWrap.style.transform = 'translateX(0px)';
}
} else {
setStatus('재생 재개');
iconWrap.style.transform = 'translateX(0px)';
card.classList.remove(styles.isStalled);
}
}
let timer: ReturnType<typeof window.setInterval> | null = null;
let start = 0;
function loop() {
tick(performance.now() - start);
}
function human() {
if (card && card.classList.contains(styles['is-demo'])) {
card.classList.remove(styles['is-demo']);
if (timer !== null) window.clearInterval(timer);
timer = null;
}
}
function onRetry() {
human();
manualRecovered = true;
if (card) card.classList.remove(styles.isStalled);
if (iconWrap) iconWrap.style.transform = 'translateX(0px)';
setStatus('재생 재개');
}
retryBtn.addEventListener('click', onRetry);
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 흔들림(진동)만 멈추고 정지 상태·문구는 그대로 전달한다
card.classList.add(styles.isStalled);
setStatus('정지됨');
}
return () => {
if (timer !== null) window.clearInterval(timer);
retryBtn.removeEventListener('click', onRetry);
};
}, []);
return (
<div className={`${styles.stallCard} ${styles['is-demo']}`} ref={cardRef}>
<div className={styles.iconWrap} ref={iconWrapRef} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 256 256">
<g fill="currentColor">
<path d={ICON_PATHS[0]} opacity=".2" />
<path d={ICON_PATHS[1]} />
</g>
</svg>
</div>
<p className={styles.status} ref={statusRef} aria-live="polite">정상 재생</p>
<button className={styles.retryBtn} ref={retryBtnRef} type="button">지금 다시 시도</button>
</div>
);
}05 정지 감지 후 흔들리는 재시도 아이콘 — 측정 누적면적 10.052% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 버퍼링 표시 애니메이션 9종 — 스피너부터 화질 배지까지
이 데모가 속한 글
버퍼링 표시 애니메이션 9종 — 스피너부터 화질 배지까지버퍼링 표시 아홉 가지 중 하나는 재생 위치와 버퍼링 위치를 두 겹의 링으로 함께 그리고, 하나는 오디오 파형이 버퍼된 만큼만 밝게 채워지며, 하나는 버퍼가 부족해지면 화질이 자동으로 낮아졌다는 배지가 뜹니다. 이 글의 데모 전부 보기


