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 オリジナル · SCSS と React は zip に同梱 · バッファ 表示 アニメーション 9種 — スピナーから画質バッジまで
このデモが属する記事
バッファ 表示 アニメーション 9種 — スピナーから画質バッジまでこのバッファ 表示 9種のうち、1つは再生位置とバッファ位置を二重リングで一緒に描き、1つは波形をバッファされた分だけ明るく塗りつぶし、1つはバッファ不足で画質が下がったバッジを表示します。 この記事のデモをすべて見る


