画像 遅延読み込み 9種 — プレースホルダー アニメーション
画像 遅延読み込みの9つのうち、1つはスクロール位置の値から交差比率を計算して画像をフェードインさせ、1つはクリックの値から読み込みタイミングを計算してiframeのプレースホルダーを実際の表示に変え、1つはアイドル時間の値から遅延時間を計算して重いウィジェットを後から読み込みます。
01 lazy-load-font-swap-fout
02 lazy-load-background-image-swap
03 lazy-load-image-fade-in-viewport
05 lazy-load-gallery-grid-stagger-reveal
06 lazy-load-video-poster-swap
07 lazy-load-iframe-click-to-load
08 lazy-load-section-reveal-below-fold
09 lazy-load-widget-below-fold-defer
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 代替書体から本来のフォントへ切り替わる遅延読み込みテキスト
- 02 画面幅の値が選ぶ背景の遅延読み込み
- 03 スクロール位置の値が交差比率を計算する画像フェードイン
- 04 名前の値がイニシャルを計算するアバターの遅延読み込み
- 05 格子位置の値が順番を計算するギャラリーの遅延読み込み
- 06 再生の値が判定する動画ポスターの遅延読み込み
- 07 クリックの値が読み込み時点を計算するiframeの遅延読み込み
- 08 スクロールの値が閾値を計算する下部セクションの遅延読み込み
- 09 アイドル時間の値が遅延時間を計算するウィジェットの遅延読み込み
以下の9つのデモは、どれも本物の画像・動画・iframe・Webフォントをネットワーク越しに取得していません。ここで「読み込み」に見えているのはすべて、タイマーか、observerのコールバックか、アイドル時間の予算のどれかが代わりを務めているだけです。これらを実際のページに組み込むときは、プレースホルダーから実物へ切り替わるその瞬間こそが、本物の <img src> や fetch、本物の <iframe src>、実際の document.fonts.ready を差し込む場所になります。Math.random も外部ライブラリも一切使っていないので、同じパターンは何度実行しても同じ動きになります。
読み込む価値があるかどうかを決める、9つの小さな計算
このページの9つはすべて、同じ問いに違う入力で答えています——今がそれを読み込む価値のある瞬間かどうか。パターン03はスクロール位置に尋ね、パターン09はブラウザ自身のアイドル時計に尋ね、パターン07はそもそもクリックされたかどうかに尋ねます。各パターンのコードの上には、その判断を担っている関数の名前を書いているので、zipからファイルを取り出したあとも迷わず見つけられます。
01代替書体から本来のフォントへ切り替わる遅延読み込みテキスト
フォントが用意できたかどうかの値を計算して代替書体をまず表示し、本来のフォントが揃うと文字の形を自然に切り替えます。各文字には STAGGER_STEP_MS にインデックスを掛けた値で transitionDelay を個別に計算して与えるので、切り替えは一斉ではなく左から右へ波のように広がります。実際の実装では本物の document.fonts.ready や font-display: swap の境界に置き換える部分で、ここでは本物のWebフォントは一切ダウンロードしていません。

<main class="stage">
<div class="lff is-demo" id="lff">
<div class="lff__badge" id="lffBadge">代替フォント表示中</div>
<h2 class="lff__head" id="lffHead" aria-label="遅延読み込み見出し"></h2>
<p class="lff__hint">クリックで再生</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 대체 서체 → 실제 서체 지연 로딩. 글자마다 인덱스 값으로 transition-delay 를 미리 계산해
// 심어두면(자바스크립트) 클래스 하나만 켜져도 순서대로 스프링 오버슛이 번져 나간다.
$duration: $dur-enter;
$easing: $ease-elastic;
$color: $subject-sky;
@include stage($stage-ink);
.lff {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
width: min(100%, 300px);
outline: none;
cursor: pointer;
}
.lff__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(110, 200, 255, .16);
color: $color;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lff__head {
margin: 0;
padding: $sp-4 $sp-4;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
color: $stage-paper;
font-size: 22px;
font-weight: 700;
font-family: "Times New Roman", Georgia, serif;
text-align: center;
transition: font-family 1ms linear;
}
.lff__char {
display: inline-block;
transform: scale(.82);
transform-origin: 50% 100%;
opacity: .55;
transition: transform $duration $easing, opacity $dur-base $ease-out;
}
.lff--loaded .lff__head {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lff__char--loaded {
transform: scale(1);
opacity: 1;
}
.lff__hint {
margin: 0;
color: rgba(255, 255, 255, .5);
font-size: 11px;
}
@media (max-width: 340px) {
.lff__head { font-size: 18px; padding: $sp-3 $sp-3; }
.lff { gap: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.lff__char { transition: none !important; }
.lff__head { 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);
}
.lff {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: min(100%, 300px);
outline: none;
cursor: pointer;
}
.lff__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(110, 200, 255, 0.16);
color: #6ec8ff;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lff__head {
margin: 0;
padding: 16px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
color: #ffffff;
font-size: 22px;
font-weight: 700;
font-family: "Times New Roman", Georgia, serif;
text-align: center;
transition: font-family 1ms linear;
}
.lff__char {
display: inline-block;
transform: scale(0.82);
transform-origin: 50% 100%;
opacity: 0.55;
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 300ms ease-out;
}
.lff--loaded .lff__head {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lff__char--loaded {
transform: scale(1);
opacity: 1;
}
.lff__hint {
margin: 0;
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
}
@media (max-width: 340px) {
.lff__head {
font-size: 18px;
padding: 12px 12px;
}
.lff {
gap: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.lff__char {
transition: none !important;
}
.lff__head {
transition: none !important;
}
}(function () {
var root = document.getElementById('lff');
var badge = document.getElementById('lffBadge');
var head = document.getElementById('lffHead');
var TEXT = '遅延読み込み見出し';
var STAGGER_STEP_MS = 45;
var FOUT_DELAY_MS = 700;
var HOLD_MS = 1800;
var RESET_DELAY_MS = 900;
var timer = null;
// 계산 겹: 글자 인덱스 값으로 전환 지연 시간을 계산해 순서대로 스프링 오버슛이 번진다.
var chars = [];
for (var i = 0; i < TEXT.length; i += 1) {
var span = document.createElement('span');
span.className = 'lff__char';
span.textContent = TEXT[i] === ' ' ? ' ' : TEXT[i];
span.style.transitionDelay = (i * STAGGER_STEP_MS) + 'ms';
head.appendChild(span);
chars.push(span);
}
function resetState() {
root.classList.remove('lff--loaded');
for (var i = 0; i < chars.length; i += 1) { chars[i].classList.remove('lff__char--loaded'); }
badge.textContent = '代替フォント表示中';
}
function swapToLoaded() {
root.classList.add('lff--loaded');
for (var i = 0; i < chars.length; i += 1) { chars[i].classList.add('lff__char--loaded'); }
badge.textContent = '実フォント読み込み完了';
}
function playOnce(loop) {
resetState();
timer = setTimeout(function () {
swapToLoaded();
if (loop) {
timer = setTimeout(function () { playOnce(true); }, HOLD_MS + RESET_DELAY_MS);
}
}, FOUT_DELAY_MS);
}
function replay() {
if (timer) { clearTimeout(timer); }
root.classList.remove('is-demo');
playOnce(false);
}
root.addEventListener('click', replay);
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); replay(); }
});
root.tabIndex = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
swapToLoaded();
} else {
playOnce(true);
}
})();import { useEffect, useRef, useState, useCallback } from "react";
import styles from "./FontSwapFout.module.scss";
// 대체 서체 → 실제 서체 지연 로딩. 글자 인덱스 값으로 transition-delay 를 계산해
// 순서대로 스프링 오버슛이 번지게 한다.
const TEXT = "지연 로딩 헤드라인";
const STAGGER_STEP_MS = 45;
const FOUT_DELAY_MS = 700;
const HOLD_MS = 1800;
const RESET_DELAY_MS = 900;
export default function FontSwapFout() {
const rootRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [loaded, setLoaded] = useState(false);
const [badge, setBadge] = useState("대체 서체 표시 중");
const timerRef = useRef<number | null>(null);
const playOnce = useCallback((loop: boolean) => {
setLoaded(false);
setBadge("대체 서체 표시 중");
timerRef.current = window.setTimeout(() => {
setLoaded(true);
setBadge("실제 서체 로드 완료");
if (loop) {
timerRef.current = window.setTimeout(() => { playOnce(true); }, HOLD_MS + RESET_DELAY_MS);
}
}, FOUT_DELAY_MS);
}, []);
const replay = useCallback(() => {
if (timerRef.current !== null) { window.clearTimeout(timerRef.current); }
setDemo(false);
playOnce(false);
}, [playOnce]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setDemo(false);
setLoaded(true);
setBadge("실제 서체 로드 완료");
} else {
playOnce(true);
}
const root = rootRef.current;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); replay(); }
}
root?.addEventListener("keydown", onKeyDown);
return () => {
if (timerRef.current !== null) { window.clearTimeout(timerRef.current); }
root?.removeEventListener("keydown", onKeyDown);
};
}, [playOnce, replay]);
const chars = TEXT.split("").map((ch, i) => (
<span
key={i}
className={`${styles["lff__char"]} ${loaded ? styles["lff__char--loaded"] : ""}`}
style={{ transitionDelay: `${i * STAGGER_STEP_MS}ms` }}
>
{ch === " " ? " " : ch}
</span>
));
return (
<main className={styles.stage}>
<div
ref={rootRef}
className={`${styles.lff} ${demo ? styles["is-demo"] : ""} ${loaded ? styles["lff--loaded"] : ""}`}
tabIndex={0}
onClick={replay}
>
<div className={styles["lff__badge"]}>{badge}</div>
<h2 className={styles["lff__head"]} aria-label="지연 로딩 헤드라인">{chars}</h2>
<p className={styles["lff__hint"]}>클릭하면 다시 재생</p>
</div>
</main>
);
}01 · 代替書体から本来のフォントへ切り替わる遅延読み込みテキスト — 計測 累積面積 5.237% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var TEXT = '遅延読み込み見出し';
var STAGGER_STEP_MS = 45;
var FOUT_DELAY_MS = 700;
var HOLD_MS = 1800;
var RESET_DELAY_MS = 900;
var timer = null;
// 計算レイヤー: 文字インデックスの値から切り替え遅延を計算し、スプリングオーバーシュートが順番に広がる。
var chars = [];
for (var i = 0; i < TEXT.length; i += 1) {
var span = document.createElement('span');
span.className = 'lff__char';
span.textContent = TEXT[i] === ' ' ? ' ' : TEXT[i];
span.style.transitionDelay = (i * STAGGER_STEP_MS) + 'ms';
head.appendChild(span);
chars.push(span);
}
function resetState() {
root.classList.remove('lff--loaded');
for (var i = 0; i < chars.length; i += 1) { chars[i].classList.remove('lff__char--loaded'); }
badge.textContent = '代替フォント表示中';
}
02画面幅の値が選ぶ背景の遅延読み込み
画面幅の値を計算して狭ければ低解像度、広ければ高解像度の背景を選び、プレースホルダーの色から実際の背景へ切り替えます。crossfadeIn は経過時間を全体時間で割って0〜1の進捗値を求め、その値からブラーと不透明度を毎フレーム書き換えます。実際に組み込む際は WIDTH_NARROW・WIDTH_WIDE を本物の画像URLに、プレースホルダーの色を background-image の読み込み完了イベントに置き換えてください。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
03スクロール位置の値が交差比率を計算する画像フェードイン
スクロール位置の値で画像がビューポートと重なる交差比率を計算し、その比率が基準を超えるとプレースホルダーから実際の画像へフェードインします。applyRatio は観測のたびにブラー半径とスケールを再計算するので、画像は一段階でではなくスクロールした分だけ少しずつくっきりしていきます。その基準点を早めたり遅めたりするのに触る行は INTERSECTION_THRESHOLD 一つだけです。

<main class="stage">
<div class="lfv is-demo" id="lfv">
<div class="lfv__badge" id="lfvBadge">交差率0%</div>
<div class="lfv__view" id="lfvView">
<div class="lfv__track">
<div class="lfv__card" data-card="1"><div class="lfv__placeholder"></div><div class="lfv__img lfv__img--1"></div></div>
<div class="lfv__card" data-card="2"><div class="lfv__placeholder"></div><div class="lfv__img lfv__img--2"></div></div>
<div class="lfv__card" data-card="3"><div class="lfv__placeholder"></div><div class="lfv__img lfv__img--3"></div></div>
<div class="lfv__card" data-card="4"><div class="lfv__placeholder"></div><div class="lfv__img lfv__img--4"></div></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크롤 위치 값 → 교차 비율(IntersectionObserver). 비율 값으로 블러·스케일을 매번
// 다시 계산해 뷰포트에 걸친 정도만큼 또렷해지고, 기준을 넘으면 로드 완료로 고정한다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.lfv {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(100%, 280px);
}
.lfv__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(47, 109, 246, .18);
color: $subject-blue-light;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lfv__view {
box-sizing: border-box;
width: 100%;
height: 200px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
padding: $sp-3;
scrollbar-width: thin;
}
.lfv__track {
display: flex;
flex-direction: column;
gap: $sp-3;
}
.lfv__card {
position: relative;
box-sizing: border-box;
height: 120px;
border-radius: $r-sm;
overflow: hidden;
flex: 0 0 auto;
}
.lfv__placeholder {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, .1);
}
.lfv__img {
position: absolute;
inset: 0;
opacity: 0;
transform-origin: center;
}
.lfv__img--1 { background: linear-gradient(135deg, $subject-blue, $subject-lilac); }
.lfv__img--2 { background: linear-gradient(135deg, $subject-mint, $subject-blue-light); }
.lfv__img--3 { background: linear-gradient(135deg, $subject-pink, $subject-blue); }
.lfv__img--4 { background: linear-gradient(135deg, $subject-sky, $subject-lilac); }
.lfv__card--loaded {
box-shadow: inset 0 0 0 2px rgba(91, 140, 248, .5);
}
@media (max-width: 340px) {
.lfv { gap: $sp-2; }
.lfv__view { height: 108px; }
.lfv__card { height: 84px; }
}
@media (prefers-reduced-motion: reduce) {
.lfv__img, .lfv__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lfv {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(100%, 280px);
}
.lfv__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.18);
color: #5b8cf8;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lfv__view {
box-sizing: border-box;
width: 100%;
height: 200px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
padding: 12px;
scrollbar-width: thin;
}
.lfv__track {
display: flex;
flex-direction: column;
gap: 12px;
}
.lfv__card {
position: relative;
box-sizing: border-box;
height: 120px;
border-radius: 8px;
overflow: hidden;
flex: 0 0 auto;
}
.lfv__placeholder {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.1);
}
.lfv__img {
position: absolute;
inset: 0;
opacity: 0;
transform-origin: center;
}
.lfv__img--1 {
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
}
.lfv__img--2 {
background: linear-gradient(135deg, #4cd4a6, #5b8cf8);
}
.lfv__img--3 {
background: linear-gradient(135deg, #ff9ebb, #2f6df6);
}
.lfv__img--4 {
background: linear-gradient(135deg, #6ec8ff, #b9a0ff);
}
.lfv__card--loaded {
box-shadow: inset 0 0 0 2px rgba(91, 140, 248, 0.5);
}
@media (max-width: 340px) {
.lfv {
gap: 8px;
}
.lfv__view {
height: 108px;
}
.lfv__card {
height: 84px;
}
}
@media (prefers-reduced-motion: reduce) {
.lfv__img, .lfv__placeholder {
transition: none !important;
}
}(function () {
var root = document.getElementById('lfv');
var view = document.getElementById('lfvView');
var badge = document.getElementById('lfvBadge');
var cards = Array.prototype.slice.call(document.querySelectorAll('.lfv__card'));
var INTERSECTION_THRESHOLD = 0.4;
var STEPS = [];
for (var s = 0; s <= 20; s += 1) { STEPS.push(s / 20); }
// 계산 겹: 交差率 값으로 블러 반경·스케일을 매 관찰마다 다시 계산해
// 뷰포트에 걸친 정도만큼 이미지가 또렷해진다. 비율이 기준을 넘으면 로드 완료로 고정.
function applyRatio(card, ratio) {
var img = card.querySelector('.lfv__img');
var placeholder = card.querySelector('.lfv__placeholder');
var blur = 10 * (1 - ratio);
var scale = 0.92 + 0.08 * ratio;
img.style.opacity = String(ratio);
img.style.filter = 'blur(' + blur.toFixed(1) + 'px)';
img.style.transform = 'scale(' + scale.toFixed(3) + ')';
placeholder.style.opacity = String(Math.max(0, 1 - ratio * 1.4));
if (ratio >= INTERSECTION_THRESHOLD) {
card.classList.add('lfv__card--loaded');
}
if (card === cards[Math.min(cards.length - 1, Math.round(lastMaxIndex))]) {
badge.textContent = '交差率 ' + Math.round(ratio * 100) + '%';
}
}
var lastMaxIndex = 0;
var io = new IntersectionObserver(function (entries) {
var maxRatio = 0, maxIdx = 0;
entries.forEach(function (entry) {
var idx = cards.indexOf(entry.target);
applyRatio(entry.target, entry.intersectionRatio);
if (entry.intersectionRatio >= maxRatio) { maxRatio = entry.intersectionRatio; maxIdx = idx; }
});
lastMaxIndex = maxIdx;
badge.textContent = '交差率 ' + Math.round(maxRatio * 100) + '%';
}, { root: view, threshold: STEPS });
cards.forEach(function (c) { io.observe(c); });
var dir = 1;
var autoplayTimer = null;
function autoplayTick() {
var max = view.scrollHeight - view.clientHeight;
view.scrollTop += dir * 2.2;
if (view.scrollTop >= max - 1) { dir = -1; }
if (view.scrollTop <= 1) { dir = 1; }
}
function userTakesOver() {
if (!root.classList.contains('is-demo')) { return; }
root.classList.remove('is-demo');
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
view.addEventListener('wheel', userTakesOver, { passive: true });
view.addEventListener('touchstart', userTakesOver, { passive: true });
view.addEventListener('scroll', function () {
// 스크롤 위치 값 자체가 IntersectionObserver 재계산의 입력이 된다 (root 스크롤).
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
cards.forEach(function (c) { applyRatio(c, 1); });
badge.textContent = '交差率100%';
} else {
autoplayTimer = setInterval(autoplayTick, 45);
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./ImageFadeInViewport.module.scss";
// 스크롤 위치 값 → 교차 비율(IntersectionObserver). 비율 값으로 블러·스케일을 매번
// 다시 계산해 뷰포트에 걸친 정도만큼 또렷해지고, 기준을 넘으면 로드 완료로 고정한다.
const INTERSECTION_THRESHOLD = 0.4;
const STEPS: number[] = [];
for (let s = 0; s <= 20; s += 1) { STEPS.push(s / 20); }
const CARD_KEYS = [1, 2, 3, 4];
type Box<T> = { current: T };
function useBox<T>(initial: T): Box<T> {
return useRef(initial) as unknown as Box<T>;
}
export default function ImageFadeInViewport() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const cardRefs = useBox<Array<HTMLDivElement | null>>([null, null, null, null]);
const imgRefs = useBox<Array<HTMLDivElement | null>>([null, null, null, null]);
const placeholderRefs = useBox<Array<HTMLDivElement | null>>([null, null, null, null]);
const loadedRefs = useBox<boolean[]>([false, false, false, false]);
const [badge, setBadge] = useState("교차 비율 0%");
const [demo, setDemo] = useState(true);
const [, forceRender] = useState(0);
function applyRatio(i: number, ratio: number) {
const img = imgRefs.current[i];
const placeholder = placeholderRefs.current[i];
if (!img || !placeholder) { return; }
const blur = 10 * (1 - ratio);
const scale = 0.92 + 0.08 * ratio;
img.style.opacity = String(ratio);
img.style.filter = "blur(" + blur.toFixed(1) + "px)";
img.style.transform = "scale(" + scale.toFixed(3) + ")";
placeholder.style.opacity = String(Math.max(0, 1 - ratio * 1.4));
if (ratio >= INTERSECTION_THRESHOLD && !loadedRefs.current[i]) {
loadedRefs.current[i] = true;
forceRender((n) => n + 1);
}
}
useEffect(() => {
const view = viewRef.current;
const root = rootRef.current;
if (!view || !root) { return; }
const io = new IntersectionObserver((entries) => {
let maxRatio = 0;
entries.forEach((entry) => {
const idx = cardRefs.current.indexOf(entry.target as HTMLDivElement);
if (idx === -1) { return; }
applyRatio(idx, entry.intersectionRatio);
if (entry.intersectionRatio >= maxRatio) { maxRatio = entry.intersectionRatio; }
});
setBadge("교차 비율 " + Math.round(maxRatio * 100) + "%");
}, { root: view, threshold: STEPS });
cardRefs.current.forEach((c) => { if (c) { io.observe(c); } });
function userTakesOver() {
if (!root!.classList.contains(styles["is-demo"])) { return; }
setDemo(false);
if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); }
}
view.addEventListener("wheel", userTakesOver, { passive: true });
view.addEventListener("touchstart", userTakesOver, { passive: true });
let dir = 1;
let autoplayTimer: number | null = null;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setDemo(false);
CARD_KEYS.forEach((_, i) => applyRatio(i, 1));
setBadge("교차 비율 100%");
} else {
autoplayTimer = window.setInterval(() => {
const max = view.scrollHeight - view.clientHeight;
view.scrollTop += dir * 2.2;
if (view.scrollTop >= max - 1) { dir = -1; }
if (view.scrollTop <= 1) { dir = 1; }
}, 45);
}
return () => {
io.disconnect();
view.removeEventListener("wheel", userTakesOver);
view.removeEventListener("touchstart", userTakesOver);
if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.lfv} ${demo ? styles["is-demo"] : ""}`}>
<div className={styles["lfv__badge"]}>{badge}</div>
<div ref={viewRef} className={styles["lfv__view"]}>
<div className={styles["lfv__track"]}>
{CARD_KEYS.map((k, i) => (
<div
key={k}
ref={(el: HTMLDivElement | null) => { cardRefs.current[i] = el; }}
className={`${styles["lfv__card"]} ${loadedRefs.current[i] ? styles["lfv__card--loaded"] : ""}`}
>
<div ref={(el: HTMLDivElement | null) => { placeholderRefs.current[i] = el; }} className={styles["lfv__placeholder"]} />
<div ref={(el: HTMLDivElement | null) => { imgRefs.current[i] = el; }} className={`${styles["lfv__img"]} ${styles["lfv__img--" + k]}`} />
</div>
))}
</div>
</div>
</div>
</main>
);
}03 · スクロール位置の値が交差比率を計算する画像フェードイン — 計測 累積面積 36.04% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var INTERSECTION_THRESHOLD = 0.4;
var STEPS = [];
for (var s = 0; s <= 20; s += 1) { STEPS.push(s / 20); }
// 計算レイヤー: 交差比率の値から観測のたびにブラー半径・スケールを再計算し、
// ビューポートと重なった分だけ画像がくっきりする。比率が基準を超えると読み込み完了として固定する。
function applyRatio(card, ratio) {
var img = card.querySelector('.lfv__img');
var placeholder = card.querySelector('.lfv__placeholder');
var blur = 10 * (1 - ratio);
var scale = 0.92 + 0.08 * ratio;
img.style.opacity = String(ratio);
img.style.filter = 'blur(' + blur.toFixed(1) + 'px)';
img.style.transform = 'scale(' + scale.toFixed(3) + ')';
placeholder.style.opacity = String(Math.max(0, 1 - ratio * 1.4));
if (ratio >= INTERSECTION_THRESHOLD) {
card.classList.add('lfv__card--loaded');
}
if (card === cards[Math.min(cards.length - 1, Math.round(lastMaxIndex))]) {
badge.textContent = '交差率 ' + Math.round(ratio * 100) + '%';
}
}
04名前の値がイニシャルを計算するアバターの遅延読み込み
入力した名前の値からイニシャル2文字を計算して丸いバッジでまず表示し、プロフィール写真が揃うとその場所を実際の写真に切り替えます。getInitials が先頭2文字を切り出し、別の関数が名前の文字コードの合計を PALETTE の色数で割った余りからバッジの色をハッシュで選ぶので、同じ名前は常に同じ色になります。実際のコードでは、プロフィール写真の取得が完了した瞬間にバッジを <img> に置き換えることになります。
<main class="stage">
<div class="lai is-demo" id="lai">
<ul class="lai__list" id="laiList"></ul>
<button type="button" class="lai__btn" id="laiBtn">写真を読み込む</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 이름 값 → 이니셜 계산(자리표시자) + 글자코드 합 해시(배지 색) + 행 인덱스 값(등장 지연).
// 세 계산 겹이 함께 실제 지연 로딩의 "먼저 보여주고 나중에 바뀐다"를 만든다.
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-pink;
@include stage($stage-paper);
.lai {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(100%, 240px);
}
.lai__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
width: 100%;
}
.lai__row {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .04);
}
.lai__avatar {
position: relative;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
overflow: hidden;
}
.lai__row--a { background: rgba(255, 158, 187, .3); color: darken($subject-pink, 25%); }
.lai__row--b { background: rgba(76, 212, 166, .3); color: darken($subject-mint, 30%); }
.lai__row--c { background: rgba(185, 160, 255, .3); color: darken($subject-lilac, 28%); }
.lai__row--d { background: rgba(110, 200, 255, .3); color: darken($subject-sky, 30%); }
.lai__initials {
font-weight: 700;
font-size: 13px;
transition: opacity $dur-quick $ease-out;
}
.lai__photo {
position: absolute;
inset: 0;
border-radius: 50%;
opacity: 0;
transform: scale(.6);
transition: transform $dur-enter $easing, opacity $dur-base $ease-out;
background: conic-gradient(from 120deg, $subject-blue, $subject-mint, $subject-pink, $subject-blue);
}
.lai__avatar--loaded .lai__initials { opacity: 0; }
.lai__avatar--loaded .lai__photo { opacity: 1; transform: scale(1); }
.lai__name {
font-size: 13px;
font-weight: 600;
color: $stage-ink;
}
.lai__btn {
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $color;
color: $stage-ink;
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
@media (max-width: 340px) {
.lai__avatar { width: 28px; height: 28px; }
.lai__row { padding: $sp-1 $sp-2; gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.lai__photo, .lai__initials { 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);
}
.lai {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(100%, 240px);
}
.lai__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.lai__row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.04);
}
.lai__avatar {
position: relative;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
overflow: hidden;
}
.lai__row--a {
background: rgba(255, 158, 187, 0.3);
color: rgb(100%, 11.9607843137%, 38.2817869416%);
}
.lai__row--b {
background: rgba(76, 212, 166, 0.3);
color: rgb(10.2543720191%, 42.6868044515%, 31.7170111288%);
}
.lai__row--c {
background: rgba(185, 160, 255, 0.3);
color: rgb(31.2858617131%, 6.7450980392%, 100%);
}
.lai__row--d {
background: rgba(110, 200, 255, 0.3);
color: rgb(0%, 51.6024340771%, 83.137254902%);
}
.lai__initials {
font-weight: 700;
font-size: 13px;
transition: opacity 220ms ease-out;
}
.lai__photo {
position: absolute;
inset: 0;
border-radius: 50%;
opacity: 0;
transform: scale(0.6);
transition: transform 600ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms ease-out;
background: conic-gradient(from 120deg, #2f6df6, #4cd4a6, #ff9ebb, #2f6df6);
}
.lai__avatar--loaded .lai__initials {
opacity: 0;
}
.lai__avatar--loaded .lai__photo {
opacity: 1;
transform: scale(1);
}
.lai__name {
font-size: 13px;
font-weight: 600;
color: #17141a;
}
.lai__btn {
border: none;
border-radius: 999px;
padding: 4px 12px;
background: #ff9ebb;
color: #17141a;
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
@media (max-width: 340px) {
.lai__avatar {
width: 28px;
height: 28px;
}
.lai__row {
padding: 4px 8px;
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.lai__photo, .lai__initials {
transition: none !important;
}
}(function () {
var root = document.getElementById('lai');
var list = document.getElementById('laiList');
var btn = document.getElementById('laiBtn');
var NAMES = ['キム・ハヌル', 'イ・ドユン', 'パク・ソジュン'];
var PALETTE = ['lai__row--a', 'lai__row--b', 'lai__row--c', 'lai__row--d'];
var ROW_STAGGER_MS = 260;
var LOAD_BASE_MS = 320;
var CYCLE_GAP_MS = 2400;
// 계산 겹 1: 이름 문자열에서 이니셜 두 글자를 잘라낸다.
function getInitials(name) { return name.slice(0, 2); }
// 계산 겹 2: 이름 글자 코드값을 모두 더한 뒤 팔레트 길이로 나눈 나머지로 배지 색을 고른다(해시).
function colorHashClass(name) {
var sum = 0;
for (var i = 0; i < name.length; i += 1) { sum += name.charCodeAt(i); }
return PALETTE[sum % PALETTE.length];
}
var rows = NAMES.map(function (name, i) {
var li = document.createElement('li');
li.className = 'lai__row';
li.innerHTML =
'<span class="lai__avatar ' + colorHashClass(name) + '" data-idx="' + i + '">' +
'<span class="lai__initials">' + getInitials(name) + '</span>' +
'<span class="lai__photo"></span>' +
'</span>' +
'<span class="lai__name">' + name + '</span>';
list.appendChild(li);
return li;
});
function reset() {
rows.forEach(function (li) { li.querySelector('.lai__avatar').classList.remove('lai__avatar--loaded'); });
}
// 계산 겹 3: 목록 위치(행 인덱스) 값으로 등장 지연 시간을 계산해 순서대로 사진이 나타난다.
var timers = [];
function loadPhotos() {
reset();
rows.forEach(function (li, i) {
var t = setTimeout(function () {
li.querySelector('.lai__avatar').classList.add('lai__avatar--loaded');
}, LOAD_BASE_MS + i * ROW_STAGGER_MS);
timers.push(t);
});
}
function clearTimers() { timers.forEach(clearTimeout); timers = []; }
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
clearTimers();
loadPhotos();
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var autoplayTimer = null;
if (reduce) {
root.classList.remove('is-demo');
rows.forEach(function (li) { li.querySelector('.lai__avatar').classList.add('lai__avatar--loaded'); });
} else {
(function loop() {
clearTimers();
loadPhotos();
autoplayTimer = setTimeout(function () {
if (!root.classList.contains('is-demo')) { return; }
reset();
setTimeout(function () {
if (root.classList.contains('is-demo')) { loop(); }
}, 500);
}, LOAD_BASE_MS + rows.length * ROW_STAGGER_MS + CYCLE_GAP_MS);
})();
}
})();import { useEffect, useRef, useState, useCallback } from "react";
import styles from "./AvatarInitialsFallback.module.scss";
// 이름 값 → 이니셜 계산(자리표시자) + 글자코드 합 해시(배지 색) + 행 인덱스 값(등장 지연).
const NAMES = ["김하늘", "이도윤", "박서준"];
const PALETTE = ["lai__row--a", "lai__row--b", "lai__row--c", "lai__row--d"];
const ROW_STAGGER_MS = 260;
const LOAD_BASE_MS = 320;
const CYCLE_GAP_MS = 2400;
function getInitials(name: string): string {
return name.slice(0, 2);
}
function colorHashClass(name: string): string {
let sum = 0;
for (let i = 0; i < name.length; i += 1) { sum += name.charCodeAt(i); }
return PALETTE[sum % PALETTE.length];
}
type Box<T> = { current: T };
function useBox<T>(initial: T): Box<T> {
return useRef(initial) as unknown as Box<T>;
}
export default function AvatarInitialsFallback() {
const rootRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [loaded, setLoaded] = useState<boolean[]>(NAMES.map(() => false));
const timersRef = useBox<number[]>([]);
const loopTimerRef = useRef<number | null>(null);
const clearTimers = useCallback(() => {
timersRef.current.forEach((t) => window.clearTimeout(t));
timersRef.current = [];
}, [timersRef]);
const loadPhotos = useCallback(() => {
setLoaded(NAMES.map(() => false));
NAMES.forEach((_, i) => {
const t = window.setTimeout(() => {
setLoaded((prev) => prev.map((v, j) => (j === i ? true : v)));
}, LOAD_BASE_MS + i * ROW_STAGGER_MS);
timersRef.current.push(t);
});
}, [timersRef]);
const onClickLoad = useCallback(() => {
setDemo(false);
clearTimers();
loadPhotos();
}, [clearTimers, loadPhotos]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setDemo(false);
setLoaded(NAMES.map(() => true));
return;
}
let cancelled = false;
function loop() {
if (cancelled) { return; }
clearTimers();
loadPhotos();
loopTimerRef.current = window.setTimeout(() => {
if (cancelled || !rootRef.current?.classList.contains(styles["is-demo"])) { return; }
setLoaded(NAMES.map(() => false));
loopTimerRef.current = window.setTimeout(() => {
if (!cancelled && rootRef.current?.classList.contains(styles["is-demo"])) { loop(); }
}, 500);
}, LOAD_BASE_MS + NAMES.length * ROW_STAGGER_MS + CYCLE_GAP_MS);
}
loop();
return () => {
cancelled = true;
clearTimers();
if (loopTimerRef.current !== null) { window.clearTimeout(loopTimerRef.current); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.lai} ${demo ? styles["is-demo"] : ""}`}>
<ul className={styles["lai__list"]}>
{NAMES.map((name, i) => (
<li key={name} className={styles["lai__row"]}>
<span className={`${styles["lai__avatar"]} ${styles[colorHashClass(name)]} ${loaded[i] ? styles["lai__avatar--loaded"] : ""}`}>
<span className={styles["lai__initials"]}>{getInitials(name)}</span>
<span className={styles["lai__photo"]} />
</span>
<span className={styles["lai__name"]}>{name}</span>
</li>
))}
</ul>
<button type="button" className={styles["lai__btn"]} onClick={onClickLoad}>사진 불러오기</button>
</div>
</main>
);
}04 · 名前の値がイニシャルを計算するアバターの遅延読み込み — 計測 累積面積 1.731% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var btn = document.getElementById('laiBtn');
var NAMES = ['キム・ハヌル', 'イ・ドユン', 'パク・ソジュン'];
var PALETTE = ['lai__row--a', 'lai__row--b', 'lai__row--c', 'lai__row--d'];
var ROW_STAGGER_MS = 260;
var LOAD_BASE_MS = 320;
var CYCLE_GAP_MS = 2400;
// 計算レイヤー1: 名前の文字列から先頭2文字をイニシャルとして切り出す。
function getInitials(name) { return name.slice(0, 2); }
// 計算レイヤー2: 名前の文字コードをすべて足し、その合計をパレットの長さで割った余りでバッジの色を選ぶ(ハッシュ)。
function colorHashClass(name) {
var sum = 0;
for (var i = 0; i < name.length; i += 1) { sum += name.charCodeAt(i); }
return PALETTE[sum % PALETTE.length];
}
05格子位置の値が順番を計算するギャラリーの遅延読み込み
サムネイルの格子位置の値から表示遅延を計算し、左上から順に時間差を付けてプレースホルダーが実際の写真に切り替わります。delayFor はセルの行と列の値からマンハッタン距離の遅延を求めるため、表示は行ごとではなく斜めに扇状へ広がります。COLS が3で固定されているので、右下のセルだけが STAGGER_STEP_MS の4倍分、最後まで待つことになります。

<main class="stage">
<div class="lgs is-demo" id="lgs">
<div class="lgs__badge" id="lgsBadge">読み込み中</div>
<div class="lgs__grid" id="lgsGrid"></div>
<p class="lgs__hint">クリックで再読み込み</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 격자 위치(행+열) 값으로 지연 시간을 계산하는 맨해튼 거리 스태거.
// 왼쪽 위 칸이 가장 먼저, 오른쪽 아래로 갈수록 늦게 실제 사진으로 바뀐다.
$duration: $dur-enter;
$easing: $ease-elastic;
$color: $subject-lilac;
@include stage($stage-ink);
.lgs {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
outline: none;
cursor: pointer;
}
.lgs__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(185, 160, 255, .18);
color: $color;
font-weight: 700;
font-size: 11px;
}
.lgs__grid {
display: grid;
grid-template-columns: repeat(3, 70px);
grid-template-rows: repeat(2, 70px);
gap: $sp-2;
}
.lgs__cell {
position: relative;
border-radius: $r-sm;
overflow: hidden;
}
.lgs__placeholder {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, .1);
transition: opacity $dur-quick $ease-out;
}
.lgs__photo {
position: absolute;
inset: 0;
opacity: 0;
transform: scale(.7);
transform-origin: center;
transition: transform $duration $easing, opacity $dur-base $ease-out;
}
.lgs__photo--0 { background: linear-gradient(135deg, $subject-lilac, $subject-blue); }
.lgs__photo--1 { background: linear-gradient(135deg, $subject-pink, $subject-lilac); }
.lgs__photo--2 { background: linear-gradient(135deg, $subject-mint, $subject-sky); }
.lgs__photo--3 { background: linear-gradient(135deg, $subject-sky, $subject-blue-light); }
.lgs__cell--loaded .lgs__placeholder { opacity: 0; }
.lgs__cell--loaded .lgs__photo { opacity: 1; transform: scale(1); }
.lgs__hint {
margin: 0;
color: rgba(255, 255, 255, .5);
font-size: 11px;
}
@media (max-width: 340px) {
.lgs { gap: $sp-2; }
.lgs__grid { grid-template-columns: repeat(3, 48px); grid-template-rows: repeat(2, 48px); gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.lgs__placeholder, .lgs__photo { 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);
}
.lgs {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
outline: none;
cursor: pointer;
}
.lgs__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(185, 160, 255, 0.18);
color: #b9a0ff;
font-weight: 700;
font-size: 11px;
}
.lgs__grid {
display: grid;
grid-template-columns: repeat(3, 70px);
grid-template-rows: repeat(2, 70px);
gap: 8px;
}
.lgs__cell {
position: relative;
border-radius: 8px;
overflow: hidden;
}
.lgs__placeholder {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.1);
transition: opacity 220ms ease-out;
}
.lgs__photo {
position: absolute;
inset: 0;
opacity: 0;
transform: scale(0.7);
transform-origin: center;
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 300ms ease-out;
}
.lgs__photo--0 {
background: linear-gradient(135deg, #b9a0ff, #2f6df6);
}
.lgs__photo--1 {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
}
.lgs__photo--2 {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.lgs__photo--3 {
background: linear-gradient(135deg, #6ec8ff, #5b8cf8);
}
.lgs__cell--loaded .lgs__placeholder {
opacity: 0;
}
.lgs__cell--loaded .lgs__photo {
opacity: 1;
transform: scale(1);
}
.lgs__hint {
margin: 0;
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
}
@media (max-width: 340px) {
.lgs {
gap: 8px;
}
.lgs__grid {
grid-template-columns: repeat(3, 48px);
grid-template-rows: repeat(2, 48px);
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.lgs__placeholder, .lgs__photo {
transition: none !important;
}
}(function () {
var root = document.getElementById('lgs');
var grid = document.getElementById('lgsGrid');
var badge = document.getElementById('lgsBadge');
var COLS = 3;
var ROWS = 2;
var STAGGER_STEP_MS = 90;
var START_DELAY_MS = 200;
var CYCLE_GAP_MS = 1600;
var cells = [];
for (var i = 0; i < COLS * ROWS; i += 1) {
var cell = document.createElement('div');
cell.className = 'lgs__cell';
cell.innerHTML = '<div class="lgs__placeholder"></div><div class="lgs__photo lgs__photo--' + (i % 4) + '"></div>';
grid.appendChild(cell);
cells.push(cell);
}
// 계산 겹: 격자 위치(행 값 + 열 값)로 지연 시간을 계산한다 — 맨해튼 거리 스태거.
// 왼쪽 위(행0·열0)가 가장 먼저, 오른쪽 아래로 갈수록 늦게 실제 사진으로 바뀐다.
function delayFor(idx) {
var row = Math.floor(idx / COLS);
var col = idx % COLS;
return START_DELAY_MS + (row + col) * STAGGER_STEP_MS;
}
var timers = [];
function clearTimers() { timers.forEach(clearTimeout); timers = []; }
function loadAll() {
cells.forEach(function (cell) { cell.classList.remove('lgs__cell--loaded'); });
badge.textContent = '読み込み中';
var maxDelay = 0;
cells.forEach(function (cell, idx) {
var d = delayFor(idx);
maxDelay = Math.max(maxDelay, d);
var t = setTimeout(function () { cell.classList.add('lgs__cell--loaded'); }, d);
timers.push(t);
});
var t2 = setTimeout(function () { badge.textContent = 'すべて読み込み完了'; }, maxDelay + 120);
timers.push(t2);
return maxDelay + 120;
}
root.addEventListener('click', function () {
root.classList.remove('is-demo');
clearTimers();
loadAll();
});
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); root.click(); }
});
root.tabIndex = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
cells.forEach(function (cell) { cell.classList.add('lgs__cell--loaded'); });
badge.textContent = 'すべて読み込み完了';
} else {
(function loop() {
clearTimers();
var total = loadAll();
setTimeout(function () {
if (!root.classList.contains('is-demo')) { return; }
setTimeout(function () {
if (root.classList.contains('is-demo')) { loop(); }
}, 500);
}, total + CYCLE_GAP_MS);
})();
}
})();import { useEffect, useRef, useState, useCallback } from "react";
import styles from "./GalleryGridStaggerReveal.module.scss";
// 격자 위치(행+열) 값으로 지연 시간을 계산하는 맨해튼 거리 스태거.
const COLS = 3;
const ROWS = 2;
const STAGGER_STEP_MS = 90;
const START_DELAY_MS = 200;
const CYCLE_GAP_MS = 1600;
const CELL_COUNT = COLS * ROWS;
function delayFor(idx: number): number {
const row = Math.floor(idx / COLS);
const col = idx % COLS;
return START_DELAY_MS + (row + col) * STAGGER_STEP_MS;
}
type Box<T> = { current: T };
function useBox<T>(initial: T): Box<T> {
return useRef(initial) as unknown as Box<T>;
}
export default function GalleryGridStaggerReveal() {
const rootRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [loaded, setLoaded] = useState<boolean[]>(Array(CELL_COUNT).fill(false));
const [badge, setBadge] = useState("불러오는 중");
const timersRef = useBox<number[]>([]);
const clearTimers = useCallback(() => {
timersRef.current.forEach((t) => window.clearTimeout(t));
timersRef.current = [];
}, [timersRef]);
const loadAll = useCallback((): number => {
setLoaded(Array(CELL_COUNT).fill(false));
setBadge("불러오는 중");
let maxDelay = 0;
for (let idx = 0; idx < CELL_COUNT; idx += 1) {
const d = delayFor(idx);
maxDelay = Math.max(maxDelay, d);
const t = window.setTimeout(() => {
setLoaded((prev) => prev.map((v, j) => (j === idx ? true : v)));
}, d);
timersRef.current.push(t);
}
const t2 = window.setTimeout(() => setBadge("전부 불러옴"), maxDelay + 120);
timersRef.current.push(t2);
return maxDelay + 120;
}, [timersRef]);
const onActivate = useCallback(() => {
setDemo(false);
clearTimers();
loadAll();
}, [clearTimers, loadAll]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setDemo(false);
setLoaded(Array(CELL_COUNT).fill(true));
setBadge("전부 불러옴");
return;
}
let cancelled = false;
let outer: number | null = null;
let inner: number | null = null;
function loop() {
if (cancelled) { return; }
clearTimers();
const total = loadAll();
outer = window.setTimeout(() => {
if (cancelled || !rootRef.current?.classList.contains(styles["is-demo"])) { return; }
inner = window.setTimeout(() => {
if (!cancelled && rootRef.current?.classList.contains(styles["is-demo"])) { loop(); }
}, 500);
}, total + CYCLE_GAP_MS);
}
loop();
return () => {
cancelled = true;
clearTimers();
if (outer !== null) { window.clearTimeout(outer); }
if (inner !== null) { window.clearTimeout(inner); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const root = rootRef.current;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onActivate(); }
}
root?.addEventListener("keydown", onKeyDown);
return () => root?.removeEventListener("keydown", onKeyDown);
}, [onActivate]);
return (
<main className={styles.stage}>
<div
ref={rootRef}
className={`${styles.lgs} ${demo ? styles["is-demo"] : ""}`}
tabIndex={0}
onClick={onActivate}
>
<div className={styles["lgs__badge"]}>{badge}</div>
<div className={styles["lgs__grid"]}>
{Array.from({ length: CELL_COUNT }).map((_, idx) => (
<div key={idx} className={`${styles["lgs__cell"]} ${loaded[idx] ? styles["lgs__cell--loaded"] : ""}`}>
<div className={styles["lgs__placeholder"]} />
<div className={`${styles["lgs__photo"]} ${styles["lgs__photo--" + (idx % 4)]}`} />
</div>
))}
</div>
<p className={styles["lgs__hint"]}>클릭하면 다시 불러옵니다</p>
</div>
</main>
);
}05 · 格子位置の値が順番を計算するギャラリーの遅延読み込み — 計測 累積面積 20.573% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var COLS = 3;
var ROWS = 2;
var STAGGER_STEP_MS = 90;
var START_DELAY_MS = 200;
var CYCLE_GAP_MS = 1600;
var cells = [];
for (var i = 0; i < COLS * ROWS; i += 1) {
var cell = document.createElement('div');
cell.className = 'lgs__cell';
cell.innerHTML = '<div class="lgs__placeholder"></div><div class="lgs__photo lgs__photo--' + (i % 4) + '"></div>';
grid.appendChild(cell);
cells.push(cell);
}
// 計算レイヤー: 格子位置(行の値+列の値)から遅延時間を計算する — マンハッタン距離のスタガー。
// 左上(行0・列0)が最初に、右下に向かうほど遅く実際の写真に切り替わる。
function delayFor(idx) {
var row = Math.floor(idx / COLS);
var col = idx % COLS;
return START_DELAY_MS + (row + col) * STAGGER_STEP_MS;
}
06再生の値が判定する動画ポスターの遅延読み込み
再生が始まったかどうかの値を計算して再生前はポスター画像だけを表示し、値が変わった瞬間に実際の動画フレームへ引き渡します。runBuffering はパターン02と同じ進捗率のループですが、ブラーではなく conic-gradient の角度に値を書き込むため、再生開始前にリングが目に見える形で一周し終えます。背後に本物の <video> や配信は一切なく、実際にはバッファリングの完了を待ってから要素を差し込む部分です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
07クリックの値が読み込み時点を計算するiframeの遅延読み込み
クリックの有無の値を計算してクリック前は地図・SNS埋め込みの場所に静的な案内だけを置き、クリックした瞬間だけその場所を実際のiframeに変えます。maxDistanceToCorner はクリック地点からフレームの四隅までの距離をピタゴラスの定理で求めて最大値を取るので、続くリップルはどこをクリックしてもフレーム全体を覆い切って終わります。実際に差し込む先は本物の <iframe src> で、このクリックが起きるまでは一切読み込まれません。

<main class="stage">
<div class="lic is-demo" id="lic">
<div class="lic__frame" id="licFrame" tabindex="0" role="button" aria-label="地図埋め込みを読み込む">
<div class="lic__notice" id="licNotice">
<span class="lic__pin">📍</span>
<p class="lic__text">クリックで地図を読み込みます</p>
</div>
<svg class="lic__ripple" id="licRipple" aria-hidden="true">
<circle id="licRippleCircle" cx="0" cy="0" r="0"></circle>
</svg>
<div class="lic__embed" id="licEmbed">
<svg viewBox="0 0 220 140" width="100%" height="100%" preserveAspectRatio="none">
<rect width="220" height="140" fill="#d8ead8"></rect>
<path d="M0 90 L60 70 L120 96 L160 60 L220 84 L220 140 L0 140 Z" fill="#bfe0c4"></path>
<path d="M0 40 L220 55" stroke="#f4d878" stroke-width="6"></path>
<path d="M40 0 L70 140" stroke="#f4d878" stroke-width="5"></path>
<circle cx="118" cy="66" r="7" fill="#e6564a"></circle>
<circle cx="118" cy="66" r="3" fill="#fff"></circle>
</svg>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 클릭 지점에서 프레임 모서리까지의 최대 거리 값을 리플 반지름 목표로 계산한다.
// 클릭 전에는 정적 안내만, 클릭한 순간에만 실제(가짜) 임베드로 전환한다.
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-ink);
.lic {
display: flex;
align-items: center;
justify-content: center;
}
.lic__frame {
position: relative;
box-sizing: border-box;
width: 220px;
height: 140px;
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
cursor: pointer;
outline: none;
box-shadow: $shadow-raised;
}
.lic__frame:focus-visible {
box-shadow: 0 0 0 3px rgba(76, 212, 166, .5);
}
.lic__notice {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-2;
background: $stage-paper;
transition: opacity $dur-quick $ease-out;
}
.lic__pin { font-size: 22px; }
.lic__text {
margin: 0;
font-size: 11px;
color: $stage-ink;
opacity: .7;
}
.lic__ripple {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
}
.lic__ripple--active { opacity: 1; }
.lic__ripple circle {
fill: rgba(76, 212, 166, .28);
}
.lic__embed {
position: absolute;
inset: 0;
opacity: 0;
}
.lic__frame--loading .lic__notice { opacity: .35; }
.lic__frame--loaded .lic__notice { opacity: 0; pointer-events: none; }
.lic__frame--loaded .lic__embed {
opacity: 1;
transition: opacity $duration $easing;
}
@media (max-width: 340px) {
.lic__frame { width: 170px; height: 110px; }
}
@media (prefers-reduced-motion: reduce) {
.lic__notice, .lic__embed { transition: none !important; }
.lic__ripple { display: 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);
}
.lic {
display: flex;
align-items: center;
justify-content: center;
}
.lic__frame {
position: relative;
box-sizing: border-box;
width: 220px;
height: 140px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
cursor: pointer;
outline: none;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lic__frame:focus-visible {
box-shadow: 0 0 0 3px rgba(76, 212, 166, 0.5);
}
.lic__notice {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: #ffffff;
transition: opacity 220ms ease-out;
}
.lic__pin {
font-size: 22px;
}
.lic__text {
margin: 0;
font-size: 11px;
color: #17141a;
opacity: 0.7;
}
.lic__ripple {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
}
.lic__ripple--active {
opacity: 1;
}
.lic__ripple circle {
fill: rgba(76, 212, 166, 0.28);
}
.lic__embed {
position: absolute;
inset: 0;
opacity: 0;
}
.lic__frame--loading .lic__notice {
opacity: 0.35;
}
.lic__frame--loaded .lic__notice {
opacity: 0;
pointer-events: none;
}
.lic__frame--loaded .lic__embed {
opacity: 1;
transition: opacity 600ms ease-out;
}
@media (max-width: 340px) {
.lic__frame {
width: 170px;
height: 110px;
}
}
@media (prefers-reduced-motion: reduce) {
.lic__notice, .lic__embed {
transition: none !important;
}
.lic__ripple {
display: none;
}
}(function () {
var root = document.getElementById('lic');
var frame = document.getElementById('licFrame');
var ripple = document.getElementById('licRipple');
var rippleCircle = document.getElementById('licRippleCircle');
var RIPPLE_DURATION_MS = 520;
var LOAD_DELAY_MS = 340;
var CYCLE_GAP_MS = 2400;
var loaded = false;
// 계산 겹: 클릭 지점에서 프레임 네 모서리까지의 최대 거리(피타고라스) 를
// 리플 반지름 목표값으로 계산해, 실제 지점에서 시작하는 원이 프레임을 덮는다.
function maxDistanceToCorner(x, y, w, h) {
var dTL = Math.sqrt(x * x + y * y);
var dTR = Math.sqrt((w - x) * (w - x) + y * y);
var dBL = Math.sqrt(x * x + (h - y) * (h - y));
var dBR = Math.sqrt((w - x) * (w - x) + (h - y) * (h - y));
return Math.max(dTL, dTR, dBL, dBR);
}
function runRipple(x, y, onDone) {
var rect = frame.getBoundingClientRect();
var targetR = maxDistanceToCorner(x, y, rect.width, rect.height);
rippleCircle.setAttribute('cx', String(x));
rippleCircle.setAttribute('cy', String(y));
rippleCircle.setAttribute('r', '0');
ripple.classList.add('lic__ripple--active');
var start = performance.now();
function step(now) {
var progress = Math.min((now - start) / RIPPLE_DURATION_MS, 1);
rippleCircle.setAttribute('r', String(targetR * progress));
if (progress < 1) {
requestAnimationFrame(step);
} else {
ripple.classList.remove('lic__ripple--active');
onDone();
}
}
requestAnimationFrame(step);
}
function loadEmbed(x, y) {
if (loaded) { return; }
loaded = true;
frame.classList.add('lic__frame--loading');
runRipple(x, y, function () {
setTimeout(function () {
frame.classList.remove('lic__frame--loading');
frame.classList.add('lic__frame--loaded');
frame.setAttribute('aria-label', '地図埋め込み読み込み済み');
}, LOAD_DELAY_MS);
});
}
function resetEmbed() {
loaded = false;
frame.classList.remove('lic__frame--loading', 'lic__frame--loaded');
frame.setAttribute('aria-label', '地図埋め込みを読み込む');
}
frame.addEventListener('click', function (e) {
root.classList.remove('is-demo');
if (loaded) { return; }
var rect = frame.getBoundingClientRect();
loadEmbed(e.clientX - rect.left, e.clientY - rect.top);
});
frame.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
root.classList.remove('is-demo');
if (loaded) { return; }
var rect = frame.getBoundingClientRect();
loadEmbed(rect.width / 2, rect.height / 2);
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
loaded = true;
frame.classList.add('lic__frame--loaded');
frame.setAttribute('aria-label', '地図埋め込み読み込み済み');
} else {
(function loop() {
resetEmbed();
setTimeout(function () {
if (!root.classList.contains('is-demo')) { return; }
loadEmbed(110, 70);
setTimeout(function () {
if (root.classList.contains('is-demo')) { loop(); }
}, CYCLE_GAP_MS);
}, 700);
})();
}
})();import { useEffect, useRef, useCallback, useState } from "react";
import styles from "./IframeClickToLoad.module.scss";
// 클릭 지점에서 프레임 네 모서리까지의 최대 거리(피타고라스) 를 리플 반지름
// 목표값으로 계산해, 실제 클릭 지점에서 시작하는 원이 프레임을 덮는다.
const RIPPLE_DURATION_MS = 520;
const LOAD_DELAY_MS = 340;
const CYCLE_GAP_MS = 2400;
type Box<T> = { current: T };
function useBox<T>(initial: T): Box<T> {
return useRef(initial) as unknown as Box<T>;
}
function maxDistanceToCorner(x: number, y: number, w: number, h: number): number {
const dTL = Math.sqrt(x * x + y * y);
const dTR = Math.sqrt((w - x) * (w - x) + y * y);
const dBL = Math.sqrt(x * x + (h - y) * (h - y));
const dBR = Math.sqrt((w - x) * (w - x) + (h - y) * (h - y));
return Math.max(dTL, dTR, dBL, dBR);
}
export default function IframeClickToLoad() {
const rootRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [rippleActive, setRippleActive] = useState(false);
const [circle, setCircle] = useState({ cx: 0, cy: 0, r: 0 });
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const loadedRef = useBox<boolean>(false);
const runRipple = useCallback((x: number, y: number, onDone: () => void) => {
const rect = frameRef.current?.getBoundingClientRect();
const w = rect ? rect.width : 220;
const h = rect ? rect.height : 140;
const targetR = maxDistanceToCorner(x, y, w, h);
setCircle({ cx: x, cy: y, r: 0 });
setRippleActive(true);
const start = performance.now();
function step(now: number) {
const progress = Math.min((now - start) / RIPPLE_DURATION_MS, 1);
setCircle({ cx: x, cy: y, r: targetR * progress });
if (progress < 1) {
requestAnimationFrame(step);
} else {
setRippleActive(false);
onDone();
}
}
requestAnimationFrame(step);
}, []);
const loadEmbed = useCallback((x: number, y: number) => {
if (loadedRef.current) { return; }
loadedRef.current = true;
setLoaded(false);
setLoading(true);
runRipple(x, y, () => {
window.setTimeout(() => {
setLoading(false);
setLoaded(true);
}, LOAD_DELAY_MS);
});
}, [loadedRef, runRipple]);
const resetEmbed = useCallback(() => {
loadedRef.current = false;
setLoading(false);
setLoaded(false);
}, [loadedRef]);
const onClick = useCallback((e: { clientX: number; clientY: number }) => {
setDemo(false);
if (loadedRef.current) { return; }
const rect = frameRef.current?.getBoundingClientRect();
const x = rect ? e.clientX - rect.left : 110;
const y = rect ? e.clientY - rect.top : 70;
loadEmbed(x, y);
}, [loadedRef, loadEmbed]);
useEffect(() => {
const frame = frameRef.current;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setDemo(false);
if (loadedRef.current) { return; }
const rect = frame?.getBoundingClientRect();
loadEmbed(rect ? rect.width / 2 : 110, rect ? rect.height / 2 : 70);
}
}
frame?.addEventListener("keydown", onKeyDown);
return () => frame?.removeEventListener("keydown", onKeyDown);
}, [loadedRef, loadEmbed]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setDemo(false);
loadedRef.current = true;
setLoaded(true);
return;
}
let cancelled = false;
let t1: number | null = null;
let t2: number | null = null;
function loop() {
if (cancelled) { return; }
resetEmbed();
t1 = window.setTimeout(() => {
if (cancelled || !rootRef.current?.classList.contains(styles["is-demo"])) { return; }
loadEmbed(110, 70);
t2 = window.setTimeout(() => {
if (!cancelled && rootRef.current?.classList.contains(styles["is-demo"])) { loop(); }
}, CYCLE_GAP_MS);
}, 700);
}
loop();
return () => {
cancelled = true;
if (t1 !== null) { window.clearTimeout(t1); }
if (t2 !== null) { window.clearTimeout(t2); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const frameClass = [
styles.lic__frame,
loading ? styles["lic__frame--loading"] : "",
loaded ? styles["lic__frame--loaded"] : "",
].join(" ").trim();
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.lic} ${demo ? styles["is-demo"] : ""}`}>
<div
ref={frameRef}
className={frameClass}
tabIndex={0}
role="button"
aria-label={loaded ? "지도 임베드 로드됨" : "지도 임베드 불러오기"}
onClick={onClick}
>
<div className={styles["lic__notice"]}>
<span className={styles["lic__pin"]}>{"📍"}</span>
<p className={styles["lic__text"]}>클릭하면 지도를 불러옵니다</p>
</div>
<svg className={`${styles["lic__ripple"]} ${rippleActive ? styles["lic__ripple--active"] : ""}`} aria-hidden="true">
<circle cx={circle.cx} cy={circle.cy} r={circle.r} />
</svg>
<div className={styles["lic__embed"]}>
<svg viewBox="0 0 220 140" width="100%" height="100%" preserveAspectRatio="none">
<rect width={220} height={140} fill="#d8ead8" />
<path d="M0 90 L60 70 L120 96 L160 60 L220 84 L220 140 L0 140 Z" fill="#bfe0c4" />
<path d="M0 40 L220 55" stroke="#f4d878" strokeWidth={6} />
<path d="M40 0 L70 140" stroke="#f4d878" strokeWidth={5} />
<circle cx={118} cy={66} r={7} fill="#e6564a" />
<circle cx={118} cy={66} r={3} fill="#fff" />
</svg>
</div>
</div>
</div>
</main>
);
}07 · クリックの値が読み込み時点を計算するiframeの遅延読み込み — 計測 累積面積 21.261% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var RIPPLE_DURATION_MS = 520;
var LOAD_DELAY_MS = 340;
var CYCLE_GAP_MS = 2400;
var loaded = false;
// 計算レイヤー: クリック地点からフレームの四隅までの最大距離(ピタゴラスの定理)を
// リップルの目標半径として計算し、実際のクリック地点から始まる円がフレームを覆う。
function maxDistanceToCorner(x, y, w, h) {
var dTL = Math.sqrt(x * x + y * y);
var dTR = Math.sqrt((w - x) * (w - x) + y * y);
var dBL = Math.sqrt(x * x + (h - y) * (h - y));
var dBR = Math.sqrt((w - x) * (w - x) + (h - y) * (h - y));
return Math.max(dTL, dTR, dBL, dBR);
}
08スクロールの値が閾値を計算する下部セクションの遅延読み込み
スクロールの値が定めた閾値を超えたかを計算し、超える前は空のままにしていた下部セクションを超えた瞬間だけ実際の内容で満たします。checkThreshold はスクロールコンテナの scrollTop を SCROLL_THRESHOLD_PX と毎回のスクロール・ホイールイベントで比較し、超えた瞬間だけ双方向に状態を切り替えます。実運用ではその同じ境界で、空のセクションを fetch やフレームワークの遅延読み込みルートに置き換えることになります。

<main class="stage">
<div class="lsr is-demo" id="lsr">
<div class="lsr__view" id="lsrView">
<div class="lsr__pad"></div>
<p class="lsr__mark">―スクロール閾値―</p>
<div class="lsr__section" id="lsrSection">
<div class="lsr__empty" id="lsrEmpty">空きスペース</div>
<div class="lsr__content" id="lsrContent">
<div class="lsr__row"></div>
<div class="lsr__row"></div>
<div class="lsr__row"></div>
</div>
</div>
</div>
<p class="lsr__hint">スクロールして下のセクションを埋めてみましょう</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크롤 컨테이너의 scrollTop 값을 정해 둔 임계치(px)와 비교해 넘어선
// 순간에만 아래 섹션이 빈 자리에서 실제 내용으로 바뀐다.
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-paper);
.lsr {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.lsr__view {
width: 220px;
height: 130px;
overflow-y: auto;
border-radius: $r-card;
background: $stage-ink;
scroll-behavior: auto;
}
.lsr__pad {
height: 90px;
}
.lsr__mark {
margin: 0;
padding: $sp-1 0;
text-align: center;
font-size: 9px;
color: rgba(255, 255, 255, .4);
}
.lsr__section {
position: relative;
height: 160px;
}
.lsr__empty {
position: absolute;
inset: $sp-2;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed rgba(255, 255, 255, .25);
border-radius: $r-sm;
color: rgba(255, 255, 255, .35);
font-size: 11px;
transition: opacity $dur-quick $ease-out;
}
.lsr__content {
position: absolute;
inset: $sp-2;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-2;
opacity: 0;
transform: translateY(10px);
transition: opacity $duration $easing, transform $duration $easing;
pointer-events: none;
}
.lsr__row {
height: 20px;
border-radius: $r-sm;
background: linear-gradient(90deg, $subject-sky, $subject-blue-light);
}
.lsr__row:nth-child(2) { transition-delay: 60ms; opacity: .85; }
.lsr__row:nth-child(3) { transition-delay: 120ms; opacity: .7; }
.lsr__section--filled .lsr__empty { opacity: 0; }
.lsr__section--filled .lsr__content {
opacity: 1;
transform: translateY(0);
}
.lsr__hint {
margin: 0;
color: rgba(23, 20, 26, .55);
font-size: 11px;
}
@media (max-width: 340px) {
.lsr__view { width: 180px; height: 110px; }
.lsr__pad { height: 70px; }
.lsr__section { height: 130px; }
}
@media (prefers-reduced-motion: reduce) {
.lsr__empty, .lsr__content { 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);
}
.lsr {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.lsr__view {
width: 220px;
height: 130px;
overflow-y: auto;
border-radius: 16px;
background: #17141a;
scroll-behavior: auto;
}
.lsr__pad {
height: 90px;
}
.lsr__mark {
margin: 0;
padding: 4px 0;
text-align: center;
font-size: 9px;
color: rgba(255, 255, 255, 0.4);
}
.lsr__section {
position: relative;
height: 160px;
}
.lsr__empty {
position: absolute;
inset: 8px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed rgba(255, 255, 255, 0.25);
border-radius: 8px;
color: rgba(255, 255, 255, 0.35);
font-size: 11px;
transition: opacity 220ms ease-out;
}
.lsr__content {
position: absolute;
inset: 8px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
opacity: 0;
transform: translateY(10px);
transition: opacity 600ms ease-out, transform 600ms ease-out;
pointer-events: none;
}
.lsr__row {
height: 20px;
border-radius: 8px;
background: linear-gradient(90deg, #6ec8ff, #5b8cf8);
}
.lsr__row:nth-child(2) {
transition-delay: 60ms;
opacity: 0.85;
}
.lsr__row:nth-child(3) {
transition-delay: 120ms;
opacity: 0.7;
}
.lsr__section--filled .lsr__empty {
opacity: 0;
}
.lsr__section--filled .lsr__content {
opacity: 1;
transform: translateY(0);
}
.lsr__hint {
margin: 0;
color: rgba(23, 20, 26, 0.55);
font-size: 11px;
}
@media (max-width: 340px) {
.lsr__view {
width: 180px;
height: 110px;
}
.lsr__pad {
height: 70px;
}
.lsr__section {
height: 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.lsr__empty, .lsr__content {
transition: none !important;
}
}(function () {
var root = document.getElementById('lsr');
var view = document.getElementById('lsrView');
var section = document.getElementById('lsrSection');
var SCROLL_THRESHOLD_PX = 86;
var AUTOSCROLL_STEP_PX = 2.2;
var CYCLE_GAP_MS = 1400;
var filled = false;
var userActive = false;
// 계산 겹: 스크롤 컨테이너의 scrollTop 값을 정해 둔 임계치(px)와 비교해,
// 넘어선 순간에만 filled 상태로 바뀐다(넘기 전엔 空きスペース 유지).
function checkThreshold() {
var over = view.scrollTop >= SCROLL_THRESHOLD_PX;
if (over && !filled) {
filled = true;
section.classList.add('lsr__section--filled');
} else if (!over && filled) {
filled = false;
section.classList.remove('lsr__section--filled');
}
}
view.addEventListener('scroll', checkThreshold);
function takeover() {
if (userActive) { return; }
userActive = true;
root.classList.remove('is-demo');
}
view.addEventListener('wheel', takeover, { passive: true });
view.addEventListener('touchstart', takeover, { passive: true });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = null;
var loopTimer = null;
function autoScrollTo(target, onDone) {
function step() {
if (userActive) { return; }
var cur = view.scrollTop;
var next = cur + (target > cur ? AUTOSCROLL_STEP_PX : -AUTOSCROLL_STEP_PX);
if ((target > cur && next >= target) || (target < cur && next <= target)) {
view.scrollTop = target;
checkThreshold();
onDone();
return;
}
view.scrollTop = next;
checkThreshold();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
if (reduce) {
root.classList.remove('is-demo');
view.scrollTop = SCROLL_THRESHOLD_PX + 20;
checkThreshold();
} else {
(function loop() {
if (userActive) { return; }
view.scrollTop = 0;
checkThreshold();
loopTimer = setTimeout(function () {
if (userActive) { return; }
autoScrollTo(140, function () {
loopTimer = setTimeout(function () {
if (!userActive && root.classList.contains('is-demo')) { loop(); }
}, CYCLE_GAP_MS);
});
}, 500);
})();
}
})();import { useEffect, useRef, useCallback, useState } from "react";
import styles from "./SectionRevealBelowFold.module.scss";
// 스크롤 컨테이너의 scrollTop 값을 정해 둔 임계치(px)와 비교해 넘어선
// 순간에만 아래 섹션이 빈 자리에서 실제 내용으로 바뀐다.
const SCROLL_THRESHOLD_PX = 86;
const AUTOSCROLL_STEP_PX = 2.2;
const CYCLE_GAP_MS = 1400;
type Box<T> = { current: T };
function useBox<T>(initial: T): Box<T> {
return useRef(initial) as unknown as Box<T>;
}
export default function SectionRevealBelowFold() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [filled, setFilled] = useState(false);
const userActiveRef = useBox<boolean>(false);
const rafRef = useBox<number | null>(null);
const checkThreshold = useCallback(() => {
const view = viewRef.current;
if (!view) { return; }
const over = view.scrollTop >= SCROLL_THRESHOLD_PX;
setFilled((prev) => (prev === over ? prev : over));
}, []);
const takeover = useCallback(() => {
if (userActiveRef.current) { return; }
userActiveRef.current = true;
setDemo(false);
}, [userActiveRef]);
const autoScrollTo = useCallback((target: number, onDone: () => void) => {
function step() {
const view = viewRef.current;
if (userActiveRef.current || !view) { return; }
const cur = view.scrollTop;
const next = cur + (target > cur ? AUTOSCROLL_STEP_PX : -AUTOSCROLL_STEP_PX);
if ((target > cur && next >= target) || (target < cur && next <= target)) {
view.scrollTop = target;
checkThreshold();
onDone();
return;
}
view.scrollTop = next;
checkThreshold();
rafRef.current = requestAnimationFrame(step);
}
rafRef.current = requestAnimationFrame(step);
}, [checkThreshold, rafRef, userActiveRef]);
useEffect(() => {
const view = viewRef.current;
view?.addEventListener("scroll", checkThreshold);
view?.addEventListener("wheel", takeover, { passive: true });
view?.addEventListener("touchstart", takeover, { passive: true });
return () => {
view?.removeEventListener("scroll", checkThreshold);
view?.removeEventListener("wheel", takeover);
view?.removeEventListener("touchstart", takeover);
};
}, [checkThreshold, takeover]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const view = viewRef.current;
if (reduce) {
setDemo(false);
if (view) { view.scrollTop = SCROLL_THRESHOLD_PX + 20; }
checkThreshold();
return;
}
let cancelled = false;
let t1: number | null = null;
let t2: number | null = null;
function loop() {
if (userActiveRef.current || cancelled) { return; }
if (view) { view.scrollTop = 0; }
checkThreshold();
t1 = window.setTimeout(() => {
if (userActiveRef.current || cancelled) { return; }
autoScrollTo(140, () => {
t2 = window.setTimeout(() => {
if (!userActiveRef.current && !cancelled && rootRef.current?.classList.contains(styles["is-demo"])) { loop(); }
}, CYCLE_GAP_MS);
});
}, 500);
}
loop();
return () => {
cancelled = true;
if (t1 !== null) { window.clearTimeout(t1); }
if (t2 !== null) { window.clearTimeout(t2); }
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.lsr} ${demo ? styles["is-demo"] : ""}`}>
<div ref={viewRef} className={styles["lsr__view"]}>
<div className={styles["lsr__pad"]} />
<p className={styles["lsr__mark"]}>{"— 스크롤 임계선 —"}</p>
<div className={`${styles["lsr__section"]} ${filled ? styles["lsr__section--filled"] : ""}`}>
<div className={styles["lsr__empty"]}>빈 자리</div>
<div className={styles["lsr__content"]}>
<div className={styles["lsr__row"]} />
<div className={styles["lsr__row"]} />
<div className={styles["lsr__row"]} />
</div>
</div>
</div>
<p className={styles["lsr__hint"]}>스크롤해서 아래 섹션을 채워보세요</p>
</div>
</main>
);
}08 · スクロールの値が閾値を計算する下部セクションの遅延読み込み — 計測 累積面積 16.765% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var SCROLL_THRESHOLD_PX = 86;
var AUTOSCROLL_STEP_PX = 2.2;
var CYCLE_GAP_MS = 1400;
var filled = false;
var userActive = false;
// 計算レイヤー: スクロールコンテナのscrollTopの値を決めた閾値(px)と比較し、
// 超えた瞬間だけfilled状態に切り替わる(超える前は空のまま)。
function checkThreshold() {
var over = view.scrollTop >= SCROLL_THRESHOLD_PX;
if (over && !filled) {
filled = true;
section.classList.add('lsr__section--filled');
} else if (!over && filled) {
filled = false;
section.classList.remove('lsr__section--filled');
}
}
09アイドル時間の値が遅延時間を計算するウィジェットの遅延読み込み
ブラウザが空いているアイドル時間の値を計算し、その時間が確保できるまで重いおすすめ・広告ウィジェットの場所をプレースホルダーのままにしておきます。tick は requestIdleCallback が返す残り時間を呼び出しのたびに積み上げて IDLE_BUDGET_MS に達するまで続け、ブラウザがアイドル時間を報告しない場合は上限付きの setTimeout にフォールバックします。この予算に達してから差し込むという流れこそ、本物の広告枠やおすすめウィジェットのスクリプトを引き渡す位置です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 });
}
}
css 遅延読み込み という言葉で検索した人が実際に欲しいもの
css 遅延読み込み を調べている人が本当に知りたいのは、どの画面でどれを使うかという話であることが多いので、9つのうち3つを名指しします。パターン02の画面幅で選ぶ背景は、ヒーローセクションやイベントページにそのまま当てはまります——ページの他の要素より先に、プレースホルダーの色から適切な解像度の背景へ落ち着く様子を訪問者が最初に目にするからです。パターン07のクリックで読み込むiframeは、店舗位置の地図やSNS投稿の埋め込みにぴったり合います。この2つはページの下の方で使われないまま終わることが多い場所で、クリックされるまで地図タイルも埋め込みスクリプトも一切読み込まれません。
パターン09は、多くのページが見落としがちな場所にちょうど収まります——サイドバー広告や関連商品のおすすめ枠は、放っておくと最初の描画でページ本体の内容とブラウザの注意を奪い合ってしまいます。requestIdleCallback にそのウィジェットを読み込む価値があるかどうかを任せるのは、まだ誰も見ていない場所にパターン02や07と同じ考え方を当てはめているだけです。
どの値が本当に決めているのか、パターンごとに見比べる
| パターン | 見ている値 | 再計算の方法 | デモの操作入力 |
|---|---|---|---|
| 01 | フォント準備状態 | 切り替えごとに1回の setTimeout |
click, keydown |
| 02 | 画面幅 | requestAnimationFrame ループ |
click, keydown |
| 03 | スクロールで動く交差比率 | IntersectionObserver コールバック |
scroll, wheel, touchstart |
| 04 | 入力された名前の文字列 | 行ごとに1回の setTimeout |
click |
| 05 | 格子の行+列の位置 | セルごとの段階的な setTimeout |
click, keydown |
| 06 | 再生開始フラグ | requestAnimationFrame ループ |
click |
| 07 | クリック座標 | requestAnimationFrame ループ |
click, keydown |
| 08 | コンテナの scrollTop |
scroll/wheelイベント+requestAnimationFrame |
scroll, wheel, touchstart |
| 09 | 積み上げたアイドル時間 | requestIdleCallback、setTimeout フォールバック |
click |
多くの html 画像 遅延読み込み の解説はCSSのトランジションで止まってしまい、loadedクラスを「いつ」付け加えるかを決めるコードのほうには触れません。実はそこの判断こそがこの9つを分けている本当の違いで、フェード自体はどのパターンでも opacity と filter の1行にすぎません。
毎フレーム値を計算し直す場所 — 02・06・07・08
02・06・07は全く同じ進捗率のループ——経過時間を時間で割って1でクランプする——を共有していて、それをブラーと不透明度・conic-gradientの角度・リップルの半径という3種類の出力にそれぞれ書き込んでいます。08で requestAnimationFrame を使っているのはデモ自体の自動スクロールのためで、閾値の判定そのものはスクロールイベント上の単純な比較です。
一度きりのタイマーで決まる場所 — 01・04・05
01・04・05は requestAnimationFrame に一切触れません。文字・行・セルごとに1回だけ setTimeout が発火し、状態の間の動きはCSSトランジションに任せています。見た目の変化が連続的に計算し直す必要のない、一つのプロパティの一回きりの切り替えであるときは、こちらのほうが軽い方法です。
ブラウザ自身の合図で決まる場所 — 03・09
03と09は、自分で書いたタイマーではなくブラウザ側に判断を委ねている2つです。IntersectionObserver が比率を返し、requestIdleCallback が残り時間を返し、どちらのパターンも返ってきた数値にただ反応しているだけです。
出荷前に動かしておきたい3つの数値
| バリエーション | 項目 | 定数 | 基準 → 変更後 | 何が変わるか |
|---|---|---|---|---|
| 03 | 画像フェードイン | INTERSECTION_THRESHOLD |
0.4 → 0.25 | ビューポートとの重なりが小さいうちに、より早く読み込み完了と判定される |
| 05 | ギャラリースタガー | STAGGER_STEP_MS |
90 → 160 | 格子を斜めに広がる表示が終わるまで、はっきり長くかかる |
| 09 | アイドルウィジェット | IDLE_BUDGET_MS |
260 → 420 | 切り替わるまでに必要なアイドル時間の合計が増え、より多くのコールバックを経て待つ傾向になる |
この3つのバリエーションはいずれもzip内の独立した index.html で、変わっているのはその定数一つだけです——リスナーやreduced-motionの分岐、その他のマークアップは基準パターンと変更後の間で何も変わりません。
9つとも共通して壊れる、ある一つの境界
この9つはどれも、自分の決めたタイミングどおりに最後まで進める前提で書かれていて、その途中で訪問者に割り込まれると同じところで壊れます——パターン03の比率がまだ上がっている最中にスクロールを止めて離れたり、パターン07のリップルが終わる前にiframeをもう一度クリックしたりすると、進行中の requestAnimationFrame やタイマーを自分でキャンセルできているかどうかが問われます。ここに含めた9つはすべて、新しい raf = の代入の前に cancelAnimationFrame を呼ぶか、コールバックの本体が動く前に loaded・filled・running のようなガード用フラグを確認していますが、この一手間こそ、誰かがパターンの見える部分だけをコピーして新しいプロジェクトに貼り付け、後片付けの行を省いたときに真っ先に抜け落ちる部分でもあります。
以下のzipには9つのindex.htmlと3つのバリエーションすべてが入っていて、パスワード 794sbpwn は画面を見ながら手入力してください。答えも一緒に映っているページからコピーしたパスワードでは、わざわざ入力させる意味がなくなってしまいます。
reduced-motion が消す、3つの動き
9つのパターンはすべて、読み込み時に一度だけ window.matchMedia('(prefers-reduced-motion: reduce)').matches を読み、その結果で分岐します。中でもアニメーションの過程が長い3つを見ると、その違いがいちばんよくわかります。パターン01は文字ごとの段階的な切り替えを飛ばし、左から右へ波打たせる代わりに全ての文字を一度に読み込み済みの状態にします。パターン03は applyRatio が普段行うブラー・スケールの推移を飛ばし、比率が基準を最初に超えた瞬間に完全にくっきりした画像へ直接切り替えます。パターン09の進捗バーは、アイドルコールバック一回ごとの積み上げをアニメーションで見せる代わりに、最終的な幅まで一段階で埋まります——ただし裏側のアイドル時間の計算自体は変わらず、reduced-motionが消しているのはあくまで判断の「見せ方」であって、判断そのものではありません。
画像 遅延読み込み javascript の実装がこれを一つも切らないままだと、OSレベルで動きを止めるよう申告した訪問者に対しても、そのままアニメーションを流し続けることになってしまいます。
この9つを試す前に、よくある疑問に先に答えておきます
項目05と09の動いたフレームが23枚中こんなに少ないのはなぜですか
どちらも一続きの場面ではなく、一度きりの読み込み遷移だからです。05は斜めのスタガーを、09は予算の積み上げを、測定期間が終わるよりずっと前に終えてしまうので、その後の23フレームの大半は同じ落ち着いた読み込み済みの状態を映しており、これはどちらのパターンの欠陥でもなく正しい挙動です。
html 画像 遅延読み込み を最初に試すならどれがいちばん簡単ですか
パターン01の代替フォント切り替えが、この9つの中でいちばん簡単な html 画像 遅延読み込み の例です——setTimeout を1回、CSSクラスの切り替えを1回、文字ごとに計算した transitionDelay だけで、あとのobserverやアイドルコールバック主導のパターンに進む前の入り口として向いています。
この9つの中で、実際にURLから画像・動画・スクリプトを読み込んでいるものはありますか
ありません。プレースホルダーから実物への切り替えはすべてタイマー・observerのコールバック・アイドル時間の予算のいずれかでシミュレートしていて、そこを本物の <img src>・<video>・<iframe src>・fetch に差し替える位置は、それぞれのパターンの説明文の中で名指ししています。
Sources: MDN — Intersection Observer API · MDN — Window.requestIdleCallback · MDN — img loading属性 · MDN — FontFaceSet · MDN — prefers-reduced-motion
Related reading: 無限スクロールローディング UIパターン9選 — スピナーから仮想ウィンドウまで · スケルトン チャート 9種 — 数式で完成する瞬間まで · 【コピペOK】シェアボタン デザイン 9選 — 埋め込みウィジェット — 1つ目はスピナーやスケルトンを実際の行に置き換える点で本物のデータを取得しない同じ原則を守っており、2つ目はパターン05が格子位置からスタガーを計算するのと同じように、実測したデータからスケルトンの表示タイミングを計算しており、3つ目はこの記事のパターン07とほぼ同じ考え方——クリックされるまで埋め込みの代わりに静的なプレースホルダーを置く——を共有しています。









