05격자 위치 값이 순서를 계산하는 갤러리 지연 로딩
썸네일의 격자 위치 값으로 등장 지연 시간을 계산해, 왼쪽 위부터 순서대로 시간차를 두고 자리표시자가 실제 사진으로 바뀐다.
쓰임포트폴리오 그리드·상품 목록 갤러리
STAGGER_STEP_MSSTART_DELAY_MSdelayFor

<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 원작 · zip 에 SCSS·React 포함 · 이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음
이 데모가 속한 글
이미지 지연 로딩 9종 — 레이지 로딩 자리표시자 모음이미지 지연 로딩 아홉 가지 가운데 하나는 스크롤 위치 값으로 교차 비율을 계산해 이미지를 페이드인시키고, 하나는 클릭 여부 값으로 로드 시점을 계산해 지도 iframe 자리를 실제 화면으로 바꾸며, 하나는 유휴 시 이 글의 데모 전부 보기


