01静止フレーム上のバッファリングオーバーレイ
ストリーミングが途切れると画面上に半透明のオーバーレイが敷かれ、その上でスピナーが回る — 再生中だったフレームはそのまま静止して見える。
使いどころ動画・ライブ配信の再生画面
backdrop-filter@keyframesaria-live

<main class="stage">
<div class="player is-demo" id="player">
<div class="frame" id="frame" aria-hidden="true">
<span class="frameDot"></span>
<span class="frameDot"></span>
<span class="frameDot"></span>
</div>
<div class="overlay" id="overlay">
<span class="ring" id="ring"></span>
</div>
<button class="toggleBtn" id="toggleBtn" type="button" aria-pressed="false">今バッファリングを止めてみる</button>
<p class="status" id="status" aria-live="polite">バッファリング中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-light;
// 계산 겹: index.html 의 tick() 이 정지 경과 시간(phase)으로 스피너 회전 속도(--spin-duration)와
// 오버레이 흐림량(--blur)·불투명도를 매 틱 산술로 갱신한다 — 고정 트윈이 아니다.
@include stage($stage-ink);
.player {
position: relative;
width: 300px;
height: 168px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.frame {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #2a2740, $stage-ink 70%);
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
}
.frameDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba($subject-cream, .25);
}
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(#0a0812, .55);
backdrop-filter: blur(var(--blur, 6px));
-webkit-backdrop-filter: blur(var(--blur, 6px));
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.ring {
--spin-duration: 900ms;
width: 44px;
height: 44px;
border-radius: 50%;
border: 4px solid rgba($subject-cream, .22);
border-top-color: $color;
transform-origin: center;
animation: spin var(--spin-duration) linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.toggleBtn {
position: absolute;
left: 50%;
bottom: $sp-3;
transform: translateX(-50%);
appearance: none;
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
font-size: 11px;
font-weight: 600;
color: $subject-ink;
background: rgba($subject-cream, .9);
cursor: pointer;
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.status {
position: absolute;
top: $sp-2;
left: $sp-2;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
font-size: 11px;
font-weight: 600;
color: $subject-cream;
background: rgba(#000, .35);
}
@media (max-width: 340px) {
.player { width: 220px; height: 140px; }
.ring { width: 34px; height: 34px; border-width: 3px; }
.toggleBtn { font-size: 10px; padding: $sp-1 $sp-2; bottom: $sp-2; }
.status { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 스피너 회전(장식)만 멈춘다. 오버레이 존재·상태 문구(aria-live)는 그대로 갱신된다
@media (prefers-reduced-motion: reduce) {
.ring { animation: none; }
.overlay { 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);
}
.player {
position: relative;
width: 300px;
height: 168px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.frame {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #2a2740, #17141a 70%);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.frameDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.25);
}
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(10, 8, 18, 0.55);
backdrop-filter: blur(var(--blur, 6px));
-webkit-backdrop-filter: blur(var(--blur, 6px));
opacity: 0;
transition: opacity 220ms ease-out;
}
.ring {
--spin-duration: 900ms;
width: 44px;
height: 44px;
border-radius: 50%;
border: 4px solid rgba(255, 247, 230, 0.22);
border-top-color: #5b8cf8;
transform-origin: center;
animation: spin var(--spin-duration) linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.toggleBtn {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
appearance: none;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #17141a;
background: rgba(255, 247, 230, 0.9);
cursor: pointer;
}
.toggleBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.status {
position: absolute;
top: 8px;
left: 8px;
margin: 0;
padding: 4px 8px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
color: #fff7e6;
background: rgba(0, 0, 0, 0.35);
}
@media (max-width: 340px) {
.player {
width: 220px;
height: 140px;
}
.ring {
width: 34px;
height: 34px;
border-width: 3px;
}
.toggleBtn {
font-size: 10px;
padding: 4px 8px;
bottom: 8px;
}
.status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ring {
animation: none;
}
.overlay {
transition: none;
}
}(function () {
var STALL_CYCLE_MS = 2000; // 정지→회복 한 사이클 — 캡처 창(2초)의 약수
var STALL_PORTION = 0.72; // 한 사이클 중 정지 상태가 차지하는 비율
var TICK_MS = 60; // 계산 갱신 간격
var SPIN_MS_STALL_START = 1500; // 정지 시작 시 스피너 한 바퀴 시간(느림)
var SPIN_MS_STALL_END = 550; // 정지가 길어질수록 빨라지는 하한
var overlay = document.getElementById('overlay');
var ring = document.getElementById('ring');
var status = document.getElementById('status');
var toggleBtn = document.getElementById('toggleBtn');
var player = document.getElementById('player');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
// 정지 경과 시간과 사이클 위상만으로 스피너 속도·오버레이 흐림 정도를 계산한다 — 값 자체는 CSS 트윈이 아니라 산술이다
function tick(elapsedMs) {
var phase = elapsedMs % STALL_CYCLE_MS;
var stallSpan = STALL_CYCLE_MS * STALL_PORTION;
var stalling = phase < stallSpan;
if (stalling) {
var stallFrac = clamp(phase / stallSpan, 0, 1);
var spinMs = SPIN_MS_STALL_START - (SPIN_MS_STALL_START - SPIN_MS_STALL_END) * stallFrac;
// 합성파 두 개로 네트워크 흔들림을 흉내낸다 — Math.random 은 렌더마다 프레임이 달라져 측정을 어지럽힌다
var jitter = 0.5 + 0.28 * Math.sin(elapsedMs / 190) + 0.14 * Math.sin(elapsedMs / 61);
jitter = clamp(jitter, 0, 1);
ring.style.setProperty('--spin-duration', spinMs.toFixed(0) + 'ms');
overlay.style.setProperty('--blur', (4 + jitter * 7).toFixed(2) + 'px');
overlay.style.opacity = (0.5 + jitter * 0.28).toFixed(2);
if (status.textContent !== 'バッファリング中') status.textContent = 'バッファリング中';
} else {
overlay.style.opacity = '0';
if (status.textContent !== '再生再開') status.textContent = '再生再開';
}
}
var timer = null;
var start = 0;
function loop() {
tick(performance.now() - start);
}
function human() {
if (player.classList.contains('is-demo')) {
player.classList.remove('is-demo');
window.clearInterval(timer);
timer = null;
}
}
toggleBtn.addEventListener('click', function () {
human();
var manualStall = overlay.style.opacity !== '1';
overlay.style.opacity = manualStall ? '1' : '0';
overlay.style.setProperty('--blur', '8px');
ring.style.setProperty('--spin-duration', '900ms');
status.textContent = manualStall ? 'バッファリング中' : '再生再開';
toggleBtn.setAttribute('aria-pressed', manualStall ? 'true' : 'false');
toggleBtn.textContent = manualStall ? '再生を再開する' : '今バッファリングを止めてみる';
});
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 — 회전·흐림 변화는 멈추고 정지된 오버레이 상태 하나만 고정으로 보여준다
overlay.style.opacity = '0.6';
overlay.style.setProperty('--blur', '6px');
status.textContent = 'バッファリング中';
}
})();import { useEffect, useRef } from 'react';
import styles from './BufferingSpinnerOverlay.module.scss';
const STALL_CYCLE_MS = 2000;
const STALL_PORTION = 0.72;
const TICK_MS = 60;
const SPIN_MS_STALL_START = 1500;
const SPIN_MS_STALL_END = 550;
function clamp(v: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, v));
}
export default function BufferingSpinnerOverlay() {
const playerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLSpanElement>(null);
const statusRef = useRef<HTMLParagraphElement>(null);
const toggleBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const player = playerRef.current;
const overlay = overlayRef.current;
const ring = ringRef.current;
const status = statusRef.current;
const toggleBtn = toggleBtnRef.current;
if (!player || !overlay || !ring || !status || !toggleBtn) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function tick(elapsedMs: number) {
if (!overlay || !ring || !status) return;
const phase = elapsedMs % STALL_CYCLE_MS;
const stallSpan = STALL_CYCLE_MS * STALL_PORTION;
const stalling = phase < stallSpan;
if (stalling) {
const stallFrac = clamp(phase / stallSpan, 0, 1);
const spinMs = SPIN_MS_STALL_START - (SPIN_MS_STALL_START - SPIN_MS_STALL_END) * stallFrac;
const jitter = clamp(0.5 + 0.28 * Math.sin(elapsedMs / 190) + 0.14 * Math.sin(elapsedMs / 61), 0, 1);
ring.style.setProperty('--spin-duration', spinMs.toFixed(0) + 'ms');
overlay.style.setProperty('--blur', (4 + jitter * 7).toFixed(2) + 'px');
overlay.style.opacity = (0.5 + jitter * 0.28).toFixed(2);
if (status.textContent !== '버퍼링 중') status.textContent = '버퍼링 중';
} else {
overlay.style.opacity = '0';
if (status.textContent !== '재생 재개') status.textContent = '재생 재개';
}
}
let timer: ReturnType<typeof window.setInterval> | null = null;
let start = 0;
function loop() {
tick(performance.now() - start);
}
function human() {
if (player && player.classList.contains(styles['is-demo'])) {
player.classList.remove(styles['is-demo']);
if (timer !== null) window.clearInterval(timer);
timer = null;
}
}
function onToggle() {
human();
if (!overlay || !ring || !status || !toggleBtn) return;
const manualStall = overlay.style.opacity !== '1';
overlay.style.opacity = manualStall ? '1' : '0';
overlay.style.setProperty('--blur', '8px');
ring.style.setProperty('--spin-duration', '900ms');
status.textContent = manualStall ? '버퍼링 중' : '재생 재개';
toggleBtn.setAttribute('aria-pressed', manualStall ? 'true' : 'false');
toggleBtn.textContent = manualStall ? '재생 다시 잇기' : '지금 버퍼링 멈춰보기';
}
toggleBtn.addEventListener('click', onToggle);
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 회전·흐림 변화는 멈추고 정지된 오버레이 상태 하나만 고정으로 보여준다
overlay.style.opacity = '0.6';
overlay.style.setProperty('--blur', '6px');
status.textContent = '버퍼링 중';
}
return () => {
if (timer !== null) window.clearInterval(timer);
toggleBtn.removeEventListener('click', onToggle);
};
}, []);
return (
<div className={`${styles.player} ${styles['is-demo']}`} ref={playerRef}>
<div className={styles.frame} aria-hidden="true">
<span className={styles.frameDot} />
<span className={styles.frameDot} />
<span className={styles.frameDot} />
</div>
<div className={styles.overlay} ref={overlayRef}>
<span className={styles.ring} ref={ringRef} />
</div>
<button className={styles.toggleBtn} ref={toggleBtnRef} type="button" aria-pressed="false">지금 버퍼링 멈춰보기</button>
<p className={styles.status} ref={statusRef} aria-live="polite">버퍼링 중</p>
</div>
);
}01 静止フレーム上のバッファリングオーバーレイ — 計測 累積面積 4.024% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · バッファ 表示 アニメーション 9種 — スピナーから画質バッジまで
このデモが属する記事
バッファ 表示 アニメーション 9種 — スピナーから画質バッジまでこのバッファ 表示 9種のうち、1つは再生位置とバッファ位置を二重リングで一緒に描き、1つは波形をバッファされた分だけ明るく塗りつぶし、1つはバッファ不足で画質が下がったバッジを表示します。 この記事のデモをすべて見る


