06재생 값이 판정하는 영상 포스터 지연 로딩
재생이 시작됐는지 값을 계산해 재생 전에는 포스터 이미지만 보여주고, 값이 바뀌는 순간 실제 영상 프레임으로 자리를 넘긴다.
쓰임영상 리뷰 페이지·강의 목록 썸네일
BUFFER_DURATION_MSPLAY_HOLD_MSrunBuffering

<main class="stage">
<div class="lvp is-demo" id="lvp">
<div class="lvp__frame" id="lvpFrame">
<div class="lvp__poster" id="lvpPoster"></div>
<div class="lvp__playing" id="lvpPlaying"></div>
<div class="lvp__ring" id="lvpRing"></div>
<button type="button" class="lvp__play" id="lvpPlay" aria-label="영상 재생">▶</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 재생 시작 여부 값(isPlaying) 이 포스터↔실제 프레임을 판정한다. 버퍼링 중엔
// 경과/전체 시간 진행률로 conic-gradient 각도를 매 프레임 계산해 링이 돈다.
// 포스터→재생 전환은 크로스페이드 없이 즉시 스위치(잔상 방지, 077 주의사항).
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-light;
@include stage($stage-paper);
.lvp {
display: flex;
align-items: center;
justify-content: center;
}
.lvp__frame {
position: relative;
box-sizing: border-box;
width: 220px;
height: 140px;
border-radius: $r-card;
overflow: hidden;
background: $stage-ink;
box-shadow: $shadow-raised;
}
.lvp__poster {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 30% 25%, rgba(255, 255, 255, .3), transparent 55%),
linear-gradient(150deg, $subject-blue, $subject-lilac);
}
.lvp__playing {
position: absolute;
inset: 0;
opacity: 0;
background: repeating-linear-gradient(120deg, $subject-blue-deep 0 18px, $subject-blue 18px 36px);
background-size: 200% 200%;
}
.lvp__frame--playing .lvp__poster { opacity: 0; }
.lvp__frame--playing .lvp__playing {
opacity: 1;
animation: lvp-scan $duration linear infinite;
}
@keyframes lvp-scan {
from { background-position: 0 0; }
to { background-position: 72px 0; }
}
.lvp__ring {
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
margin: -22px 0 0 -22px;
border-radius: 50%;
opacity: 0;
mask: radial-gradient(circle, transparent 62%, #000 63%);
-webkit-mask: radial-gradient(circle, transparent 62%, #000 63%);
}
.lvp__frame--buffering .lvp__ring { opacity: 1; }
.lvp__frame--buffering .lvp__poster { opacity: .5; }
.lvp__play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, .88);
color: $stage-ink;
font-size: 14px;
display: grid;
place-items: center;
cursor: pointer;
}
.lvp__frame--buffering .lvp__play { opacity: 0; pointer-events: none; }
@media (max-width: 340px) {
.lvp__frame { width: 170px; height: 110px; }
}
@media (prefers-reduced-motion: reduce) {
.lvp__playing { animation: none !important; }
}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);
}
.lvp {
display: flex;
align-items: center;
justify-content: center;
}
.lvp__frame {
position: relative;
box-sizing: border-box;
width: 220px;
height: 140px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lvp__poster {
position: absolute;
inset: 0;
background: radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.3), transparent 55%), linear-gradient(150deg, #2f6df6, #b9a0ff);
}
.lvp__playing {
position: absolute;
inset: 0;
opacity: 0;
background: repeating-linear-gradient(120deg, #0a4fe8 0 18px, #2f6df6 18px 36px);
background-size: 200% 200%;
}
.lvp__frame--playing .lvp__poster {
opacity: 0;
}
.lvp__frame--playing .lvp__playing {
opacity: 1;
animation: lvp-scan 2s linear infinite;
}
@keyframes lvp-scan {
from {
background-position: 0 0;
}
to {
background-position: 72px 0;
}
}
.lvp__ring {
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
margin: -22px 0 0 -22px;
border-radius: 50%;
opacity: 0;
mask: radial-gradient(circle, transparent 62%, #000 63%);
-webkit-mask: radial-gradient(circle, transparent 62%, #000 63%);
}
.lvp__frame--buffering .lvp__ring {
opacity: 1;
}
.lvp__frame--buffering .lvp__poster {
opacity: 0.5;
}
.lvp__play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.88);
color: #17141a;
font-size: 14px;
display: grid;
place-items: center;
cursor: pointer;
}
.lvp__frame--buffering .lvp__play {
opacity: 0;
pointer-events: none;
}
@media (max-width: 340px) {
.lvp__frame {
width: 170px;
height: 110px;
}
}
@media (prefers-reduced-motion: reduce) {
.lvp__playing {
animation: none !important;
}
}(function () {
var root = document.getElementById('lvp');
var frame = document.getElementById('lvpFrame');
var ring = document.getElementById('lvpRing');
var playBtn = document.getElementById('lvpPlay');
var BUFFER_DURATION_MS = 620;
var PLAY_HOLD_MS = 1600;
var CYCLE_GAP_MS = 700;
var isPlaying = false;
var raf = null;
// 계산 겹: 경과 시간 값을 버퍼링 전체 시간 값으로 나눠 진행률을 구하고,
// 그 진행률을 conic-gradient 각도 변수로 매 프레임 다시 써 링이 돈다.
function runBuffering(onDone) {
var start = performance.now();
frame.classList.add('lvp__frame--buffering');
function step(now) {
var progress = Math.min((now - start) / BUFFER_DURATION_MS, 1);
ring.style.background = 'conic-gradient(from -90deg, #5b8cf8 ' + (progress * 360).toFixed(1) + 'deg, rgba(255,255,255,.18) 0)';
if (progress < 1) {
raf = requestAnimationFrame(step);
} else {
frame.classList.remove('lvp__frame--buffering');
onDone();
}
}
raf = requestAnimationFrame(step);
}
function setPlaying(v) {
isPlaying = v;
frame.classList.toggle('lvp__frame--playing', v);
playBtn.textContent = v ? '❚❚' : '▶';
playBtn.setAttribute('aria-label', v ? '영상 정지' : '영상 재생');
}
function startPlay() {
if (isPlaying) { return; }
ring.style.background = 'conic-gradient(from -90deg, #5b8cf8 0deg, rgba(255,255,255,.18) 0)';
runBuffering(function () { setPlaying(true); });
}
function resetToPoster() {
if (raf) { cancelAnimationFrame(raf); }
frame.classList.remove('lvp__frame--buffering');
setPlaying(false);
}
playBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (isPlaying) { resetToPoster(); } else { startPlay(); }
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var loopTimer = null;
if (reduce) {
root.classList.remove('is-demo');
setPlaying(true);
} else {
(function loop() {
resetToPoster();
loopTimer = setTimeout(function () {
if (!root.classList.contains('is-demo')) { return; }
startPlay();
loopTimer = setTimeout(function () {
if (root.classList.contains('is-demo')) { loop(); }
}, PLAY_HOLD_MS);
}, CYCLE_GAP_MS);
})();
}
})();import { useEffect, useRef, useCallback, useState } from "react";
import styles from "./VideoPosterSwap.module.scss";
// 재생 시작 여부 값(isPlaying) 이 포스터↔실제 프레임을 판정한다. 버퍼링 중엔
// 경과/전체 시간 진행률로 conic-gradient 각도를 매 프레임 계산해 링이 돈다.
const BUFFER_DURATION_MS = 620;
const PLAY_HOLD_MS = 1600;
const CYCLE_GAP_MS = 700;
type Box<T> = { current: T };
function useBox<T>(initial: T): Box<T> {
return useRef(initial) as unknown as Box<T>;
}
export default function VideoPosterSwap() {
const rootRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [playing, setPlayingState] = useState(false);
const [buffering, setBuffering] = useState(false);
const rafRef = useBox<number | null>(null);
const isPlayingRef = useBox<boolean>(false);
const setPlaying = useCallback((v: boolean) => {
isPlayingRef.current = v;
setPlayingState(v);
}, [isPlayingRef]);
const runBuffering = useCallback((onDone: () => void) => {
const start = performance.now();
setBuffering(true);
function step(now: number) {
const progress = Math.min((now - start) / BUFFER_DURATION_MS, 1);
if (ringRef.current) {
ringRef.current.style.background =
"conic-gradient(from -90deg, #5b8cf8 " + (progress * 360).toFixed(1) + "deg, rgba(255,255,255,.18) 0)";
}
if (progress < 1) {
rafRef.current = requestAnimationFrame(step);
} else {
setBuffering(false);
onDone();
}
}
rafRef.current = requestAnimationFrame(step);
}, [rafRef, ringRef]);
const startPlay = useCallback(() => {
if (isPlayingRef.current) { return; }
if (ringRef.current) {
ringRef.current.style.background = "conic-gradient(from -90deg, #5b8cf8 0deg, rgba(255,255,255,.18) 0)";
}
runBuffering(() => setPlaying(true));
}, [isPlayingRef, ringRef, runBuffering, setPlaying]);
const resetToPoster = useCallback(() => {
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
setBuffering(false);
setPlaying(false);
}, [rafRef, setPlaying]);
const onActivate = useCallback(() => {
setDemo(false);
if (isPlayingRef.current) { resetToPoster(); } else { startPlay(); }
}, [isPlayingRef, resetToPoster, startPlay]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setDemo(false);
setPlaying(true);
return;
}
let cancelled = false;
let gapTimer: number | null = null;
let holdTimer: number | null = null;
function loop() {
if (cancelled) { return; }
resetToPoster();
gapTimer = window.setTimeout(() => {
if (cancelled || !rootRef.current?.classList.contains(styles["is-demo"])) { return; }
startPlay();
holdTimer = window.setTimeout(() => {
if (!cancelled && rootRef.current?.classList.contains(styles["is-demo"])) { loop(); }
}, PLAY_HOLD_MS);
}, CYCLE_GAP_MS);
}
loop();
return () => {
cancelled = true;
if (gapTimer !== null) { window.clearTimeout(gapTimer); }
if (holdTimer !== null) { window.clearTimeout(holdTimer); }
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const frameClass = [
styles.lvp__frame,
playing ? styles["lvp__frame--playing"] : "",
buffering ? styles["lvp__frame--buffering"] : "",
].join(" ").trim();
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.lvp} ${demo ? styles["is-demo"] : ""}`}>
<div ref={frameRef} className={frameClass}>
<div className={styles["lvp__poster"]} />
<div className={styles["lvp__playing"]} />
<div ref={ringRef} className={styles["lvp__ring"]} />
<button
type="button"
className={styles["lvp__play"]}
aria-label={playing ? "영상 정지" : "영상 재생"}
onClick={onActivate}
>
{playing ? "❚❚" : "▶"}
</button>
</div>
</div>
</main>
);
}06 재생 값이 판정하는 영상 포스터 지연 로딩 — 측정 누적면적 21.261% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음
이 데모가 속한 글
이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음이미지 지연 로딩 아홉 가지 가운데 하나는 스크롤 위치 값으로 교차 비율을 계산해 이미지를 페이드인시키고, 하나는 클릭 여부 값으로 로드 시점을 계산해 지도 iframe 자리를 실제 화면으로 바꾸며, 하나는 유휴 시 이 글의 데모 전부 보기


