02화면 너비 값이 고르는 배경 지연 로딩
화면 너비 값을 계산해 좁을 땐 저해상도, 넓을 땐 고해상도 배경 중 알맞은 쪽을 고르고 자리표시자 색에서 실제 배경으로 넘어간다.
쓰임히어로 섹션·이벤트 페이지 배경
BREAKPOINT_PXCROSSFADE_MSWIDTH_NARROW

<main class="stage">
<div class="lbs is-demo" id="lbs">
<div class="lbs__badge" id="lbsBadge">폭 180px · 저해상도</div>
<div class="lbs__frame" id="lbsFrame">
<div class="lbs__placeholder" id="lbsPlaceholder"></div>
<div class="lbs__bg lbs__bg--low" id="lbsBgLow"></div>
<div class="lbs__bg lbs__bg--high" id="lbsBgHigh"></div>
</div>
<p class="lbs__hint">클릭하면 폭이 바뀝니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 화면 너비 값 → 해상도 등급. ResizeObserver 가 프레임 폭을 재고, 기준값보다 좁으면
// 저해상도, 넓으면 고해상도 배경을 고른 뒤 진행률(경과/전체 시간)로 블러·불투명도를 계산한다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-paper);
.lbs {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
outline: none;
cursor: pointer;
}
.lbs__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(76, 212, 166, .16);
color: darken($color, 18%);
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lbs__frame {
position: relative;
box-sizing: border-box;
width: 180px;
height: 160px;
border-radius: $r-card;
overflow: hidden;
transition: width $duration $easing;
background: $subject-cream;
box-shadow: $shadow-raised;
}
.lbs__placeholder {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(23, 20, 26, .08), rgba(23, 20, 26, .16));
transition: opacity $dur-quick $ease-out;
}
.lbs__placeholder--hidden {
opacity: 0;
}
.lbs__bg {
position: absolute;
inset: 0;
opacity: 0;
}
.lbs__bg--low {
background: linear-gradient(160deg, $subject-mint, $subject-sky);
}
.lbs__bg--high {
background:
radial-gradient(circle at 30% 30%, rgba(255, 255, 255, .5), transparent 55%),
radial-gradient(circle at 75% 65%, rgba(255, 255, 255, .35), transparent 45%),
linear-gradient(160deg, $subject-mint, $subject-lilac);
}
.lbs__hint {
margin: 0;
color: rgba(23, 20, 26, .5);
font-size: 11px;
}
@media (max-width: 340px) {
.lbs { gap: $sp-2; }
.lbs__frame { width: 140px !important; height: 92px; }
}
@media (prefers-reduced-motion: reduce) {
.lbs__frame { transition: none !important; }
.lbs__placeholder { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lbs {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
outline: none;
cursor: pointer;
}
.lbs__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(76, 212, 166, 0.16);
color: rgb(14.9030206677%, 62.0381558029%, 46.0953895072%);
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lbs__frame {
position: relative;
box-sizing: border-box;
width: 180px;
height: 160px;
border-radius: 16px;
overflow: hidden;
transition: width 300ms ease-out;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lbs__placeholder {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(23, 20, 26, 0.08), rgba(23, 20, 26, 0.16));
transition: opacity 220ms ease-out;
}
.lbs__placeholder--hidden {
opacity: 0;
}
.lbs__bg {
position: absolute;
inset: 0;
opacity: 0;
}
.lbs__bg--low {
background: linear-gradient(160deg, #4cd4a6, #6ec8ff);
}
.lbs__bg--high {
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.5), transparent 55%), radial-gradient(circle at 75% 65%, rgba(255, 255, 255, 0.35), transparent 45%), linear-gradient(160deg, #4cd4a6, #b9a0ff);
}
.lbs__hint {
margin: 0;
color: rgba(23, 20, 26, 0.5);
font-size: 11px;
}
@media (max-width: 340px) {
.lbs {
gap: 8px;
}
.lbs__frame {
width: 140px !important;
height: 92px;
}
}
@media (prefers-reduced-motion: reduce) {
.lbs__frame {
transition: none !important;
}
.lbs__placeholder {
transition: none !important;
}
}(function () {
var root = document.getElementById('lbs');
var frame = document.getElementById('lbsFrame');
var badge = document.getElementById('lbsBadge');
var placeholder = document.getElementById('lbsPlaceholder');
var bgLow = document.getElementById('lbsBgLow');
var bgHigh = document.getElementById('lbsBgHigh');
var BREAKPOINT_PX = 240;
var WIDTH_NARROW = 180;
var WIDTH_WIDE = 300;
var LOAD_DELAY_MS = 480;
var CROSSFADE_MS = 480;
var CYCLE_MS = 2200;
var lastTier = null;
var raf = null;
var loadTimer = null;
// 계산 겹: 경과 시간 값을 전체 전환 시간 값으로 나눠 진행률을 구하고,
// 그 진행률로 블러 반경과 불투명도를 매 프레임 다시 계산한다.
function crossfadeIn(el, duration) {
var start = performance.now();
if (raf) { cancelAnimationFrame(raf); }
function step(now) {
var progress = Math.min((now - start) / duration, 1);
el.style.opacity = String(progress);
el.style.filter = 'blur(' + (6 * (1 - progress)) + 'px)';
if (progress < 1) { raf = requestAnimationFrame(step); }
}
raf = requestAnimationFrame(step);
}
function applyTier(widthPx) {
var tier = widthPx < BREAKPOINT_PX ? 'low' : 'high';
badge.textContent = '폭 ' + Math.round(widthPx) + 'px · ' + (tier === 'low' ? '저해상도' : '고해상도');
if (tier === lastTier) { return; }
lastTier = tier;
bgLow.style.opacity = '0';
bgHigh.style.opacity = '0';
placeholder.classList.remove('lbs__placeholder--hidden');
if (loadTimer) { clearTimeout(loadTimer); }
loadTimer = setTimeout(function () {
var target = tier === 'low' ? bgLow : bgHigh;
placeholder.classList.add('lbs__placeholder--hidden');
crossfadeIn(target, CROSSFADE_MS);
}, LOAD_DELAY_MS);
}
var ro = new ResizeObserver(function (entries) {
for (var i = 0; i < entries.length; i += 1) {
applyTier(entries[i].contentRect.width);
}
});
ro.observe(frame);
function setWide(wide) {
frame.style.width = (wide ? WIDTH_WIDE : WIDTH_NARROW) + 'px';
}
var wide = false;
function toggle() {
wide = !wide;
setWide(wide);
}
root.addEventListener('click', toggle);
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
root.tabIndex = 0;
var autoplayTimer = null;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
setWide(true);
lastTier = 'high';
placeholder.classList.add('lbs__placeholder--hidden');
bgHigh.style.opacity = '1';
bgHigh.style.filter = 'blur(0px)';
badge.textContent = '폭 ' + WIDTH_WIDE + 'px · 고해상도';
} else {
setWide(false);
autoplayTimer = setInterval(function () {
if (!root.classList.contains('is-demo')) { clearInterval(autoplayTimer); return; }
toggle();
}, CYCLE_MS);
}
function userTakesOver() {
root.classList.remove('is-demo');
}
root.addEventListener('click', userTakesOver);
})();import { useEffect, useRef, useState, useCallback } from "react";
import styles from "./BackgroundImageSwap.module.scss";
// 화면 너비 값 → 해상도 등급. ResizeObserver 가 프레임 폭을 재고, 기준값보다 좁으면
// 저해상도, 넓으면 고해상도 배경을 고른 뒤 진행률로 블러·불투명도를 계산한다.
const BREAKPOINT_PX = 240;
const WIDTH_NARROW = 180;
const WIDTH_WIDE = 300;
const LOAD_DELAY_MS = 480;
const CROSSFADE_MS = 480;
const CYCLE_MS = 2200;
export default function BackgroundImageSwap() {
const rootRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const placeholderRef = useRef<HTMLDivElement>(null);
const bgLowRef = useRef<HTMLDivElement>(null);
const bgHighRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [wideState, setWideState] = useState(false);
const [badge, setBadge] = useState("폭 180px · 저해상도");
const lastTierRef = useRef<string | null>(null);
const rafRef = useRef<number | null>(null);
const loadTimerRef = useRef<number | null>(null);
const wideRef = useRef(false);
const crossfadeIn = useCallback((el: HTMLDivElement, duration: number) => {
const start = performance.now();
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); }
function step(now: number) {
const progress = Math.min((now - start) / duration, 1);
el.style.opacity = String(progress);
el.style.filter = "blur(" + (6 * (1 - progress)) + "px)";
if (progress < 1) { rafRef.current = requestAnimationFrame(step); }
}
rafRef.current = requestAnimationFrame(step);
}, []);
const applyTier = useCallback((widthPx: number) => {
const tier = widthPx < BREAKPOINT_PX ? "low" : "high";
setBadge("폭 " + Math.round(widthPx) + "px · " + (tier === "low" ? "저해상도" : "고해상도"));
if (tier === lastTierRef.current) { return; }
lastTierRef.current = tier;
const low = bgLowRef.current;
const high = bgHighRef.current;
const placeholder = placeholderRef.current;
if (!low || !high || !placeholder) { return; }
low.style.opacity = "0";
high.style.opacity = "0";
placeholder.classList.remove(styles["lbs__placeholder--hidden"]);
if (loadTimerRef.current !== null) { window.clearTimeout(loadTimerRef.current); }
loadTimerRef.current = window.setTimeout(() => {
const target = tier === "low" ? low : high;
placeholder.classList.add(styles["lbs__placeholder--hidden"]);
crossfadeIn(target, CROSSFADE_MS);
}, LOAD_DELAY_MS);
}, [crossfadeIn]);
const toggle = useCallback(() => {
wideRef.current = !wideRef.current;
setWideState(wideRef.current);
setDemo(false);
}, []);
useEffect(() => {
const frame = frameRef.current;
if (!frame) { return; }
const ro = new ResizeObserver((entries) => {
for (const entry of entries) { applyTier(entry.contentRect.width); }
});
ro.observe(frame);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let autoplayTimer: number | null = null;
if (reduce) {
setDemo(false);
wideRef.current = true;
setWideState(true);
lastTierRef.current = "high";
placeholderRef.current?.classList.add(styles["lbs__placeholder--hidden"]);
if (bgHighRef.current) {
bgHighRef.current.style.opacity = "1";
bgHighRef.current.style.filter = "blur(0px)";
}
setBadge("폭 " + WIDTH_WIDE + "px · 고해상도");
} else {
autoplayTimer = window.setInterval(() => {
if (!rootRef.current?.classList.contains(styles["is-demo"])) {
if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); }
return;
}
wideRef.current = !wideRef.current;
setWideState(wideRef.current);
}, CYCLE_MS);
}
return () => {
ro.disconnect();
if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); }
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); }
if (loadTimerRef.current !== null) { window.clearTimeout(loadTimerRef.current); }
};
}, [applyTier]);
useEffect(() => {
const root = rootRef.current;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); }
}
root?.addEventListener("keydown", onKeyDown);
return () => root?.removeEventListener("keydown", onKeyDown);
}, [toggle]);
return (
<main className={styles.stage}>
<div
ref={rootRef}
className={`${styles.lbs} ${demo ? styles["is-demo"] : ""}`}
tabIndex={0}
onClick={toggle}
>
<div className={styles["lbs__badge"]}>{badge}</div>
<div
ref={frameRef}
className={styles["lbs__frame"]}
style={{ width: (wideState ? WIDTH_WIDE : WIDTH_NARROW) + "px" }}
>
<div ref={placeholderRef} className={styles["lbs__placeholder"]} />
<div ref={bgLowRef} className={`${styles["lbs__bg"]} ${styles["lbs__bg--low"]}`} />
<div ref={bgHighRef} className={`${styles["lbs__bg"]} ${styles["lbs__bg--high"]}`} />
</div>
<p className={styles["lbs__hint"]}>클릭하면 폭이 바뀝니다</p>
</div>
</main>
);
}02 화면 너비 값이 고르는 배경 지연 로딩 — 측정 누적면적 38.076% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음
이 데모가 속한 글
이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음이미지 지연 로딩 아홉 가지 가운데 하나는 스크롤 위치 값으로 교차 비율을 계산해 이미지를 페이드인시키고, 하나는 클릭 여부 값으로 로드 시점을 계산해 지도 iframe 자리를 실제 화면으로 바꾸며, 하나는 유휴 시 이 글의 데모 전부 보기


