09유휴 시간 값이 지연 시간을 계산하는 위젯 지연 로딩
브라우저가 한가한 유휴 시간 값을 계산해 그 시간이 확보될 때까지 무거운 추천·광고 위젯 자리를 자리표시자로 남겨 둔다.
쓰임관련 상품 추천·사이드바 광고 위젯
IDLE_BUDGET_MSIDLE_TIMEOUT_MSIDLE_TICK_CAP_MS

<main class="stage">
<div class="liw is-demo" id="liw">
<div class="liw__widget" id="liwWidget">
<div class="liw__placeholder" id="liwPlaceholder">
<p class="liw__label">추천 위젯 자리</p>
<div class="liw__track">
<div class="liw__bar" id="liwBar"></div>
</div>
<p class="liw__sub" id="liwSub">유휴 시간 확보 중 0%</p>
</div>
<div class="liw__content" id="liwContent">
<div class="liw__card"></div>
<div class="liw__card"></div>
<div class="liw__card"></div>
</div>
</div>
<button type="button" class="liw__replay" id="liwReplay">다시 보기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// requestIdleCallback 의 유휴 남은시간(timeRemaining) 값을 매 콜백마다
// 누적해, 누적값이 목표 예산(IDLE_BUDGET_MS)에 닿는 순간에만 위젯 자리를
// 실제 내용으로 바꾼다.
$duration: $dur-enter;
$easing: $ease-elastic;
$color: $subject-pink;
@include stage($stage-ink);
.liw {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.liw__widget {
position: relative;
width: 240px;
height: 130px;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
overflow: hidden;
}
.liw__placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-2;
padding: 0 $sp-3;
box-sizing: border-box;
transition: opacity $dur-quick $ease-out;
}
.liw__label {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, .55);
}
.liw__track {
width: 100%;
height: 6px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .12);
overflow: hidden;
}
.liw__bar {
width: 0%;
height: 100%;
border-radius: $r-pill;
background: linear-gradient(90deg, $subject-pink, $subject-lilac);
transition: width 80ms linear;
}
.liw__sub {
margin: 0;
font-size: 10px;
color: rgba(255, 255, 255, .4);
}
.liw__content {
position: absolute;
inset: $sp-2;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
opacity: 0;
transform: scale(.85);
transform-origin: center;
transition: opacity $duration $easing, transform $duration $easing;
pointer-events: none;
}
.liw__card {
border-radius: $r-sm;
background: linear-gradient(160deg, $subject-mint, $subject-sky);
}
.liw__widget--loaded .liw__placeholder { opacity: 0; }
.liw__widget--loaded .liw__content {
opacity: 1;
transform: scale(1);
}
.liw__replay {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
border: none;
background: rgba(255, 255, 255, .12);
color: #fff;
font-size: 11px;
cursor: pointer;
}
@media (max-width: 340px) {
.liw__widget { width: 190px; height: 110px; }
}
@media (prefers-reduced-motion: reduce) {
.liw__placeholder, .liw__content, .liw__bar { transition: 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);
}
.liw {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.liw__widget {
position: relative;
width: 240px;
height: 130px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.liw__placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 12px;
box-sizing: border-box;
transition: opacity 220ms ease-out;
}
.liw__label {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
}
.liw__track {
width: 100%;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
.liw__bar {
width: 0%;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #ff9ebb, #b9a0ff);
transition: width 80ms linear;
}
.liw__sub {
margin: 0;
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
}
.liw__content {
position: absolute;
inset: 8px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
opacity: 0;
transform: scale(0.85);
transform-origin: center;
transition: opacity 600ms cubic-bezier(0.34, 1.56, 0.64, 1), transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
pointer-events: none;
}
.liw__card {
border-radius: 8px;
background: linear-gradient(160deg, #4cd4a6, #6ec8ff);
}
.liw__widget--loaded .liw__placeholder {
opacity: 0;
}
.liw__widget--loaded .liw__content {
opacity: 1;
transform: scale(1);
}
.liw__replay {
padding: 4px 12px;
border-radius: 999px;
border: none;
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-size: 11px;
cursor: pointer;
}
@media (max-width: 340px) {
.liw__widget {
width: 190px;
height: 110px;
}
}
@media (prefers-reduced-motion: reduce) {
.liw__placeholder, .liw__content, .liw__bar {
transition: none !important;
}
}(function () {
var root = document.getElementById('liw');
var widget = document.getElementById('liwWidget');
var bar = document.getElementById('liwBar');
var sub = document.getElementById('liwSub');
var replayBtn = document.getElementById('liwReplay');
var IDLE_BUDGET_MS = 260;
var IDLE_TIMEOUT_MS = 900;
var IDLE_TICK_CAP_MS = 40;
var CYCLE_GAP_MS = 1800;
var ric = window.requestIdleCallback || function (cb) {
var start = performance.now();
return setTimeout(function () {
cb({ didTimeout: false, timeRemaining: function () { return Math.max(0, 50 - (performance.now() - start)); } });
}, 40);
};
var cic = window.cancelIdleCallback || function (id) { clearTimeout(id); };
var accumulated = 0;
var idleHandle = null;
var running = false;
// 계산 겹: requestIdleCallback 이 넘겨주는 유휴 남은시간(timeRemaining) 값을
// 매 콜백마다 누적해, 누적값이 목표 예산(IDLE_BUDGET_MS)에 닿는 순간에만
// 위젯 자리를 실제 내용으로 바꾼다.
function tick(deadline) {
if (!running) { return; }
var got = deadline.didTimeout ? IDLE_TICK_CAP_MS : Math.min(deadline.timeRemaining(), IDLE_TICK_CAP_MS);
accumulated = Math.min(IDLE_BUDGET_MS, accumulated + Math.max(got, 12));
var pct = Math.round((accumulated / IDLE_BUDGET_MS) * 100);
bar.style.width = pct + '%';
sub.textContent = '유휴 시간 확보 중 ' + pct + '%';
if (accumulated >= IDLE_BUDGET_MS) {
widget.classList.add('liw__widget--loaded');
running = false;
} else {
idleHandle = ric(tick, { timeout: IDLE_TIMEOUT_MS });
}
}
function start() {
if (running) { return; }
running = true;
accumulated = 0;
bar.style.width = '0%';
sub.textContent = '유휴 시간 확보 중 0%';
widget.classList.remove('liw__widget--loaded');
idleHandle = ric(tick, { timeout: IDLE_TIMEOUT_MS });
}
function stop() {
running = false;
if (idleHandle !== null) { cic(idleHandle); idleHandle = null; }
}
replayBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stop();
start();
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
accumulated = IDLE_BUDGET_MS;
bar.style.width = '100%';
sub.textContent = '유휴 시간 확보 중 100%';
widget.classList.add('liw__widget--loaded');
} else {
(function loop() {
start();
var poll = setInterval(function () {
if (!widget.classList.contains('liw__widget--loaded')) { return; }
clearInterval(poll);
setTimeout(function () {
if (root.classList.contains('is-demo')) { loop(); }
}, CYCLE_GAP_MS);
}, 60);
})();
}
})();import { useEffect, useRef, useCallback, useState } from "react";
import styles from "./WidgetBelowFoldDefer.module.scss";
// requestIdleCallback 이 넘겨주는 유휴 남은시간(timeRemaining) 값을 매
// 콜백마다 누적해, 누적값이 목표 예산(IDLE_BUDGET_MS)에 닿는 순간에만
// 위젯 자리를 실제 내용으로 바꾼다.
const IDLE_BUDGET_MS = 260;
const IDLE_TIMEOUT_MS = 900;
const IDLE_TICK_CAP_MS = 40;
const CYCLE_GAP_MS = 1800;
type IdleDeadline = { didTimeout: boolean; timeRemaining: () => number };
type Win = typeof window & {
requestIdleCallback?: (cb: (d: IdleDeadline) => void, opts?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
type Box<T> = { current: T };
function useBox<T>(initial: T): Box<T> {
return useRef(initial) as unknown as Box<T>;
}
export default function WidgetBelowFoldDefer() {
const rootRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [pct, setPct] = useState(0);
const [loaded, setLoaded] = useState(false);
const accumulatedRef = useBox<number>(0);
const runningRef = useBox<boolean>(false);
const idleHandleRef = useBox<number | null>(null);
const ric = useCallback((cb: (d: IdleDeadline) => void, opts?: { timeout: number }) => {
const w = window as Win;
if (w.requestIdleCallback) { return w.requestIdleCallback(cb, opts); }
const start = performance.now();
return window.setTimeout(() => {
cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (performance.now() - start)) });
}, 40);
}, []);
const cic = useCallback((id: number) => {
const w = window as Win;
if (w.cancelIdleCallback) { w.cancelIdleCallback(id); return; }
window.clearTimeout(id);
}, []);
const tick = useCallback((deadline: IdleDeadline) => {
if (!runningRef.current) { return; }
const got = deadline.didTimeout ? IDLE_TICK_CAP_MS : Math.min(deadline.timeRemaining(), IDLE_TICK_CAP_MS);
accumulatedRef.current = Math.min(IDLE_BUDGET_MS, accumulatedRef.current + Math.max(got, 12));
const next = Math.round((accumulatedRef.current / IDLE_BUDGET_MS) * 100);
setPct(next);
if (accumulatedRef.current >= IDLE_BUDGET_MS) {
setLoaded(true);
runningRef.current = false;
} else {
idleHandleRef.current = ric(tick, { timeout: IDLE_TIMEOUT_MS });
}
}, [accumulatedRef, idleHandleRef, ric, runningRef]);
const start = useCallback(() => {
if (runningRef.current) { return; }
runningRef.current = true;
accumulatedRef.current = 0;
setPct(0);
setLoaded(false);
idleHandleRef.current = ric(tick, { timeout: IDLE_TIMEOUT_MS });
}, [accumulatedRef, idleHandleRef, ric, runningRef, tick]);
const stop = useCallback(() => {
runningRef.current = false;
if (idleHandleRef.current !== null) { cic(idleHandleRef.current); idleHandleRef.current = null; }
}, [cic, idleHandleRef, runningRef]);
const onReplay = useCallback(() => {
setDemo(false);
stop();
start();
}, [start, stop]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setDemo(false);
accumulatedRef.current = IDLE_BUDGET_MS;
setPct(100);
setLoaded(true);
return;
}
let cancelled = false;
let poll: number | null = null;
let gap: number | null = null;
function loop() {
if (cancelled) { return; }
start();
poll = window.setInterval(() => {
if (!loadedGuard()) { return; }
if (poll !== null) { window.clearInterval(poll); }
gap = window.setTimeout(() => {
if (!cancelled && rootRef.current?.classList.contains(styles["is-demo"])) { loop(); }
}, CYCLE_GAP_MS);
}, 60);
}
function loadedGuard() {
return accumulatedRef.current >= IDLE_BUDGET_MS;
}
loop();
return () => {
cancelled = true;
stop();
if (poll !== null) { window.clearInterval(poll); }
if (gap !== null) { window.clearTimeout(gap); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.liw} ${demo ? styles["is-demo"] : ""}`}>
<div className={`${styles["liw__widget"]} ${loaded ? styles["liw__widget--loaded"] : ""}`}>
<div className={styles["liw__placeholder"]}>
<p className={styles["liw__label"]}>추천 위젯 자리</p>
<div className={styles["liw__track"]}>
<div className={styles["liw__bar"]} style={{ width: pct + "%" }} />
</div>
<p className={styles["liw__sub"]}>{"유휴 시간 확보 중 " + pct + "%"}</p>
</div>
<div className={styles["liw__content"]}>
<div className={styles["liw__card"]} />
<div className={styles["liw__card"]} />
<div className={styles["liw__card"]} />
</div>
</div>
<button type="button" className={styles["liw__replay"]} onClick={onReplay}>다시 보기</button>
</div>
</main>
);
}09 유휴 시간 값이 지연 시간을 계산하는 위젯 지연 로딩 — 측정 누적면적 17.406% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음
이 데모가 속한 글
이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음이미지 지연 로딩 아홉 가지 가운데 하나는 스크롤 위치 값으로 교차 비율을 계산해 이미지를 페이드인시키고, 하나는 클릭 여부 값으로 로드 시점을 계산해 지도 iframe 자리를 실제 화면으로 바꾸며, 하나는 유휴 시 이 글의 데모 전부 보기


