バッファ 表示 アニメーション 9種 — スピナーから画質バッジまで
このバッファ 表示 9種のうち、1つは再生位置とバッファ位置を二重リングで一緒に描き、1つは波形をバッファされた分だけ明るく塗りつぶし、1つはバッファ不足で画質が下がったバッジを表示します。
01 buffering-spinner-overlay
02 buffering-progress-ring
03 buffering-network-signal-bars
04 buffering-waveform-fill
05 buffering-stall-retry-shake
06 buffering-countdown-eta
07 buffering-scrub-preview-ghost
08 buffering-quality-drop-badge
09 buffering-reduced-motion
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 静止フレーム上のバッファリングオーバーレイ
- 02 再生・バッファ二重リング
- 03 信号強度バーのバッファ表示
- 04 バッファ分だけ塗られる波形
- 05 停止検知後に揺れる再試行アイコン
- 06 残り容量・速度から再計算する予想時間
- 07 シークバーのバッファゴーストプレビュー
- 08 帯域幅判定で変わる画質バッジ
- 09 動作の低減オン・オフの並列比較
以下には9種類のバッファリング表示UIパターンが並んでいます。どれもライブラリなし・ビルド手順なしの単純なHTML・CSS・JSファイル1枚なので、zip内の好きなフォルダだけを取り出してindex.htmlをブラウザで直接開けます。
デモ
上のカードをクリックして止めたり、シークバーをドラッグしたり、画質を強制的に切り替えたりして、それぞれがどう振る舞うか確かめてみてください。以下の説明はすべて実際のデモのソースコードを読んで書いたもので、記憶を頼りに書いた説明ではありません。
9つのパターンの解剖 — どこで計算の一層が回るか
表の数値は上のデモが実際に描画したキャプチャそのものから来ています — 各パターンにつき24枚のフレームを同じ方法でサンプリングした結果です。累積面積は動いたピクセルが各フレームで占めた割合の合計、平均強度はピクセル値が実際に動いたフレームでの変化の強さ、動いたフレーム数(23中)は23回のフレーム間比較のうち実際に差があった回数です。
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム(23中) |
|---|---|---|---|---|
| 01 | 静止フレーム上のバッファリングオーバーレイ | 4.02% | 71.5 | 20 |
| 02 | 再生・バッファ二重リング | 1.78% | 140.2 | 23 |
| 03 | 信号強度バーのバッファ表示 | 2.56% | 141.2 | 13 |
| 04 | バッファ分だけ塗られる波形 | 4.61% | 168.3 | 23 |
| 05 | 停止検知後に揺れる再試行アイコン | 10.05% | 47.2 | 17 |
| 06 | 残り容量・速度から再計算する予想時間 | 1.79% | 106.0 | 23 |
| 07 | シークバーのバッファゴーストプレビュー | 3.03% | 143.0 | 23 |
| 08 | 帯域幅判定で変わる画質バッジ | 1.53% | 67.9 | 16 |
| 09 | 動作の低減オン・オフの並列比較 | 0.96% | 110.3 | 23 |
9つの中で累積面積が最も広いのはパターン05の10.05%で、揺れるアイコンが往復する軌道が広いためです。反対に最も狭いのはパターン09の0.96%で、小さな二つのリングが並んで回るだけでは画面全体への影響がほとんど出ないからです。平均強度が最も高いのはパターン04の168.3で、9つの中で唯一150を超えています — マスクが波形の上で剥がれる動きは、回転や色のブレンドよりも動いたフレームあたりのピクセル変化が大きいためです。逆に平均強度が最も低いのはパターン05の47.2で、サンプリングされたフレームの多くが揺れの合間の静止状態を捉えているためです。パターン02・04・06・07・09はいずれも23回のフレーム間比較すべてで変化があり、9つの中で最多タイです。一方パターン03は13回にとどまり最少で、内部の保持ロジックが信号の変化中でもバーの表示をしばらく固定し続けるためです。
クラスタA — 合成サイン波が信号を模し、二層目がいつ信じるかを決める(01・03・06・08)
パターン01・03・06・08はいずれも同じ出発点を持ちます — Math.sin()を2〜3個組み合わせた関数がネットワーク状態(停止の揺れ・接続速度・転送速度・帯域幅の読み取り)の代わりを務め、実際のネットワークには一切触れません。違うのはその上に重ねる二層目です。01はその値をそのままぼかしと不透明度に流し込み、03と08はどちらも一定回数のティックやミリ秒だけ新しい値が続かないと表示を更新せず、06は残り量をその模した速度で割って秒数を出します。
クラスタB — 経過時間や固定の割合だけで一つの式が回る、センサーは関係ない(02・04・05)
パターン02・04・05はライブ信号らしきものを一切読みません — 02のリング形状は二つの独立した割合を一つの変換関数に渡すだけ、04の棒ごとの盛り上がりは進捗の割合に固定のイージング曲線を適用するだけ、05のアイコンの揺れは停止が閾値をどれだけ超えたかを減衰するサイン波に通すだけです。割合や経過時間を差し替えれば見た目は変わりますが、外部の何かを聞き取っているわけではありません。
クラスタC — 本物を測る二つのパターン(07・09)
パターン07は9つの中で唯一、実際のDOM要素を測定した値を核にしています — getBoundingClientRect()がトラックの実際の幅を返し、ポインタの実際のX座標をその幅で割った値がゴーストマーカーの位置になります。パターン09は9つの中で唯一、最初の確認のあとも聞き続けます — matchMediaのchangeハンドラがOS設定が実際に切り替わった瞬間にステータス文言を更新し、他の8つのように.matchesを一度だけ読んで終わるのとは違います。
パターン別コード
以下は9つそれぞれの完全なソースファイルから、実際に何が動くかを決めている計算の一層だけを取り出した短い断片です。
01静止フレーム上のバッファリングオーバーレイ
ストリーミングが途切れると画面上に半透明のオーバーレイが敷かれ、その上でスピナーが回る — 再生中だったフレームはそのまま静止して見える。

<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 に同梱 · パスワードを見る
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;
// 合成波2つでネットワークの揺れを再現する — 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 = '再生再開';
}
}
02再生・バッファ二重リング
円形リングを二重に重ね、外側リングは今までに受け取ったバッファ量を、内側リングは実際の再生位置を一緒に描く(329の単一リング+数字、035の単色ゾーン切り替えとは異なる実演 — バッファが再生位置よりどれだけ先行しているかが二つのリングの間隔で見える)。

<main class="stage">
<div class="ringWrap is-demo" id="ringWrap" role="progressbar" aria-label="バッファ進捗" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="bufferRing" id="bufferRing"></div>
<svg class="playRing" id="playRing" viewBox="0 0 120 120" aria-hidden="true">
<circle class="playTrack" cx="60" cy="60" r="42"></circle>
<circle class="playValue" id="playValue" cx="60" cy="60" r="42"></circle>
</svg>
<div class="readout">
<span class="pct" id="bufferPct">0%</span>
<span class="label">バッファ</span>
</div>
</div>
<button class="jumpBtn" id="jumpBtn" type="button">再生をバッファの末尾へ移動</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
// 계산 겹: index.html 의 apply() 가 버퍼·재생 퍼센트를 conic-gradient 정지점(%)과
// SVG 둘레 길이(stroke-dashoffset)로 바꾼다 — 두 링 사이 간격이 곧 "버퍼가 재생을 얼마나 앞서는가"다.
@include stage($stage-ink);
main.stage { position: relative; flex-direction: column; gap: $sp-4; display: grid; place-items: center; }
.ringWrap {
--ring-w: 6px;
--buffer-pct: 0%;
position: relative;
width: 120px;
height: 120px;
}
.bufferRing {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient($color var(--buffer-pct), rgba($subject-cream, .12) 0);
mask: radial-gradient(farthest-side, transparent calc(100% - var(--ring-w) - 2px), #000 calc(100% - var(--ring-w) - 2px));
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - var(--ring-w) - 2px), #000 calc(100% - var(--ring-w) - 2px));
transition: background 40ms linear;
}
.playRing {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
transform-origin: center;
}
.playTrack {
fill: none;
stroke: rgba($subject-cream, .14);
stroke-width: 4;
}
.playValue {
fill: none;
stroke: $subject-pink;
stroke-linecap: round;
transition: stroke-dashoffset $easing 40ms;
}
.readout {
position: absolute;
inset: 0;
display: grid;
place-items: center;
text-align: center;
}
.pct {
display: block;
font-size: 22px;
font-weight: 700;
color: $subject-cream;
}
.label {
display: block;
font-size: 10px;
color: rgba($subject-cream, .6);
}
.jumpBtn {
appearance: none;
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
font-size: 11px;
font-weight: 600;
color: $subject-ink;
background: $subject-cream;
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.ringWrap { width: 92px; height: 92px; }
.pct { font-size: 18px; }
.jumpBtn { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 링 값 전환(트랜지션)만 끈다. 값 자체(퍼센트)는 그대로 보여야 의미가 있다
@media (prefers-reduced-motion: reduce) {
.bufferRing, .playValue { 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);
}
main.stage {
position: relative;
flex-direction: column;
gap: 16px;
display: grid;
place-items: center;
}
.ringWrap {
--ring-w: 6px;
--buffer-pct: 0%;
position: relative;
width: 120px;
height: 120px;
}
.bufferRing {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(#2f6df6 var(--buffer-pct), rgba(255, 247, 230, 0.12) 0);
mask: radial-gradient(farthest-side, transparent calc(100% - var(--ring-w) - 2px), #000 calc(100% - var(--ring-w) - 2px));
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - var(--ring-w) - 2px), #000 calc(100% - var(--ring-w) - 2px));
transition: background 40ms linear;
}
.playRing {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
transform-origin: center;
}
.playTrack {
fill: none;
stroke: rgba(255, 247, 230, 0.14);
stroke-width: 4;
}
.playValue {
fill: none;
stroke: #ff9ebb;
stroke-linecap: round;
transition: stroke-dashoffset ease-out 40ms;
}
.readout {
position: absolute;
inset: 0;
display: grid;
place-items: center;
text-align: center;
}
.pct {
display: block;
font-size: 22px;
font-weight: 700;
color: #fff7e6;
}
.label {
display: block;
font-size: 10px;
color: rgba(255, 247, 230, 0.6);
}
.jumpBtn {
appearance: none;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #17141a;
background: #fff7e6;
cursor: pointer;
}
.jumpBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.ringWrap {
width: 92px;
height: 92px;
}
.pct {
font-size: 18px;
}
.jumpBtn {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bufferRing, .playValue {
transition: none;
}
}(function () {
var BUFFER_RING_STROKE_WIDTH = 6; // px — 변형이 6→14 로 바꾸는 값
var LOOP_MS = 2000; // 캡처 창과 같은 한 사이클
var TICK_MS = 50;
var PLAY_R = 42;
var CIRC = 2 * Math.PI * PLAY_R;
var wrap = document.getElementById('ringWrap');
var bufferRing = document.getElementById('bufferRing');
var playValue = document.getElementById('playValue');
var bufferPctEl = document.getElementById('bufferPct');
var jumpBtn = document.getElementById('jumpBtn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
wrap.style.setProperty('--ring-w', BUFFER_RING_STROKE_WIDTH + 'px');
playValue.style.strokeWidth = '4';
playValue.setAttribute('stroke-dasharray', CIRC.toFixed(2));
function easeInOut(x) { return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; }
function lerp(a, b, t) { return a + (b - a) * t; }
var manualPlay = null;
// バッファ 퍼센트·再生 퍼센트를 각도(그라디언트 정지점)·둘레 길이(stroke-dashoffset)로 바꾸는 산술
function apply(bufferFrac, playFrac) {
var bp = Math.round(bufferFrac * 100);
bufferRing.style.setProperty('--buffer-pct', bp + '%');
var offset = CIRC * (1 - playFrac);
playValue.style.strokeDashoffset = offset.toFixed(2);
bufferPctEl.textContent = bp + '%';
wrap.setAttribute('aria-valuenow', String(bp));
}
function tick(elapsedMs) {
var t = (elapsedMs % LOOP_MS) / LOOP_MS;
var bufferFrac = lerp(0.32, 0.95, t);
var playFrac = manualPlay !== null ? manualPlay : lerp(0.04, 0.8, easeInOut(t));
apply(bufferFrac, Math.min(playFrac, bufferFrac));
}
var timer = null;
var start = 0;
function loop() { tick(performance.now() - start); }
function human() {
if (wrap.classList.contains('is-demo')) {
wrap.classList.remove('is-demo');
window.clearInterval(timer);
timer = null;
}
}
jumpBtn.addEventListener('click', function () {
human();
var bp = parseFloat(bufferRing.style.getPropertyValue('--buffer-pct')) / 100;
manualPlay = bp;
apply(bp, bp);
});
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 — 링은 계속 진행률을 나타내야 하므로 애니메이션(전환)만 끄고 값은 정적으로 한 번 그린다
apply(0.62, 0.4);
}
})();import { useEffect, useRef } from 'react';
import styles from './BufferingProgressRing.module.scss';
const BUFFER_RING_STROKE_WIDTH = 6; // px — 변형이 6→14 로 바꾸는 값
const LOOP_MS = 2000;
const TICK_MS = 50;
const PLAY_R = 42;
const CIRC = 2 * Math.PI * PLAY_R;
function easeInOut(x: number) {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
export default function BufferingProgressRing() {
const wrapRef = useRef<HTMLDivElement>(null);
const bufferRingRef = useRef<HTMLDivElement>(null);
const playValueRef = useRef<SVGCircleElement>(null);
const bufferPctRef = useRef<HTMLSpanElement>(null);
const jumpBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
const bufferRing = bufferRingRef.current;
const playValue = playValueRef.current;
const bufferPctEl = bufferPctRef.current;
const jumpBtn = jumpBtnRef.current;
if (!wrap || !bufferRing || !playValue || !bufferPctEl || !jumpBtn) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
wrap.style.setProperty('--ring-w', BUFFER_RING_STROKE_WIDTH + 'px');
playValue.style.strokeWidth = '4';
playValue.setAttribute('stroke-dasharray', CIRC.toFixed(2));
let manualPlay: number | null = null;
function apply(bufferFrac: number, playFrac: number) {
if (!bufferRing || !playValue || !bufferPctEl || !wrap) return;
const bp = Math.round(bufferFrac * 100);
bufferRing.style.setProperty('--buffer-pct', bp + '%');
const offset = CIRC * (1 - playFrac);
playValue.style.strokeDashoffset = offset.toFixed(2);
bufferPctEl.textContent = bp + '%';
wrap.setAttribute('aria-valuenow', String(bp));
}
function tick(elapsedMs: number) {
const t = (elapsedMs % LOOP_MS) / LOOP_MS;
const bufferFrac = lerp(0.32, 0.95, t);
const playFrac = manualPlay !== null ? manualPlay : lerp(0.04, 0.8, easeInOut(t));
apply(bufferFrac, Math.min(playFrac, bufferFrac));
}
let timer: ReturnType<typeof window.setInterval> | null = null;
let start = 0;
function loop() {
tick(performance.now() - start);
}
function human() {
if (wrap && wrap.classList.contains(styles['is-demo'])) {
wrap.classList.remove(styles['is-demo']);
if (timer !== null) window.clearInterval(timer);
timer = null;
}
}
function onJump() {
human();
if (!bufferRing) return;
const bp = parseFloat(bufferRing.style.getPropertyValue('--buffer-pct')) / 100;
manualPlay = bp;
apply(bp, bp);
}
jumpBtn.addEventListener('click', onJump);
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 — 링은 계속 진행률을 나타내야 하므로 애니메이션(전환)만 끄고 값은 정적으로 한 번 그린다
apply(0.62, 0.4);
}
return () => {
if (timer !== null) window.clearInterval(timer);
jumpBtn.removeEventListener('click', onJump);
};
}, []);
return (
<>
<div
className={`${styles.ringWrap} ${styles['is-demo']}`}
ref={wrapRef}
role="progressbar"
aria-label="버퍼 진행률"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={0}
>
<div className={styles.bufferRing} ref={bufferRingRef} />
<svg className={styles.playRing} viewBox="0 0 120 120" aria-hidden="true">
<circle className={styles.playTrack} cx={60} cy={60} r={42} />
<circle className={styles.playValue} ref={playValueRef} cx={60} cy={60} r={42} />
</svg>
<div className={styles.readout}>
<span className={styles.pct} ref={bufferPctRef}>0%</span>
<span className={styles.label}>버퍼</span>
</div>
</div>
<button className={styles.jumpBtn} ref={jumpBtnRef} type="button">버퍼 끝으로 재생 이동</button>
</>
);
}02 · 再生・バッファ二重リング — 計測 累積面積 1.778% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// バッファ%と再生%を角度(グラデーション停止点)と周長(stroke-dashoffset)に変える算術
function apply(bufferFrac, playFrac) {
var bp = Math.round(bufferFrac * 100);
bufferRing.style.setProperty('--buffer-pct', bp + '%');
var offset = CIRC * (1 - playFrac);
playValue.style.strokeDashoffset = offset.toFixed(2);
bufferPctEl.textContent = bp + '%';
wrap.setAttribute('aria-valuenow', String(bp));
}
03信号強度バーのバッファ表示
信号の強さを表す4〜5本のバーがバッファリング速度に合わせて順に明るくなったり暗くなったりを繰り返し、点灯している本数で今の受信速度が速いか遅いかを示す。こうしたプログレスバー cssの多くは最終的に0から5までの一つの数値に還元されますが、このバーはその数値を画面に届ける方法が他の8つと違うだけです。

<main class="stage">
<div class="meter is-demo" id="meter" role="img" aria-label="ネットワーク信号強度">
<span class="bar" data-i="0"></span>
<span class="bar" data-i="1"></span>
<span class="bar" data-i="2"></span>
<span class="bar" data-i="3"></span>
<span class="bar" data-i="4"></span>
</div>
<p class="readout" id="readout">信号普通</p>
<button class="pinBtn" id="pinBtn" type="button" aria-pressed="false">この強さで固定</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-mint;
// 계산 겹: index.html 의 tick() 이 결정론적 합성파를 만들고, 목표 레벨과 표시 레벨 차이가
// CHANGE_MARGIN 을 넘고 HOLD_TICKS 만큼 유지될 때만 레벨을 바꾼다 — 값 흔들림(플리커)을 막는 히스테리시스.
@include stage($stage-yellow);
.meter {
display: flex;
align-items: flex-end;
gap: $sp-2;
height: 90px;
}
.bar {
--bar-level: .22;
display: block;
width: 16px;
border-radius: $r-xs;
background: $color;
// 막대는 바닥(baseline)에 붙어 자라는 계기판이라 origin 을 bottom 으로 둔다 (rule 8 예외: 시작 변에서 자라는 요소)
transform-origin: bottom;
transform: scaleY(var(--bar-level));
transition: transform $duration $easing;
&:nth-child(1) { height: 28px; }
&:nth-child(2) { height: 44px; }
&:nth-child(3) { height: 60px; }
&:nth-child(4) { height: 76px; }
&:nth-child(5) { height: 90px; }
}
.readout {
margin: $sp-3 0 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
text-align: center;
}
.pinBtn {
appearance: none;
border: none;
margin-top: $sp-2;
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 $subject-blue; outline-offset: 2px; }
&[aria-pressed="true"] { background: $subject-orange-deep; }
}
@media (max-width: 340px) {
.meter { height: 64px; gap: $sp-1; }
.bar { width: 12px; }
.bar:nth-child(1) { height: 20px; }
.bar:nth-child(2) { height: 30px; }
.bar:nth-child(3) { height: 42px; }
.bar:nth-child(4) { height: 54px; }
.bar:nth-child(5) { height: 64px; }
.readout, .pinBtn { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 막대 높이 전환만 끈다. 레벨 값 자체는 그대로 반영해야 신호 정보가 전달된다
@media (prefers-reduced-motion: reduce) {
.bar { 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);
}
.meter {
display: flex;
align-items: flex-end;
gap: 8px;
height: 90px;
}
.bar {
--bar-level: .22;
display: block;
width: 16px;
border-radius: 4px;
background: #4cd4a6;
transform-origin: bottom;
transform: scaleY(var(--bar-level));
transition: transform 220ms ease-out;
}
.bar:nth-child(1) {
height: 28px;
}
.bar:nth-child(2) {
height: 44px;
}
.bar:nth-child(3) {
height: 60px;
}
.bar:nth-child(4) {
height: 76px;
}
.bar:nth-child(5) {
height: 90px;
}
.readout {
margin: 12px 0 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
text-align: center;
}
.pinBtn {
appearance: none;
border: none;
margin-top: 8px;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #fff7e6;
background: #17141a;
cursor: pointer;
}
.pinBtn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pinBtn[aria-pressed=true] {
background: #c93000;
}
@media (max-width: 340px) {
.meter {
height: 64px;
gap: 4px;
}
.bar {
width: 12px;
}
.bar:nth-child(1) {
height: 20px;
}
.bar:nth-child(2) {
height: 30px;
}
.bar:nth-child(3) {
height: 42px;
}
.bar:nth-child(4) {
height: 54px;
}
.bar:nth-child(5) {
height: 64px;
}
.readout, .pinBtn {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bar {
transition: none;
}
}(function () {
var BAR_COUNT = 5;
var TICK_MS = 90; // 신호 재측정 간격
var CHANGE_MARGIN = 0.6; // 히스테리시스 — 표시 레벨을 바꾸려면 이만큼은 벌어져야 한다(레벨 단위)
var HOLD_TICKS = 3; // 새 레벨이 이 틱 수만큼 계속 유지돼야 실제로 바뀐다
var meter = document.getElementById('meter');
var bars = Array.prototype.slice.call(meter.querySelectorAll('.bar'));
var readout = document.getElementById('readout');
var pinBtn = document.getElementById('pinBtn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var labels = ['信号弱い', '信号弱い', '信号普通', '信号良好', '信号強い'];
var displayLevel = 2;
var pendingLevel = 2;
var pendingCount = 0;
var pinned = null;
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
// 결정론적 합성파로 "속도"를 흉내내고, 그 값을 0~5 레벨로 양자화한 뒤 히스테리시스를 거친다
function rawSpeed(ms) {
var w = 0.5 + 0.5 * Math.sin(ms / 700) + 0.25 * Math.sin(ms / 233) + 0.15 * Math.sin(ms / 97);
return clamp(w, 0, 1.35);
}
function paint(level) {
bars.forEach(function (b, i) {
b.style.setProperty('--bar-level', i < level ? '1' : '.22');
});
readout.textContent = labels[clamp(level, 0, 4)];
meter.setAttribute('aria-label', 'ネットワーク信号強度 ' + level + ' / ' + BAR_COUNT);
}
function tick(ms) {
if (pinned !== null) return;
var raw = rawSpeed(ms) * BAR_COUNT;
var targetLevel = clamp(Math.round(raw), 0, BAR_COUNT);
if (targetLevel !== displayLevel && Math.abs(raw - displayLevel) > CHANGE_MARGIN) {
if (targetLevel === pendingLevel) {
pendingCount++;
} else {
pendingLevel = targetLevel;
pendingCount = 1;
}
if (pendingCount >= HOLD_TICKS) {
displayLevel = pendingLevel;
pendingCount = 0;
}
} else {
pendingCount = 0;
pendingLevel = displayLevel;
}
paint(displayLevel);
}
var timer = null;
var start = 0;
function loop() { tick(performance.now() - start); }
function human() {
if (meter.classList.contains('is-demo')) {
meter.classList.remove('is-demo');
window.clearInterval(timer);
timer = null;
}
}
pinBtn.addEventListener('click', function () {
human();
if (pinned === null) {
pinned = displayLevel;
pinBtn.setAttribute('aria-pressed', 'true');
pinBtn.textContent = '固定を解除';
} else {
pinned = null;
pinBtn.setAttribute('aria-pressed', 'false');
pinBtn.textContent = 'この強さで固定';
}
});
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 — 막대 높이 전환은 멈추고 마지막 측정 레벨 하나로 고정해 보여준다
paint(3);
}
})();import { useEffect, useRef } from 'react';
import styles from './BufferingNetworkSignalBars.module.scss';
const BAR_COUNT = 5;
const TICK_MS = 90; // 신호 재측정 간격
const CHANGE_MARGIN = 0.6; // 히스테리시스 — 표시 레벨을 바꾸려면 이만큼은 벌어져야 한다(레벨 단위)
const HOLD_TICKS = 3; // 새 레벨이 이 틱 수만큼 계속 유지돼야 실제로 바뀐다
const LABELS = ['신호 약함', '신호 약함', '신호 보통', '신호 양호', '신호 강함'];
function clamp(v: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, v));
}
export default function BufferingNetworkSignalBars() {
const meterRef = useRef<HTMLDivElement>(null);
const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
const readoutRef = useRef<HTMLParagraphElement>(null);
const pinBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const meter = meterRef.current;
const readout = readoutRef.current;
const pinBtn = pinBtnRef.current;
const bars = barRefs.current;
if (!meter || !readout || !pinBtn || !bars) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let displayLevel = 2;
let pendingLevel = 2;
let pendingCount = 0;
let pinned: number | null = null;
// 결정론적 합성파로 "속도"를 흉내내고, 그 값을 0~5 레벨로 양자화한 뒤 히스테리시스를 거친다
function rawSpeed(ms: number) {
const w = 0.5 + 0.5 * Math.sin(ms / 700) + 0.25 * Math.sin(ms / 233) + 0.15 * Math.sin(ms / 97);
return clamp(w, 0, 1.35);
}
function paint(level: number) {
bars!.forEach((b, i) => {
if (b) b.style.setProperty('--bar-level', i < level ? '1' : '.22');
});
if (readout) readout.textContent = LABELS[clamp(level, 0, 4)];
if (meter) meter.setAttribute('aria-label', '네트워크 신호 세기 ' + level + ' / ' + BAR_COUNT);
}
function tick(ms: number) {
if (pinned !== null) return;
const raw = rawSpeed(ms) * BAR_COUNT;
const targetLevel = clamp(Math.round(raw), 0, BAR_COUNT);
if (targetLevel !== displayLevel && Math.abs(raw - displayLevel) > CHANGE_MARGIN) {
if (targetLevel === pendingLevel) {
pendingCount++;
} else {
pendingLevel = targetLevel;
pendingCount = 1;
}
if (pendingCount >= HOLD_TICKS) {
displayLevel = pendingLevel;
pendingCount = 0;
}
} else {
pendingCount = 0;
pendingLevel = displayLevel;
}
paint(displayLevel);
}
let timer: ReturnType<typeof window.setInterval> | null = null;
let start = 0;
function loop() {
tick(performance.now() - start);
}
function human() {
if (meter && meter.classList.contains(styles['is-demo'])) {
meter.classList.remove(styles['is-demo']);
if (timer !== null) window.clearInterval(timer);
timer = null;
}
}
function onPin() {
human();
if (!pinBtn) return;
if (pinned === null) {
pinned = displayLevel;
pinBtn.setAttribute('aria-pressed', 'true');
pinBtn.textContent = '고정 풀기';
} else {
pinned = null;
pinBtn.setAttribute('aria-pressed', 'false');
pinBtn.textContent = '이 세기로 고정';
}
}
pinBtn.addEventListener('click', onPin);
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 — 막대 높이 전환은 멈추고 마지막 측정 레벨 하나로 고정해 보여준다
paint(3);
}
return () => {
if (timer !== null) window.clearInterval(timer);
pinBtn.removeEventListener('click', onPin);
};
}, []);
return (
<>
<div className={`${styles.meter} ${styles['is-demo']}`} ref={meterRef} role="img" aria-label="네트워크 신호 세기">
{[0, 1, 2, 3, 4].map((i) => (
<span key={i} className={styles.bar} ref={(el: HTMLSpanElement | null) => { if (barRefs.current) barRefs.current[i] = el; }} />
))}
</div>
<p className={styles.readout} ref={readoutRef}>신호 보통</p>
<button className={styles.pinBtn} ref={pinBtnRef} type="button" aria-pressed="false">이 세기로 고정</button>
</>
);
}03 · 信号強度バーのバッファ表示 — 計測 累積面積 2.558% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function tick(ms) {
if (pinned !== null) return;
var raw = rawSpeed(ms) * BAR_COUNT;
var targetLevel = clamp(Math.round(raw), 0, BAR_COUNT);
if (targetLevel !== displayLevel && Math.abs(raw - displayLevel) > CHANGE_MARGIN) {
if (targetLevel === pendingLevel) {
pendingCount++;
} else {
pendingLevel = targetLevel;
pendingCount = 1;
}
if (pendingCount >= HOLD_TICKS) {
displayLevel = pendingLevel;
pendingCount = 0;
}
} else {
pendingCount = 0;
pendingLevel = displayLevel;
}
paint(displayLevel);
}
04バッファ分だけ塗られる波形
オーディオ波形バーの上に半透明の膜を重ね、バッファされた区間の分だけその膜を剥がして本来の色を見せる(291のリアルタイム解析ビジュアライザーとは異なる実演 — ここではマイクやファイル解析なしに、バッファ進捗値ひとつだけで膜が剥がれる幅を計算する)。

<main class="stage">
<div class="waveWrap is-demo" id="waveWrap" role="progressbar" aria-label="オーディオバッファ進捗" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="bars" id="bars"></div>
<div class="film" id="film"></div>
</div>
<button class="skipBtn" id="skipBtn" type="button">バッファ済みの位置までスキップ</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
// 계산 겹: index.html 의 bezier() 가 WAVEFORM_FILL_EASE 문자열을 실제 3차 베지어 곡선으로 풀어
// 진행률(t)을 채움 비율로 바꾸고, 그 비율의 경계 막대에만 sin() 범프를 얹어 "채워지는 순간"을 표시한다.
@include stage($stage-paper);
.waveWrap {
position: relative;
width: 280px;
height: 100px;
}
.bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 100%;
}
.bar {
--h: 20px;
--bump: 0px;
display: block;
width: 8px;
border-radius: $r-xs;
background: $color;
// 막대는 바닥에서 자라는 파형 계기라 origin 을 bottom 으로 둔다
transform-origin: bottom;
height: calc(var(--h) + var(--bump));
}
.film {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba($stage-paper, .92), rgba($stage-paper, .8));
mask-image: linear-gradient(to right, transparent var(--buffer-pct), #000 var(--buffer-pct));
-webkit-mask-image: linear-gradient(to right, transparent var(--buffer-pct), #000 var(--buffer-pct));
}
.skipBtn {
appearance: none;
border: none;
margin-top: $sp-3;
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 $subject-blue-deep; outline-offset: 2px; }
}
@media (max-width: 340px) {
.waveWrap { width: 220px; height: 76px; }
.bar { width: 6px; }
.skipBtn { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 채움 이징(진행) 자체는 JS 가 멈춘 채 한 값으로 그리므로 scss 는 덧붙일 전환이 없다
@media (prefers-reduced-motion: reduce) {
.bar { transition: none; }
}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);
}
.waveWrap {
position: relative;
width: 280px;
height: 100px;
}
.bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 100%;
}
.bar {
--h: 20px;
--bump: 0px;
display: block;
width: 8px;
border-radius: 4px;
background: #2f6df6;
transform-origin: bottom;
height: calc(var(--h) + var(--bump));
}
.film {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.8));
mask-image: linear-gradient(to right, transparent var(--buffer-pct), #000 var(--buffer-pct));
-webkit-mask-image: linear-gradient(to right, transparent var(--buffer-pct), #000 var(--buffer-pct));
}
.skipBtn {
appearance: none;
border: none;
margin-top: 12px;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #fff7e6;
background: #17141a;
cursor: pointer;
}
.skipBtn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.waveWrap {
width: 220px;
height: 76px;
}
.bar {
width: 6px;
}
.skipBtn {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bar {
transition: none;
}
}(function () {
var WAVEFORM_FILL_EASE = 'linear'; // 훅 — 변형이 cubic-bezier(.22,1,.36,1) 로 바꾸는 값
var BAR_COUNT = 22;
var LOOP_MS = 2000;
var TICK_MS = 70;
var BUMP_PX = 10; // バッファ 경계 막대가 채워지는 순간 살짝 솟는 최대 높이
// 고정 패턴(사인 두 겹 합성) — Math.random 없이 매번 같은 파형을 만든다
var HEIGHTS = [];
for (var i = 0; i < BAR_COUNT; i++) {
var v = 18 + 16 * Math.abs(Math.sin(i * 0.7)) + 8 * Math.abs(Math.sin(i * 1.9));
HEIGHTS.push(Math.round(v));
}
var wrap = document.getElementById('waveWrap');
var barsEl = document.getElementById('bars');
var film = document.getElementById('film');
var skipBtn = document.getElementById('skipBtn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var bars = HEIGHTS.map(function (h, i) {
var b = document.createElement('span');
b.className = 'bar';
b.style.setProperty('--h', h + 'px');
barsEl.appendChild(b);
return b;
});
// 3차 베지어 이징 평가기 — WAVEFORM_FILL_EASE 값을 실제 진행률 곡선에 반영한다
function bezier(x1, y1, x2, y2) {
function s(a, b) { return 3 * a * (1 - 3 * b + 3 * a) + 3 * b * b * (0); }
function bx(t) { return 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t; }
function by(t) { return 3 * (1 - t) * (1 - t) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t; }
return function (x) {
var t = x;
for (var it = 0; it < 6; it++) {
var cx = bx(t) - x;
var d = 3 * (1 - t) * (1 - t) * x1 + 6 * (1 - t) * t * (x2 - x1) + 3 * t * t * (1 - x2);
if (Math.abs(d) < 1e-6) break;
t -= cx / d;
t = Math.min(1, Math.max(0, t));
}
return by(t);
};
}
function easeFn() {
if (WAVEFORM_FILL_EASE === 'linear') return function (x) { return x; };
var nums = WAVEFORM_FILL_EASE.replace('cubic-bezier(', '').replace(')', '').split(',').map(Number);
return bezier(nums[0], nums[1], nums[2], nums[3]);
}
var ease = easeFn();
function paint(pct) {
var pctStr = (pct * 100).toFixed(1) + '%';
film.style.setProperty('--buffer-pct', pctStr);
wrap.setAttribute('aria-valuenow', String(Math.round(pct * 100)));
var raw = pct * BAR_COUNT;
var idx = Math.min(BAR_COUNT - 1, Math.floor(raw));
var frac = raw - idx;
bars.forEach(function (b, i) {
var bump = i === idx ? Math.sin(frac * Math.PI) * BUMP_PX : 0;
b.style.setProperty('--bump', bump.toFixed(1) + 'px');
});
}
var manualPct = null;
function tick(elapsedMs) {
if (manualPct !== null) { paint(manualPct); return; }
var t = (elapsedMs % LOOP_MS) / LOOP_MS;
paint(ease(t));
}
var timer = null;
var start = 0;
function loop() { tick(performance.now() - start); }
function human() {
if (wrap.classList.contains('is-demo')) {
wrap.classList.remove('is-demo');
window.clearInterval(timer);
timer = null;
}
}
skipBtn.addEventListener('click', function () {
human();
manualPct = 1;
paint(1);
});
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 — 채워지는 전환은 멈추고 バッファ 진행률을 고정된 한 장면으로만 보여준다
paint(0.55);
}
})();import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import styles from './BufferingWaveformFill.module.scss';
const WAVEFORM_FILL_EASE: string = 'linear'; // 훅 — 변형이 cubic-bezier(.22,1,.36,1) 로 바꾸는 값
const BAR_COUNT = 22;
const LOOP_MS = 2000;
const TICK_MS = 70;
const BUMP_PX = 10; // 버퍼 경계 막대가 채워지는 순간 살짝 솟는 최대 높이
// 고정 패턴(사인 두 겹 합성) — Math.random 없이 매번 같은 파형을 만든다
const HEIGHTS: number[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
const v = 18 + 16 * Math.abs(Math.sin(i * 0.7)) + 8 * Math.abs(Math.sin(i * 1.9));
HEIGHTS.push(Math.round(v));
}
// 3차 베지어 이징 평가기 — WAVEFORM_FILL_EASE 값을 실제 진행률 곡선에 반영한다
function bezier(x1: number, y1: number, x2: number, y2: number) {
function bx(t: number) { return 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t; }
function by(t: number) { return 3 * (1 - t) * (1 - t) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t; }
return function (x: number) {
let t = x;
for (let it = 0; it < 6; it++) {
const cx = bx(t) - x;
const d = 3 * (1 - t) * (1 - t) * x1 + 6 * (1 - t) * t * (x2 - x1) + 3 * t * t * (1 - x2);
if (Math.abs(d) < 1e-6) break;
t -= cx / d;
t = Math.min(1, Math.max(0, t));
}
return by(t);
};
}
function easeFn(): (x: number) => number {
if (WAVEFORM_FILL_EASE === 'linear') return (x: number) => x;
const nums = WAVEFORM_FILL_EASE.replace('cubic-bezier(', '').replace(')', '').split(',').map(Number);
return bezier(nums[0], nums[1], nums[2], nums[3]);
}
export default function BufferingWaveformFill() {
const wrapRef = useRef<HTMLDivElement>(null);
const filmRef = useRef<HTMLDivElement>(null);
const skipBtnRef = useRef<HTMLButtonElement>(null);
const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
useEffect(() => {
const wrap = wrapRef.current;
const film = filmRef.current;
const skipBtn = skipBtnRef.current;
const bars = barRefs.current;
if (!wrap || !film || !skipBtn || !bars) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = easeFn();
function paint(pct: number) {
if (!film || !wrap) return;
const pctStr = (pct * 100).toFixed(1) + '%';
film.style.setProperty('--buffer-pct', pctStr);
wrap.setAttribute('aria-valuenow', String(Math.round(pct * 100)));
const raw = pct * BAR_COUNT;
const idx = Math.min(BAR_COUNT - 1, Math.floor(raw));
const frac = raw - idx;
bars!.forEach((b, i) => {
if (!b) return;
const bump = i === idx ? Math.sin(frac * Math.PI) * BUMP_PX : 0;
b.style.setProperty('--bump', bump.toFixed(1) + 'px');
});
}
let manualPct: number | null = null;
function tick(elapsedMs: number) {
if (manualPct !== null) { paint(manualPct); return; }
const t = (elapsedMs % LOOP_MS) / LOOP_MS;
paint(ease(t));
}
let timer: ReturnType<typeof window.setInterval> | null = null;
let start = 0;
function loop() {
tick(performance.now() - start);
}
function human() {
if (wrap && wrap.classList.contains(styles['is-demo'])) {
wrap.classList.remove(styles['is-demo']);
if (timer !== null) window.clearInterval(timer);
timer = null;
}
}
function onSkip() {
human();
manualPct = 1;
paint(1);
}
skipBtn.addEventListener('click', onSkip);
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 — 채워지는 전환은 멈추고 버퍼 진행률을 고정된 한 장면으로만 보여준다
paint(0.55);
}
return () => {
if (timer !== null) window.clearInterval(timer);
skipBtn.removeEventListener('click', onSkip);
};
}, []);
return (
<>
<div
className={`${styles.waveWrap} ${styles['is-demo']}`}
ref={wrapRef}
role="progressbar"
aria-label="오디오 버퍼 진행률"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={0}
>
<div className={styles.bars}>
{HEIGHTS.map((h, i) => (
<span
key={i}
className={styles.bar}
ref={(el: HTMLSpanElement | null) => { if (barRefs.current) barRefs.current[i] = el; }}
style={{ ['--h' as string]: h + 'px' } as CSSProperties}
/>
))}
</div>
<div className={styles.film} ref={filmRef} />
</div>
<button className={styles.skipBtn} ref={skipBtnRef} type="button">버퍼된 곳까지 건너뛰기</button>
</>
);
}04 · バッファ分だけ塗られる波形 — 計測 累積面積 4.609% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function easeFn() {
if (WAVEFORM_FILL_EASE === 'linear') return function (x) { return x; };
var nums = WAVEFORM_FILL_EASE.replace('cubic-bezier(', '').replace(')', '').split(',').map(Number);
return bezier(nums[0], nums[1], nums[2], nums[3]);
}
var ease = easeFn();
function paint(pct) {
var pctStr = (pct * 100).toFixed(1) + '%';
film.style.setProperty('--buffer-pct', pctStr);
wrap.setAttribute('aria-valuenow', String(Math.round(pct * 100)));
var raw = pct * BAR_COUNT;
var idx = Math.min(BAR_COUNT - 1, Math.floor(raw));
var frac = raw - idx;
bars.forEach(function (b, i) {
var bump = i === idx ? Math.sin(frac * Math.PI) * BUMP_PX : 0;
b.style.setProperty('--bump', bump.toFixed(1) + 'px');
});
}
05停止検知後に揺れる再試行アイコン
停止が一定時間(300ms)を超えると状態文言が「停止中」から「接続が切れて再試行中」に変わり、リトライアイコンが減衰するサイン波で短く揺れて収まる。ボタンを押すと即座に再生再開に戻る。

<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 に同梱 · パスワードを見る
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);
}
06残り容量・速度から再計算する予想時間
毎ティック、残り容量を再計算した瞬間速度で割り直してETA表示を更新し、一時停止すると文言が止まり、受信完了で「受信完了」に変わる(349のリトライ待ちカウントダウンとは異なる実演 — こちらは失敗後の待機ではなく、今埋まっている速度から毎瞬残り秒数を再計算する)。

<main class="stage">
<div class="etaCard is-demo" id="etaCard">
<div class="track" id="track" role="progressbar" aria-label="受信済み容量" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="fill" id="fill"></div>
</div>
<p class="eta" id="eta" aria-live="polite">計算中</p>
<p class="detail" id="detail">0.0MB / 48.0MB · 0.0MB/s</p>
<button class="pauseBtn" id="pauseBtn" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-mint;
// 계산 겹: index.html 의 instSpeed() 가 합성파로 순간 속도를 만들고, tick() 이
// "남은 용량 ÷ 그 순간 속도" 를 매 틱 다시 나눠 ETA 문구를 새로 계산한다 — 저장된 카운트다운이 아니다.
@include stage($stage-paper);
.etaCard {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: 240px;
}
.track {
position: relative;
width: 100%;
height: 10px;
border-radius: $r-pill;
background: rgba($subject-ink, .1);
overflow: hidden;
}
.fill {
--fill-pct: 0%;
position: absolute;
inset: 0;
width: var(--fill-pct);
border-radius: $r-pill;
background: $color;
transition: width $duration $easing;
}
.eta {
margin: 0;
font-size: 14px;
font-weight: 700;
color: $subject-ink;
}
.detail {
margin: 0;
font-size: 11px;
color: rgba($subject-ink, .6);
}
.pauseBtn {
appearance: none;
border: none;
margin-top: $sp-1;
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 $subject-blue-deep; outline-offset: 2px; }
&[aria-pressed="true"] { background: $subject-orange-deep; }
}
@media (max-width: 340px) {
.etaCard { width: 190px; }
.eta { font-size: 12px; }
.detail, .pauseBtn { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 채움 막대 너비 전환만 끈다. ETA 문구는 값이 곧 정보라 계속 갱신돼야 한다
@media (prefers-reduced-motion: reduce) {
.fill { transition: none; }
}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);
}
.etaCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 240px;
}
.track {
position: relative;
width: 100%;
height: 10px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.fill {
--fill-pct: 0%;
position: absolute;
inset: 0;
width: var(--fill-pct);
border-radius: 999px;
background: #4cd4a6;
transition: width 300ms ease-out;
}
.eta {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #17141a;
}
.detail {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.6);
}
.pauseBtn {
appearance: none;
border: none;
margin-top: 4px;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #fff7e6;
background: #17141a;
cursor: pointer;
}
.pauseBtn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.pauseBtn[aria-pressed=true] {
background: #c93000;
}
@media (max-width: 340px) {
.etaCard {
width: 190px;
}
.eta {
font-size: 12px;
}
.detail, .pauseBtn {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fill {
transition: none;
}
}(function () {
var TOTAL_MB = 48;
var LOOP_MS = 7000; // 이 시간 안에 전체를 받는 기준 사이클
var TICK_MS = 120;
var BASE_SPEED_MBPS = TOTAL_MB / (LOOP_MS / 1000);
var SPEED_VARIANCE = 0.55; // 속도 흔들림 비율
var etaCard = document.getElementById('etaCard');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var etaEl = document.getElementById('eta');
var detail = document.getElementById('detail');
var pauseBtn = document.getElementById('pauseBtn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var loadedMb = 0;
var lastMs = 0;
var paused = false;
// 결정론적 합성파로 순간 전송 속도를 흉내낸다 — 매 틱 다른 속도라 ETA 가 매번 다시 계산된다
function instSpeed(ms) {
var w = 1 + SPEED_VARIANCE * Math.sin(ms / 480) + 0.2 * Math.sin(ms / 133);
return Math.max(0.15, BASE_SPEED_MBPS * w);
}
function paint(speed) {
var pct = Math.min(100, (loadedMb / TOTAL_MB) * 100);
fill.style.setProperty('--fill-pct', pct.toFixed(1) + '%');
track.setAttribute('aria-valuenow', String(Math.round(pct)));
detail.textContent = loadedMb.toFixed(1) + 'MB / ' + TOTAL_MB.toFixed(1) + 'MB · ' + speed.toFixed(1) + 'MB/s';
if (paused) {
etaEl.textContent = '一時停止中';
} else if (loadedMb >= TOTAL_MB) {
etaEl.textContent = '受信完了';
} else {
// 남은 용량 ÷ 지금 속도 = 예상 시간 — 저장해 둔 카운트다운이 아니라 매 틱 새로 나눈 값
var remaining = TOTAL_MB - loadedMb;
var etaSec = remaining / speed;
etaEl.textContent = '約 ' + Math.ceil(etaSec) + '秒';
}
}
function tick(nowMs) {
var dtMs = lastMs === 0 ? 0 : nowMs - lastMs;
lastMs = nowMs;
var speed = instSpeed(nowMs);
if (!paused && loadedMb < TOTAL_MB) {
loadedMb = Math.min(TOTAL_MB, loadedMb + speed * (dtMs / 1000));
}
if (loadedMb >= TOTAL_MB && !paused) {
loadedMb = 0; // 데모 루프 — 완료 뒤 처음부터 다시
}
paint(speed);
}
var timer = null;
var start = 0;
function loop() { tick(performance.now() - start); }
function human() {
if (etaCard.classList.contains('is-demo')) {
etaCard.classList.remove('is-demo');
window.clearInterval(timer);
timer = null;
}
}
pauseBtn.addEventListener('click', function () {
human();
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
pauseBtn.textContent = paused ? 'ダウンロードを再開' : '一時停止';
paint(instSpeed(performance.now() - start));
if (!timer) {
lastMs = performance.now() - start;
timer = window.setInterval(loop, TICK_MS);
}
});
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 채움 막대 전환만 멈추고 남은 시간 문구는 한 값으로 고정해 보여준다
loadedMb = TOTAL_MB * 0.4;
paint(BASE_SPEED_MBPS);
}
})();import { useEffect, useRef } from 'react';
import styles from './BufferingCountdownEta.module.scss';
const TOTAL_MB = 48;
const LOOP_MS = 7000; // 이 시간 안에 전체를 받는 기준 사이클
const TICK_MS = 120;
const BASE_SPEED_MBPS = TOTAL_MB / (LOOP_MS / 1000);
const SPEED_VARIANCE = 0.55; // 속도 흔들림 비율
// 결정론적 합성파로 순간 전송 속도를 흉내낸다 — 매 틱 다른 속도라 ETA 가 매번 다시 계산된다
function instSpeed(ms: number) {
const w = 1 + SPEED_VARIANCE * Math.sin(ms / 480) + 0.2 * Math.sin(ms / 133);
return Math.max(0.15, BASE_SPEED_MBPS * w);
}
export default function BufferingCountdownEta() {
const cardRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const etaRef = useRef<HTMLParagraphElement>(null);
const detailRef = useRef<HTMLParagraphElement>(null);
const pauseBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const card = cardRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const etaEl = etaRef.current;
const detail = detailRef.current;
const pauseBtn = pauseBtnRef.current;
if (!card || !track || !fill || !etaEl || !detail || !pauseBtn) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let loadedMb = 0;
let lastMs = 0;
let paused = false;
function paint(speed: number) {
if (!fill || !track || !detail || !etaEl) return;
const pct = Math.min(100, (loadedMb / TOTAL_MB) * 100);
fill.style.setProperty('--fill-pct', pct.toFixed(1) + '%');
track.setAttribute('aria-valuenow', String(Math.round(pct)));
detail.textContent = loadedMb.toFixed(1) + 'MB / ' + TOTAL_MB.toFixed(1) + 'MB · ' + speed.toFixed(1) + 'MB/s';
if (paused) {
etaEl.textContent = '일시정지됨';
} else if (loadedMb >= TOTAL_MB) {
etaEl.textContent = '받기 완료';
} else {
// 남은 용량 ÷ 지금 속도 = 예상 시간 — 저장해 둔 카운트다운이 아니라 매 틱 새로 나눈 값
const remaining = TOTAL_MB - loadedMb;
const etaSec = remaining / speed;
etaEl.textContent = '약 ' + Math.ceil(etaSec) + '초 남음';
}
}
function tick(nowMs: number) {
const dtMs = lastMs === 0 ? 0 : nowMs - lastMs;
lastMs = nowMs;
const speed = instSpeed(nowMs);
if (!paused && loadedMb < TOTAL_MB) {
loadedMb = Math.min(TOTAL_MB, loadedMb + speed * (dtMs / 1000));
}
if (loadedMb >= TOTAL_MB && !paused) {
loadedMb = 0; // 데모 루프 — 완료 뒤 처음부터 다시
}
paint(speed);
}
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 onPause() {
human();
if (!pauseBtn) return;
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
pauseBtn.textContent = paused ? '이어받기' : '일시정지';
paint(instSpeed(performance.now() - start));
if (!timer) {
lastMs = performance.now() - start;
timer = window.setInterval(loop, TICK_MS);
}
}
pauseBtn.addEventListener('click', onPause);
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 채움 막대 전환만 멈추고 남은 시간 문구는 한 값으로 고정해 보여준다
loadedMb = TOTAL_MB * 0.4;
paint(BASE_SPEED_MBPS);
}
return () => {
if (timer !== null) window.clearInterval(timer);
pauseBtn.removeEventListener('click', onPause);
};
}, []);
return (
<div className={`${styles.etaCard} ${styles['is-demo']}`} ref={cardRef}>
<div
className={styles.track}
ref={trackRef}
role="progressbar"
aria-label="받은 용량"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={0}
>
<div className={styles.fill} ref={fillRef} />
</div>
<p className={styles.eta} ref={etaRef} aria-live="polite">계산 중</p>
<p className={styles.detail} ref={detailRef}>0.0MB / 48.0MB · 0.0MB/s</p>
<button className={styles.pauseBtn} ref={pauseBtnRef} type="button" aria-pressed="false">일시정지</button>
</div>
);
}06 · 残り容量・速度から再計算する予想時間 — 計測 累積面積 1.794% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 決定論的な合成波で瞬間転送速度を再現する — 毎ティック速度が変わるためETAは毎回再計算される
function instSpeed(ms) {
var w = 1 + SPEED_VARIANCE * Math.sin(ms / 480) + 0.2 * Math.sin(ms / 133);
return Math.max(0.15, BASE_SPEED_MBPS * w);
}
function paint(speed) {
var pct = Math.min(100, (loadedMb / TOTAL_MB) * 100);
fill.style.setProperty('--fill-pct', pct.toFixed(1) + '%');
track.setAttribute('aria-valuenow', String(Math.round(pct)));
detail.textContent = loadedMb.toFixed(1) + 'MB / ' + TOTAL_MB.toFixed(1) + 'MB · ' + speed.toFixed(1) + 'MB/s';
if (paused) {
etaEl.textContent = '一時停止中';
} else if (loadedMb >= TOTAL_MB) {
etaEl.textContent = '受信完了';
} else {
// 残り容量 ÷ 今の速度 = 予想時間 — 保存したカウントダウンではなく毎ティック新しく割った値
var remaining = TOTAL_MB - loadedMb;
var etaSec = remaining / speed;
etaEl.textContent = '約' + Math.ceil(etaSec) + '秒';
}
}
07シークバーのバッファゴーストプレビュー
シークバーをドラッグすると、getBoundingClientRectで実測したトラック幅を基準にしたポインタ比率がゴーストマーカーの位置と時間ラベルを計算し、再生・バッファ時間を「再生 0:00 ・ バッファ 0:00」の形式で表示する。

<main class="stage">
<div class="scrubWrap is-demo" id="scrubWrap">
<div class="track" id="track" role="slider" aria-label="シークバー" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
<div class="bufferFill" id="bufferFill"></div>
<div class="playFill" id="playFill"></div>
<div class="ghost" id="ghost">
<span class="ghostTip" id="ghostTip">0:00</span>
</div>
<div class="handle" id="handle"></div>
</div>
<p class="readout" id="readout" aria-live="polite">再生 0:00 ・ バッファ 0:00</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-lilac;
// 계산 겹: index.html 의 fracFromPointer() 가 getBoundingClientRect 로 트랙 폭을 실측해
// 포인터 X 좌표를 0~1 비율로 바꾸고, 그 비율이 유령 미리보기 위치·시간 라벨을 만든다.
@include stage($stage-ink);
.scrubWrap {
display: flex;
flex-direction: column;
gap: $sp-3;
width: 260px;
}
.track {
position: relative;
width: 100%;
height: 8px;
border-radius: $r-pill;
background: rgba($subject-cream, .14);
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
}
.bufferFill {
--buffer-pct: 0%;
position: absolute;
inset: 0;
width: var(--buffer-pct);
border-radius: $r-pill;
background: rgba($subject-cream, .3);
}
.playFill {
--play-pct: 0%;
position: absolute;
inset: 0;
width: var(--play-pct);
border-radius: $r-pill;
background: $color;
}
.handle {
--play-pct: 0%;
position: absolute;
top: 50%;
left: var(--play-pct);
width: 12px;
height: 12px;
border-radius: 50%;
background: $subject-cream;
transform: translate(-50%, -50%);
}
.ghost {
--ghost-pct: 0%;
position: absolute;
top: -28px;
left: var(--ghost-pct);
transform: translateX(-50%);
pointer-events: none;
}
.ghostTip {
display: inline-block;
padding: 4px $sp-2;
border-radius: $r-xs;
font-size: 10px;
font-weight: 600;
color: $subject-ink;
background: $subject-cream;
white-space: nowrap;
}
.readout {
margin: 0;
font-size: 11px;
color: rgba($subject-cream, .7);
text-align: center;
}
@media (max-width: 340px) {
.scrubWrap { width: 200px; }
.readout { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 자동 이동에서만 쓰던 매끄러운 값 갱신을 멈춘다. 위치 값 자체는 그대로 정확해야 한다
@media (prefers-reduced-motion: reduce) {
.bufferFill, .playFill, .handle { 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);
}
.scrubWrap {
display: flex;
flex-direction: column;
gap: 12px;
width: 260px;
}
.track {
position: relative;
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.14);
cursor: pointer;
}
.track:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.bufferFill {
--buffer-pct: 0%;
position: absolute;
inset: 0;
width: var(--buffer-pct);
border-radius: 999px;
background: rgba(255, 247, 230, 0.3);
}
.playFill {
--play-pct: 0%;
position: absolute;
inset: 0;
width: var(--play-pct);
border-radius: 999px;
background: #b9a0ff;
}
.handle {
--play-pct: 0%;
position: absolute;
top: 50%;
left: var(--play-pct);
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
transform: translate(-50%, -50%);
}
.ghost {
--ghost-pct: 0%;
position: absolute;
top: -28px;
left: var(--ghost-pct);
transform: translateX(-50%);
pointer-events: none;
}
.ghostTip {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: #17141a;
background: #fff7e6;
white-space: nowrap;
}
.readout {
margin: 0;
font-size: 11px;
color: rgba(255, 247, 230, 0.7);
text-align: center;
}
@media (max-width: 340px) {
.scrubWrap {
width: 200px;
}
.readout {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bufferFill, .playFill, .handle {
transition: none;
}
}(function () {
var TOTAL_SEC = 185; // 총 길이(가상)
var LOOP_MS = 4200;
var TICK_MS = 60;
var BUFFER_LEAD = 0.28; // 버퍼가 재생보다 앞서는 고정 여유(비율)
var wrap = document.getElementById('scrubWrap');
var track = document.getElementById('track');
var bufferFill = document.getElementById('bufferFill');
var playFill = document.getElementById('playFill');
var ghost = document.getElementById('ghost');
var ghostTip = document.getElementById('ghostTip');
var handle = document.getElementById('handle');
var readout = document.getElementById('readout');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function fmt(sec) {
var s = Math.max(0, Math.round(sec));
var m = Math.floor(s / 60);
var r = s % 60;
return m + ':' + (r < 10 ? '0' : '') + r;
}
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
var playFrac = 0.18;
var manualPlayFrac = null;
function setGhost(frac) {
var pct = (frac * 100).toFixed(1) + '%';
ghost.style.setProperty('--ghost-pct', pct);
ghostTip.textContent = fmt(frac * TOTAL_SEC);
}
function paint(playF, ghostF) {
var bufF = clamp(playF + BUFFER_LEAD, 0, 1);
bufferFill.style.setProperty('--buffer-pct', (bufF * 100).toFixed(1) + '%');
playFill.style.setProperty('--play-pct', (playF * 100).toFixed(1) + '%');
handle.style.setProperty('--play-pct', (playF * 100).toFixed(1) + '%');
track.setAttribute('aria-valuenow', String(Math.round(playF * 100)));
setGhost(ghostF);
readout.textContent = '再生 ' + fmt(playF * TOTAL_SEC) + ' ・ バッファ ' + fmt(bufF * TOTAL_SEC);
}
function tick(elapsedMs) {
var t = (elapsedMs % LOOP_MS) / LOOP_MS;
playFrac = manualPlayFrac !== null ? manualPlayFrac : 0.05 + 0.55 * t;
// 유령(미리보기) 위치는 실제 포인터 X 좌표를 트랙 폭 기준 비율로 바꾼 값 — 여기선 같은 방식을 합성파로 재현
var pointerFrac = clamp(playFrac + 0.22 + 0.06 * Math.sin(elapsedMs / 260), 0, 1);
paint(playFrac, pointerFrac);
}
var timer = null;
var start = 0;
function loop() { tick(performance.now() - start); }
function human() {
if (wrap.classList.contains('is-demo')) {
wrap.classList.remove('is-demo');
window.clearInterval(timer);
timer = null;
}
}
// 실제 포인터 좌표 → 트랙 안 비율로 바꾸는 계산 (getBoundingClientRect 실측)
function fracFromPointer(clientX) {
var rect = track.getBoundingClientRect();
return clamp((clientX - rect.left) / rect.width, 0, 1);
}
var dragging = false;
track.addEventListener('pointermove', function (ev) {
human();
var f = fracFromPointer(ev.clientX);
setGhost(f);
if (dragging) {
manualPlayFrac = f;
paint(f, f);
}
});
track.addEventListener('pointerdown', function (ev) {
human();
dragging = true;
var f = fracFromPointer(ev.clientX);
manualPlayFrac = f;
paint(f, f);
});
window.addEventListener('pointerup', function () { dragging = false; });
track.addEventListener('keydown', function (ev) {
human();
var step = 0.02;
if (ev.key === 'ArrowRight') { manualPlayFrac = clamp((manualPlayFrac !== null ? manualPlayFrac : playFrac) + step, 0, 1); paint(manualPlayFrac, manualPlayFrac); }
else if (ev.key === 'ArrowLeft') { manualPlayFrac = clamp((manualPlayFrac !== null ? manualPlayFrac : playFrac) - step, 0, 1); paint(manualPlayFrac, manualPlayFrac); }
});
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 자동 이동은 멈추고 再生·バッファ·유령 위치를 한 장면으로 고정한다
paint(0.3, 0.5);
}
})();import { useEffect, useRef } from 'react';
import styles from './BufferingScrubPreviewGhost.module.scss';
const TOTAL_SEC = 185; // 총 길이(가상)
const LOOP_MS = 4200;
const TICK_MS = 60;
const BUFFER_LEAD = 0.28; // 버퍼가 재생보다 앞서는 고정 여유(비율)
function fmt(sec: number) {
const s = Math.max(0, Math.round(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return m + ':' + (r < 10 ? '0' : '') + r;
}
function clamp(v: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, v));
}
export default function BufferingScrubPreviewGhost() {
const wrapRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const bufferFillRef = useRef<HTMLDivElement>(null);
const playFillRef = useRef<HTMLDivElement>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const ghostTipRef = useRef<HTMLSpanElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const readoutRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
const track = trackRef.current;
const bufferFill = bufferFillRef.current;
const playFill = playFillRef.current;
const ghost = ghostRef.current;
const ghostTip = ghostTipRef.current;
const handle = handleRef.current;
const readout = readoutRef.current;
if (!wrap || !track || !bufferFill || !playFill || !ghost || !ghostTip || !handle || !readout) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let playFrac = 0.18;
let manualPlayFrac: number | null = null;
function setGhost(frac: number) {
if (!ghost || !ghostTip) return;
const pct = (frac * 100).toFixed(1) + '%';
ghost.style.setProperty('--ghost-pct', pct);
ghostTip.textContent = fmt(frac * TOTAL_SEC);
}
function paint(playF: number, ghostF: number) {
if (!bufferFill || !playFill || !handle || !track || !readout) return;
const bufF = clamp(playF + BUFFER_LEAD, 0, 1);
bufferFill.style.setProperty('--buffer-pct', (bufF * 100).toFixed(1) + '%');
playFill.style.setProperty('--play-pct', (playF * 100).toFixed(1) + '%');
handle.style.setProperty('--play-pct', (playF * 100).toFixed(1) + '%');
track.setAttribute('aria-valuenow', String(Math.round(playF * 100)));
setGhost(ghostF);
readout.textContent = '재생 ' + fmt(playF * TOTAL_SEC) + ' · 버퍼 ' + fmt(bufF * TOTAL_SEC);
}
function tick(elapsedMs: number) {
const t = (elapsedMs % LOOP_MS) / LOOP_MS;
playFrac = manualPlayFrac !== null ? manualPlayFrac : 0.05 + 0.55 * t;
// 유령(미리보기) 위치는 실제 포인터 X 좌표를 트랙 폭 기준 비율로 바꾼 값 — 여기선 같은 방식을 합성파로 재현
const pointerFrac = clamp(playFrac + 0.22 + 0.06 * Math.sin(elapsedMs / 260), 0, 1);
paint(playFrac, pointerFrac);
}
let timer: ReturnType<typeof window.setInterval> | null = null;
let start = 0;
function loop() {
tick(performance.now() - start);
}
function human() {
if (wrap && wrap.classList.contains(styles['is-demo'])) {
wrap.classList.remove(styles['is-demo']);
if (timer !== null) window.clearInterval(timer);
timer = null;
}
}
// 실제 포인터 좌표 → 트랙 안 비율로 바꾸는 계산 (getBoundingClientRect 실측)
function fracFromPointer(clientX: number) {
if (!track) return 0;
const rect = track.getBoundingClientRect();
return clamp((clientX - rect.left) / rect.width, 0, 1);
}
let dragging = false;
function onPointerMove(ev: PointerEvent) {
human();
const f = fracFromPointer(ev.clientX);
setGhost(f);
if (dragging) {
manualPlayFrac = f;
paint(f, f);
}
}
function onPointerDown(ev: PointerEvent) {
human();
dragging = true;
const f = fracFromPointer(ev.clientX);
manualPlayFrac = f;
paint(f, f);
}
function onPointerUp() {
dragging = false;
}
function onKeyDown(ev: KeyboardEvent) {
human();
const step = 0.02;
if (ev.key === 'ArrowRight') {
manualPlayFrac = clamp((manualPlayFrac !== null ? manualPlayFrac : playFrac) + step, 0, 1);
paint(manualPlayFrac, manualPlayFrac);
} else if (ev.key === 'ArrowLeft') {
manualPlayFrac = clamp((manualPlayFrac !== null ? manualPlayFrac : playFrac) - step, 0, 1);
paint(manualPlayFrac, manualPlayFrac);
}
}
track.addEventListener('pointermove', onPointerMove);
track.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointerup', onPointerUp);
track.addEventListener('keydown', onKeyDown);
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 자동 이동은 멈추고 재생·버퍼·유령 위치를 한 장면으로 고정한다
paint(0.3, 0.5);
}
return () => {
if (timer !== null) window.clearInterval(timer);
track.removeEventListener('pointermove', onPointerMove);
track.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerup', onPointerUp);
track.removeEventListener('keydown', onKeyDown);
};
}, []);
return (
<div className={`${styles.scrubWrap} ${styles['is-demo']}`} ref={wrapRef}>
<div
className={styles.track}
ref={trackRef}
role="slider"
aria-label="탐색 막대"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={0}
tabIndex={0}
>
<div className={styles.bufferFill} ref={bufferFillRef} />
<div className={styles.playFill} ref={playFillRef} />
<div className={styles.ghost} ref={ghostRef}>
<span className={styles.ghostTip} ref={ghostTipRef}>0:00</span>
</div>
<div className={styles.handle} ref={handleRef} />
</div>
<p className={styles.readout} ref={readoutRef} aria-live="polite">재생 0:00 · 버퍼 0:00</p>
</div>
);
}07 · シークバーのバッファゴーストプレビュー — 計測 累積面積 3.027% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 実際のポインタ座標をトラック内の比率に変える計算(getBoundingClientRectで実測)
function fracFromPointer(clientX) {
var rect = track.getBoundingClientRect();
return clamp((clientX - rect.left) / rect.width, 0, 1);
}
var dragging = false;
track.addEventListener('pointermove', function (ev) {
human();
var f = fracFromPointer(ev.clientX);
setGhost(f);
if (dragging) {
manualPlayFrac = f;
paint(f, f);
}
});
08帯域幅判定で変わる画質バッジ
合成波で模した帯域幅を毎ティック高・中・低に判定するが、新しい判定がQUALITY_BADGE_HOLD_MS(1200ms)続いたときだけバッジの文字を変えて瞬間的な揺れを防ぎ、バッジの背景色はその間もliveColor()で実時間の信号を混ぜ続ける。

<main class="stage">
<div class="qualityCard is-demo" id="qualityCard">
<div class="badge" id="badge">
<span class="warnIcon" id="warnIcon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2"/>
<path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/>
</g>
</svg>
</span>
<span class="badgeText" id="badgeText">高画質</span>
</div>
<p class="readout" id="readout" aria-live="polite">帯域幅 約6.0Mbps</p>
<button class="forceBtn" id="forceBtn" type="button" aria-pressed="false">今すぐ高画質に切り替え</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-sky;
// 계산 겹: index.html 의 judge() 가 합성파 대역폭을 등급으로 판정하고, tick() 이
// QUALITY_BADGE_HOLD_MS 동안 같은 판정이 유지될 때만 배지를 바꾼다 — 순간 튐 방지 판정.
@include stage($stage-orange);
.qualityCard {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.badge {
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-2 $sp-4;
border-radius: $r-pill;
background: $color;
// 바탕색은 JS liveColor() 가 매 틱 인라인으로 계속 덮어쓴다(실시간 대역폭) — 여기 transition 은 그 갱신을 부드럽게만 잇는다
transition: background-color 90ms linear;
}
.warnIcon {
display: inline-flex;
color: $subject-cream;
&[hidden] { display: none; }
}
.badgeText {
font-size: 13px;
font-weight: 700;
color: $subject-ink;
}
.badge[data-level="low"] .badgeText { color: $subject-cream; }
.readout {
margin: 0;
font-size: 11px;
color: rgba($subject-ink, .65);
}
.forceBtn {
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 $subject-blue-deep; outline-offset: 2px; }
&[aria-pressed="true"] { background: $subject-orange-deep; }
}
@media (max-width: 340px) {
.badge { padding: $sp-1 $sp-3; }
.badgeText, .readout, .forceBtn { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 배지 배경색 전환만 끈다. 등급 판정 자체는 계속 갱신돼야 의미가 있다
@media (prefers-reduced-motion: reduce) {
.badge { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.qualityCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.badge {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border-radius: 999px;
background: #6ec8ff;
transition: background-color 90ms linear;
}
.warnIcon {
display: inline-flex;
color: #fff7e6;
}
.warnIcon[hidden] {
display: none;
}
.badgeText {
font-size: 13px;
font-weight: 700;
color: #17141a;
}
.badge[data-level=low] .badgeText {
color: #fff7e6;
}
.readout {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.65);
}
.forceBtn {
appearance: none;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #fff7e6;
background: #17141a;
cursor: pointer;
}
.forceBtn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.forceBtn[aria-pressed=true] {
background: #c93000;
}
@media (max-width: 340px) {
.badge {
padding: 4px 12px;
}
.badgeText, .readout, .forceBtn {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.badge {
transition: none;
}
}(function () {
var QUALITY_BADGE_HOLD_MS = 1200; // 훅 — 변형이 1200→3000 으로 바꾸는 값
var LOOP_MS = 6000;
var TICK_MS = 100;
var HIGH_MBPS = 6;
var LOW_MBPS = 2.5;
var card = document.getElementById('qualityCard');
var badge = document.getElementById('badge');
var badgeText = document.getElementById('badgeText');
var warnIcon = document.getElementById('warnIcon');
var readout = document.getElementById('readout');
var forceBtn = document.getElementById('forceBtn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LEVELS = { high: '高画質', mid: '中画質', low: '低画質' };
// 판정 배지의 바탕색을 만드는 3점 그라디언트 정지색(고→중→저)
var STOP_HIGH = [110, 200, 255];
var STOP_MID = [255, 158, 187];
var STOP_LOW = [194, 54, 26];
function bandwidth(ms) {
var w = 4.2 + 3.6 * Math.sin(ms / 380) + 1.3 * Math.sin(ms / 133);
return Math.max(0.4, w);
}
function judge(mbps) {
if (mbps >= HIGH_MBPS) return 'high';
if (mbps <= LOW_MBPS) return 'low';
return 'mid';
}
function mix(a, b, t) { return Math.round(a + (b - a) * t); }
// 원시 대역폭 값을 0~1 로 정규화해 바탕색을 매 틱 연속으로 섞는다(실시간 신호) —
// 화면 문구(고/중/저)는 아래 tick() 의 판정+홀드 결과를 따로 쓴다(순간 튐 없는 결정)
function liveColor(mbps) {
var t = Math.max(0, Math.min(1, (mbps - LOW_MBPS) / (HIGH_MBPS - LOW_MBPS)));
var stops = t > 0.5 ? [STOP_MID, STOP_HIGH, (t - 0.5) * 2] : [STOP_LOW, STOP_MID, t * 2];
var from = stops[0], to = stops[1], localT = stops[2];
return 'rgb(' + mix(from[0], to[0], localT) + ',' + mix(from[1], to[1], localT) + ',' + mix(from[2], to[2], localT) + ')';
}
var displayLevel = 'high';
var pendingLevel = 'high';
var pendingSinceMs = 0;
var forced = null;
function paint(level, mbps) {
badgeText.textContent = LEVELS[level];
badge.setAttribute('data-level', level);
badge.style.backgroundColor = liveColor(mbps);
warnIcon.hidden = level !== 'low';
readout.textContent = '帯域幅 約 ' + mbps.toFixed(1) + 'Mbps';
}
// 대역폭이 임계값을 넘었다고 즉시 바꾸지 않고, QUALITY_BADGE_HOLD_MS 만큼 그 판정이
// 계속 유지될 때만 라벨·아이콘을 바꾼다 — 순간 튐으로 배지 글자가 깜빡이는 것을 막는 판정 계산.
// (바탕색은 위 liveColor() 로 매 틱 계속 움직여 실시간 신호 자체는 감추지 않는다)
function tick(ms) {
var mbps = bandwidth(ms);
if (forced !== null) { paint(forced, mbps); return; }
var target = judge(mbps);
if (target !== displayLevel) {
if (target === pendingLevel) {
if (ms - pendingSinceMs >= QUALITY_BADGE_HOLD_MS) {
displayLevel = target;
}
} else {
pendingLevel = target;
pendingSinceMs = ms;
}
} else {
pendingLevel = displayLevel;
}
paint(displayLevel, mbps);
}
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;
}
}
forceBtn.addEventListener('click', function () {
human();
if (forced === null) {
forced = 'high';
forceBtn.setAttribute('aria-pressed', 'true');
forceBtn.textContent = '自動判定に戻す';
} else {
forced = null;
forceBtn.setAttribute('aria-pressed', 'false');
forceBtn.textContent = '今すぐ高画質に切り替え';
}
});
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 판정 갱신을 멈추고 한 등급을 고정해 보여준다
paint('mid', 4.0);
}
})();import { useEffect, useRef } from 'react';
import styles from './BufferingQualityDropBadge.module.scss';
const QUALITY_BADGE_HOLD_MS = 1200; // 훅 — 변형이 1200→3000 으로 바꾸는 값
const LOOP_MS = 6000;
const TICK_MS = 100;
const HIGH_MBPS = 6;
const LOW_MBPS = 2.5;
const WARN_ICON_PATH_BG =
'M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91';
const WARN_ICON_PATH_FG =
'M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12';
type Level = 'high' | 'mid' | 'low';
const LEVELS: Record<Level, string> = { high: '고화질', mid: '중간 화질', low: '저화질' };
// 판정 배지의 바탕색을 만드는 3점 그라디언트 정지색(고→중→저)
const STOP_HIGH: [number, number, number] = [110, 200, 255];
const STOP_MID: [number, number, number] = [255, 158, 187];
const STOP_LOW: [number, number, number] = [194, 54, 26];
function bandwidth(ms: number) {
const w = 4.2 + 3.6 * Math.sin(ms / 380) + 1.3 * Math.sin(ms / 133);
return Math.max(0.4, w);
}
function judge(mbps: number): Level {
if (mbps >= HIGH_MBPS) return 'high';
if (mbps <= LOW_MBPS) return 'low';
return 'mid';
}
function mix(a: number, b: number, t: number) {
return Math.round(a + (b - a) * t);
}
// 원시 대역폭 값을 0~1 로 정규화해 바탕색을 매 틱 연속으로 섞는다(실시간 신호) —
// 화면 문구(고/중/저)는 tick() 의 판정+홀드 결과를 따로 쓴다(순간 튐 없는 결정)
function liveColor(mbps: number) {
const t = Math.max(0, Math.min(1, (mbps - LOW_MBPS) / (HIGH_MBPS - LOW_MBPS)));
const from = t > 0.5 ? STOP_MID : STOP_LOW;
const to = t > 0.5 ? STOP_HIGH : STOP_MID;
const localT = t > 0.5 ? (t - 0.5) * 2 : t * 2;
return `rgb(${mix(from[0], to[0], localT)},${mix(from[1], to[1], localT)},${mix(from[2], to[2], localT)})`;
}
export default function BufferingQualityDropBadge() {
const cardRef = useRef<HTMLDivElement>(null);
const badgeRef = useRef<HTMLDivElement>(null);
const warnIconRef = useRef<HTMLSpanElement>(null);
const badgeTextRef = useRef<HTMLSpanElement>(null);
const readoutRef = useRef<HTMLParagraphElement>(null);
const forceBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const card = cardRef.current;
const badge = badgeRef.current;
const warnIcon = warnIconRef.current;
const badgeText = badgeTextRef.current;
const readout = readoutRef.current;
const forceBtn = forceBtnRef.current;
if (!card || !badge || !warnIcon || !badgeText || !readout || !forceBtn) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let displayLevel: Level = 'high';
let pendingLevel: Level = 'high';
let pendingSinceMs = 0;
let forced: Level | null = null;
function paint(level: Level, mbps: number) {
if (!badgeText || !badge || !warnIcon || !readout) return;
badgeText.textContent = LEVELS[level];
badge.setAttribute('data-level', level);
badge.style.backgroundColor = liveColor(mbps);
warnIcon.hidden = level !== 'low';
readout.textContent = '대역폭 약 ' + mbps.toFixed(1) + 'Mbps';
}
// 대역폭이 임계값을 넘었다고 즉시 바꾸지 않고, QUALITY_BADGE_HOLD_MS 만큼 그 판정이
// 계속 유지될 때만 라벨·아이콘을 바꾼다 — 순간 튐으로 배지 글자가 깜빡이는 것을 막는 판정 계산.
// (바탕색은 위 liveColor() 로 매 틱 계속 움직여 실시간 신호 자체는 감추지 않는다)
function tick(ms: number) {
const mbps = bandwidth(ms);
if (forced !== null) {
paint(forced, mbps);
return;
}
const target = judge(mbps);
if (target !== displayLevel) {
if (target === pendingLevel) {
if (ms - pendingSinceMs >= QUALITY_BADGE_HOLD_MS) {
displayLevel = target;
}
} else {
pendingLevel = target;
pendingSinceMs = ms;
}
} else {
pendingLevel = displayLevel;
}
paint(displayLevel, mbps);
}
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 onForce() {
human();
if (!forceBtn) return;
if (forced === null) {
forced = 'high';
forceBtn.setAttribute('aria-pressed', 'true');
forceBtn.textContent = '자동 판정으로 되돌리기';
} else {
forced = null;
forceBtn.setAttribute('aria-pressed', 'false');
forceBtn.textContent = '지금 고화질로 전환';
}
}
forceBtn.addEventListener('click', onForce);
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 판정 갱신을 멈추고 한 등급을 고정해 보여준다
paint('mid', 4.0);
}
return () => {
if (timer !== null) window.clearInterval(timer);
forceBtn.removeEventListener('click', onForce);
};
}, []);
return (
<div className={`${styles.qualityCard} ${styles['is-demo']}`} ref={cardRef}>
<div className={styles.badge} ref={badgeRef} data-level="high">
<span className={styles.warnIcon} ref={warnIconRef} hidden aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256">
<g fill="currentColor">
<path d={WARN_ICON_PATH_BG} opacity=".2" />
<path d={WARN_ICON_PATH_FG} />
</g>
</svg>
</span>
<span className={styles.badgeText} ref={badgeTextRef}>고화질</span>
</div>
<p className={styles.readout} ref={readoutRef} aria-live="polite">대역폭 약 6.0Mbps</p>
<button className={styles.forceBtn} ref={forceBtnRef} type="button" aria-pressed="false">지금 고화질로 전환</button>
</div>
);
}08 · 帯域幅判定で変わる画質バッジ — 計測 累積面積 1.535% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 帯域幅が閾値を超えた瞬間には切り替えず、その判定がQUALITY_BADGE_HOLD_MSの間ずっと続いたときだけ
// ラベルとアイコンを変える — 瞬間的な急上昇でバッジの文字がちらつくのを防ぐ判定計算。
// (背景色は上のliveColor()で毎ティック混ぜ続けるので、ライブ信号そのものが隠れるわけではない。)
function tick(ms) {
var mbps = bandwidth(ms);
if (forced !== null) { paint(forced, mbps); return; }
var target = judge(mbps);
if (target !== displayLevel) {
if (target === pendingLevel) {
if (ms - pendingSinceMs >= QUALITY_BADGE_HOLD_MS) {
displayLevel = target;
}
} else {
pendingLevel = target;
pendingSinceMs = ms;
}
} else {
pendingLevel = displayLevel;
}
paint(displayLevel, mbps);
}
09動作の低減オン・オフの並列比較
左パネルはtickMotion()で角度を連続計算してリングを回し続け、右パネルは同じ経過時間をREDUCED_STEP_MS(900ms)単位で区切ったtickReduced()で段階的にしか変えない — 実際のOSの動作の低減設定の変化もmatchMediaでリアルタイムに反映し、二つのパネルを並べて比較する。

<main class="stage">
<div class="compareWrap is-demo" id="compareWrap">
<p class="sysStatus" id="sysStatus" aria-live="polite">システム設定を確認中</p>
<div class="panels">
<div class="panel">
<span class="ring ringMotion" id="ringMotion" aria-hidden="true"></span>
<p class="panelLabel">動作維持パネル</p>
</div>
<div class="panel">
<span class="ring ringReduced" id="ringReduced" aria-hidden="true"></span>
<p class="panelLabel">動作低減パネル</p>
</div>
</div>
<button class="previewBtn" id="previewBtn" type="button" aria-pressed="false">両パネルを並べてプレビュー</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue;
// 계산 겹: index.html 의 tickMotion()·tickReduced() 가 같은 경과 시간(ms)을 두 갈래로 계산한다 —
// 하나는 연속 각도(회전), 하나는 REDUCED_STEP_MS 로 끊은 계단식 값. 그 차이 자체가 이 편의 주제다.
@include stage($stage-ink);
.compareWrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.sysStatus {
margin: 0;
font-size: 11px;
color: rgba($subject-cream, .7);
}
.panels {
display: flex;
gap: $sp-5;
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.ring {
display: block;
width: 44px;
height: 44px;
border-radius: 50%;
border: 4px solid rgba($subject-cream, .18);
}
.ringMotion {
border-top-color: $color;
}
.ringReduced {
--dim: 1;
border-top-color: $subject-pink;
opacity: var(--dim);
}
.panelLabel {
margin: 0;
font-size: 10px;
color: rgba($subject-cream, .6);
}
.single-panel .panels .panel:first-child {
display: none;
}
.previewBtn {
appearance: none;
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
font-size: 11px;
font-weight: 600;
color: $subject-ink;
background: $subject-cream;
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.panels { gap: $sp-3; }
.ring { width: 34px; height: 34px; border-width: 3px; }
.sysStatus, .panelLabel, .previewBtn { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 동작 유지판(왼쪽)의 실제 회전을 강제로 멈춘다(!important 로 인라인 transform 을 덮는다).
// 동작 줄임판(오른쪽)은 원래도 계단식이라 손댈 전환이 없다. 시연 비교 토글 자체는 시연 게이트만이라 이 블록과 무관하다.
@media (prefers-reduced-motion: reduce) {
.ringMotion { transform: none !important; }
}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);
}
.compareWrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.sysStatus {
margin: 0;
font-size: 11px;
color: rgba(255, 247, 230, 0.7);
}
.panels {
display: flex;
gap: 20px;
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.ring {
display: block;
width: 44px;
height: 44px;
border-radius: 50%;
border: 4px solid rgba(255, 247, 230, 0.18);
}
.ringMotion {
border-top-color: #2f6df6;
}
.ringReduced {
--dim: 1;
border-top-color: #ff9ebb;
opacity: var(--dim);
}
.panelLabel {
margin: 0;
font-size: 10px;
color: rgba(255, 247, 230, 0.6);
}
.single-panel .panels .panel:first-child {
display: none;
}
.previewBtn {
appearance: none;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #17141a;
background: #fff7e6;
cursor: pointer;
}
.previewBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.panels {
gap: 12px;
}
.ring {
width: 34px;
height: 34px;
border-width: 3px;
}
.sysStatus, .panelLabel, .previewBtn {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ringMotion {
transform: none !important;
}
}(function () {
var SPIN_MS = 1600;
var TICK_MS = 40;
var REDUCED_STEP_MS = 900; // 동작 줄임판은 이 간격마다 한 번씩만 상태를 바꾼다(연속 회전 없음)
var wrap = document.getElementById('compareWrap');
var sysStatus = document.getElementById('sysStatus');
var ringMotion = document.getElementById('ringMotion');
var ringReduced = document.getElementById('ringReduced');
var previewBtn = document.getElementById('previewBtn');
var mq = window.matchMedia('(prefers-reduced-motion: reduce)');
// 실사용 경로 적용 — 실제 OS 환경설정이 바뀌면 이 리스너가 즉시 문구를 다시 쓴다(새로고침 불필요)
function updateSystemStatus() {
sysStatus.textContent = 'システム設定: 動作の低減 ' + (mq.matches ? 'オン' : 'オフ');
}
mq.addEventListener('change', updateSystemStatus);
updateSystemStatus();
// 왼쪽(동작 유지)은 각도를 연속으로 계산해 회전시키고, 오른쪽(동작 줄임)은 같은 시간 입력을
// REDUCED_STEP_MS 단위로 끊어(정수 나눗셈) 계단식으로만 바꾼다 — 두 분기를 같은 시계로 비교하는 계산.
function tickMotion(ms) {
var deg = (ms % SPIN_MS) / SPIN_MS * 360;
ringMotion.style.transform = 'rotate(' + deg.toFixed(1) + 'deg)';
}
function tickReduced(ms) {
var step = Math.floor(ms / REDUCED_STEP_MS) % 2;
ringReduced.style.setProperty('--dim', step === 0 ? '1' : '.45');
}
var timer = null;
var start = 0;
function loop() {
var ms = performance.now() - start;
tickMotion(ms);
tickReduced(ms);
}
function human() {
if (wrap.classList.contains('is-demo')) {
wrap.classList.remove('is-demo');
window.clearInterval(timer);
timer = null;
}
}
// 시연 게이트만 — 실제 시스템 설정과 무관하게, 이 데모 안에서만 두 판을 동시에 보여주는 비교 토글이다
var comparing = true;
previewBtn.addEventListener('click', function () {
human();
comparing = !comparing;
previewBtn.setAttribute('aria-pressed', comparing ? 'true' : 'false');
previewBtn.textContent = comparing ? '動作維持パネルを隠す' : '両パネルを並べてプレビュー';
wrap.classList.toggle('single-panel', !comparing);
});
start = performance.now();
tickMotion(0);
tickReduced(0);
timer = window.setInterval(loop, TICK_MS);
})();import { useEffect, useRef } from 'react';
import styles from './BufferingReducedMotion.module.scss';
const SPIN_MS = 1600;
const TICK_MS = 40;
const REDUCED_STEP_MS = 900; // 동작 줄임판은 이 간격마다 한 번씩만 상태를 바꾼다(연속 회전 없음)
// 왼쪽(동작 유지)은 각도를 연속으로 계산해 회전시키고, 오른쪽(동작 줄임)은 같은 시간 입력을
// REDUCED_STEP_MS 단위로 끊어(정수 나눗셈) 계단식으로만 바꾼다 — 두 분기를 같은 시계로 비교하는 계산.
function tickMotion(ms: number) {
return (ms % SPIN_MS) / SPIN_MS * 360;
}
function tickReduced(ms: number) {
return Math.floor(ms / REDUCED_STEP_MS) % 2;
}
export default function BufferingReducedMotion() {
const wrapRef = useRef<HTMLDivElement>(null);
const sysStatusRef = useRef<HTMLParagraphElement>(null);
const ringMotionRef = useRef<HTMLSpanElement>(null);
const ringReducedRef = useRef<HTMLSpanElement>(null);
const previewBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
const sysStatus = sysStatusRef.current;
const ringMotion = ringMotionRef.current;
const ringReduced = ringReducedRef.current;
const previewBtn = previewBtnRef.current;
if (!wrap || !sysStatus || !ringMotion || !ringReduced || !previewBtn) return undefined;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
// 실사용 경로 적용 — 실제 OS 환경설정이 바뀌면 이 리스너가 즉시 문구를 다시 쓴다(새로고침 불필요)
function updateSystemStatus() {
if (!sysStatus) return;
sysStatus.textContent = '시스템 설정: 동작 줄이기 ' + (mq.matches ? '켜짐' : '꺼짐');
}
mq.addEventListener('change', updateSystemStatus);
updateSystemStatus();
function paintMotion(ms: number) {
if (!ringMotion) return;
const deg = tickMotion(ms);
ringMotion.style.transform = 'rotate(' + deg.toFixed(1) + 'deg)';
}
function paintReduced(ms: number) {
if (!ringReduced) return;
const step = tickReduced(ms);
ringReduced.style.setProperty('--dim', step === 0 ? '1' : '.45');
}
let timer: ReturnType<typeof window.setInterval> | null = null;
let start = 0;
function loop() {
const ms = performance.now() - start;
paintMotion(ms);
paintReduced(ms);
}
function human() {
if (wrap && wrap.classList.contains(styles['is-demo'])) {
wrap.classList.remove(styles['is-demo']);
if (timer !== null) window.clearInterval(timer);
timer = null;
}
}
// 시연 게이트만 — 실제 시스템 설정과 무관하게, 이 데모 안에서만 두 판을 동시에 보여주는 비교 토글이다
let comparing = true;
function onPreview() {
human();
if (!wrap || !previewBtn) return;
comparing = !comparing;
previewBtn.setAttribute('aria-pressed', comparing ? 'true' : 'false');
previewBtn.textContent = comparing ? '동작 유지판 숨기기' : '두 판 나란히 미리보기';
wrap.classList.toggle(styles['single-panel'], !comparing);
}
previewBtn.addEventListener('click', onPreview);
start = performance.now();
paintMotion(0);
paintReduced(0);
timer = window.setInterval(loop, TICK_MS);
return () => {
if (timer !== null) window.clearInterval(timer);
previewBtn.removeEventListener('click', onPreview);
mq.removeEventListener('change', updateSystemStatus);
};
}, []);
return (
<div className={`${styles.compareWrap} ${styles['is-demo']}`} ref={wrapRef}>
<p className={styles.sysStatus} ref={sysStatusRef} aria-live="polite">시스템 설정 확인 중</p>
<div className={styles.panels}>
<div className={styles.panel}>
<span className={`${styles.ring} ${styles.ringMotion}`} ref={ringMotionRef} aria-hidden="true" />
<p className={styles.panelLabel}>동작 유지판</p>
</div>
<div className={styles.panel}>
<span className={`${styles.ring} ${styles.ringReduced}`} ref={ringReducedRef} aria-hidden="true" />
<p className={styles.panelLabel}>동작 줄임판</p>
</div>
</div>
<button className={styles.previewBtn} ref={previewBtnRef} type="button" aria-pressed="false">두 판 나란히 미리보기</button>
</div>
);
}09 · 動作の低減オン・オフの並列比較 — 計測 累積面積 0.961% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 実際に使われる経路 — OSの本当の設定が変わった瞬間、このリスナーがすぐ文言を書き換える(リロード不要)
function updateSystemStatus() {
sysStatus.textContent = 'システム設定: 動作の低減 ' + (mq.matches ? 'オン' : 'オフ');
}
mq.addEventListener('change', updateSystemStatus);
updateSystemStatus();
// 左パネル(動作維持)は角度を連続計算して回転させ続け、右パネル(動作低減)は同じ経過時間の入力を
// REDUCED_STEP_MS単位に区切って段階的にしか変えない — 両方を同じ時計と比べる計算。
function tickMotion(ms) {
var deg = (ms % SPIN_MS) / SPIN_MS * 360;
ringMotion.style.transform = 'rotate(' + deg.toFixed(1) + 'deg)';
}
function tickReduced(ms) {
var step = Math.floor(ms / REDUCED_STEP_MS) % 2;
ringReduced.style.setProperty('--dim', step === 0 ? '1' : '.45');
}
値がUIを変える場所 — 速度・閾値・保持時間・順序
html css ローディング アニメーションを探すと似た見た目のものはいくつも見つかりますが、下の変数を一つ変えるだけでパターンの印象がまるごと変わることを実際に確認できます。
01 — 揺れの位相
オーバーレイのぼかしと不透明度はどちらもtick()内のjitterを読んでいます — 二つの合成サイン波を足してから0〜1にクランプした値で、ある瞬間のオーバーレイの見た目はCSSの@keyframesの一コマではなく、式を計算した結果そのものです。
02 — 二つの割合
apply()はbufferFracとplayFracという二つの数値だけを受け取り、--buffer-pctと円周のstroke-dashoffsetに変換します。0〜1の数値を二つ渡せば、それがこのデモのタイマーから来たものでも実際のプレーヤーのバッファ範囲から来たものでも、両方のリングは即座に描き直されます。
03 — 保持カウント
tick()はtargetLevelが同じ値のままHOLD_TICKS(3回)連続でティックし続けないとdisplayLevelを変えません。これが、合成信号が境界をふらつくたびにバーの本数がちらつくのを防いでいる仕組みです。
04 — 塗りつぶしのイージング
WAVEFORM_FILL_EASEの値によってeaseFn()が返す関数が変わります — 文字列'linear'そのままか、cubic-bezier()を4つの数値に分解したものかのどちらかで、その一つの関数がpaint()内の各バーの--bump値の形を決めています。
05 — 停止の閾値
停止がSTALL_THRESHOLD_MS(300ms)を超えるまでは何も揺れません。超えた瞬間からshakeOffset()が超過分の経過時間を、SHAKE_AMP_PXで振幅を決めた減衰サイン波に変換し、停止が長引くほど振れ幅はゼロに近づいていきます。
06 — 毎回計算し直す残り
paint()はTOTAL_MB - loadedMbを、その瞬間instSpeed()が返す値で割っています。カウントダウンの文言は保存された数値がひとりでに減っていくのではなく、毎ティックその場で割り算し直した結果です。
07 — 実測される割合
fracFromPointer()はpointermoveのたびにgetBoundingClientRect()でトラックの実際の幅を取得し、カーソルのオフセットをその幅で割ります。ゴーストマーカーの位置は実際の要素を測ったライブな値であり、他の8つが頼っているタイマー駆動の値とは性質が違います。
08 — 保持時間
tick()はその判定がQUALITY_BADGE_HOLD_MS(1200ms)ぴったり続くまでdisplayLevelを新しい判定に動かしません。その間もバッジの背景色は同じティックの中でliveColor()によって連続的に混ざり続けます — ラベルは待たされますが、色は待たされません。
09 — 一つの時計、二つの式
tickMotion()とtickReduced()はどちらも毎フレーム同じmsの値を受け取ります。一方はそれを連続した角度に変えてrotate()に渡し、もう一方はREDUCED_STEP_MS(900ms)で割った商をそのまま0か1の段階に変えます。二つのパネルの違いは、どちらの時計を読むかではなく、どちらの式でその時計を読むかだけです。
どこで壊れるか — アクセシビリティ・reduced-motion・低速回線
パターン07のトラックはrole="slider"とtabindex="0"を持っているので、キーボードユーザーはすでに矢印キーで再生位置を動かせます — paint()はkeydownでもpointermoveと全く同じように実行されます。ただしpaint()は常に、今確定したばかりのplayFracと同じ割合でsetGhost()を呼ぶため、キーボードユーザーが見るゴーストマーカーは、すでに移動し終えた位置とぴったり重なって表示されます。ゴーストプレビューの存在意義そのもの — 確定する前にどこに着地するか見せること — は、クリックせずにホバーできるポインタにしか成り立ちません。キー入力は確定とプレビューが同時に起きるため、マウスを使わない瞬間、このパターンの目玉機能には見せるものが何も残らないのです。
動作の低減が残すもの
どのパターンも手動操作(ボタン・ドラッグ・矢印キー)はreduced motionの下でも変わらず動きます。止まるのは、あなたが触れなくてもデモを動かし続ける自動タイマーだけです。
- 01 オーバーレイ — 揺れとスピン速度の変化が止まり、オーバーレイは不透明度0.6・ぼかし6pxに固定されて「バッファリング中」の文言が出続けます。
- 02 二重リング — バッファ・再生のトゥイーンが止まり、apply()は62%/40%の固定値で一度だけ描かれます。
- 03 信号バー — 合成信号の再サンプリングが止まり、レベル3(「信号良好」)に固定されます。
- 04 波形の塗りつぶし — 塗りつぶしとバーごとの盛り上がりが止まり、paint(0.55)で55%バッファ済みの一場面が静止して描かれます。
- 05 停止時の揺れ — アイコンの揺れだけが止まり、カードは停止状態と「停止中」の文言をそのまま表示し続けます。
- 06 カウントダウンETA — 塗りバーのアニメーションが止まり、カウントダウンの文言は40%受信時点で計算した一つの値に固定されます。
- 07 シークのゴースト — デモ自身の自動再生が止まり、両方のマーカーが一組の固定位置で静止します(ドラッグと矢印キーはタイマーに依存していないため、そのまま動き続けます)。
- 08 画質バッジ — 判定ループが止まり、バッジは一つの帯域幅の読み取り値で「中画質」に固定されます。
- 09 動作低減の比較 — このパターンだけは何も無効化されません。動作低減の状態そのものを見せることが目的だからです。updateSystemStatus()はどちらの場合もOSの実際の設定をそのままライブに反映し続けます。
これら9つのファイルはすべて同じ一つのパスワード、nj7m3t5r、を共有しています。一度zipを解凍すれば、上で見た9パターン分のHTML・CSS・JSがそのままディスク上に揃い、ブラウザで直接開いて確認できます。
3つのバリエーションとアクセシビリティ
| バリエーション | 値 | 感触 |
|---|---|---|
| バッファリングの線幅を太くする | 02 BUFFER_RING_STROKE_WIDTH 6px → 14px | 二つのリングの間隔がずっと太い帯として見え、バッファが再生位置よりどれだけ先行しているかが数字なしでも一目でわかる — 細い時のわずかな差が、はっきりした余裕区間に変わる。 |
| 波形の塗りつぶしイージングを滑らかにする | 04 WAVEFORM_FILL_EASE linear → cubic-bezier(.22,1,.36,1) | 膜が剥がれる速度が終わりに向けて緩やかになり、バッファが追いついた瞬間が急に止まるのではなく柔らかく落ち着くように見える — 一定速度の時より到達が自然に感じられる。 |
| 画質低下バッジの保持時間を延ばす | 08 QUALITY_BADGE_HOLD_MS 1200 → 3000 | バッジが画面に留まる時間が二倍以上に伸び、バッファがすぐ回復しても画質が下がったことを読み取る余裕が生まれる — 短い時はちらっと消えるだけだった。 |
パターン01・03・06・08・09はそれぞれaria-pressedを持つ本物の<button type="button">なので、EnterキーとSpaceキーは追加のkeydownコードなしですでに切り替わります。パターン07のトラックだけはrole="slider"とtabindex="0"、そして専用の矢印キー処理を持っています — シークの位置は普通のボタンでは表現できない範囲の意味を必要とするからです。パターン02・04・06はどれもrole="progressbar"とライブに更新されるaria-valuenowで見た目を包み、パターン03のメーターはティックごとに更新されるラベル付きのrole="img"を持っています — この3つは読み取り専用の表示で、それ自体のキーボード操作は必要としません。
FAQ
これら9つのバッファリングアニメーションcssパターンは、実際のネットワーク接続や動画要素がないと動きませんか?
いいえ。9つとも完全にシミュレーションです — fetchを呼ばず、メディア要素のbufferedレンジも読まず、new Date()にも触れていません。上のバッファ・帯域幅・ネットワーク信号はすべてperformance.now()から計算した合成サイン波なので、完全にオフラインでも毎回同じ数値でテストできます。
prefers-reduced-motionがこのデザインで本当に尊重されているかはどう確認できますか?
OSのアクセシビリティ設定で「動作の低減」をオンにしてから上のデモを開き直してください。パターン01〜08は読み込み時に一度だけwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesを確認して固定状態に切り替わりますが、パターン09だけはリロードなしに設定の変化を聞き続け、その仕組みはupdateSystemStatus()関数の中でそのまま確認できます。
この9種のプログレスバー デザイン cssはCodePenのようなものが必要ですか、それともダウンロードが必要ですか?
この9種のプログレスバー デザイン cssを見るのに外部の埋め込みは一切読み込まれません。zipをダウンロードすれば、9つのindex.htmlがそのまま解凍された状態で手元のディスクに残り、自由に編集できます。
出典: MDN — conic-gradient(), MDN — mask-image, MDN — Window.setInterval(), MDN — prefers-reduced-motion, MDN — aria-live。
関連記事: アップロード プログレスバー ローダー9種 — 計算するUI · ダウンロード 進捗バー 9選 — 値がパーセントで更新される9つの方式 · オーディオビジュアライザー 作り方 9選 — 棒からマイクまで









