08스크롤 순차 등장 타일
그리드가 화면에 들어오면 IntersectionObserver가 타일마다 순서대로 클래스를 붙이고, transition-delay 값만 타일 순서대로 달라 순차적으로 떠오르듯 나타납니다.
쓰임랜딩 페이지 기능 그리드, 스크롤로 내려가며 보여주는 포트폴리오
IntersectionObservertransition-delayJS 8줄

<main class="stage">
<div class="ss-grid is-demo">
<div class="ss-tile"><span class="ss-tile__tag">Design</span></div>
<div class="ss-tile"><span class="ss-tile__tag">Build</span></div>
<div class="ss-tile"><span class="ss-tile__tag">Ship</span></div>
<div class="ss-tile"><span class="ss-tile__tag">Scale</span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$stagger: 160ms;
@include stage($stage-paper);
.ss-grid {
width: min(320px, 88vw);
aspect-ratio: 340 / 190;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: $sp-2;
}
.ss-tile {
border-radius: $r-sm;
padding: $sp-3;
box-sizing: border-box;
color: $subject-ink;
background: rgba(23, 20, 26, .06);
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
display: flex;
align-items: flex-end;
// 진짜 등장 전 상태 — transition 경로와 keyframes 경로가 같은 시작점을 공유한다
opacity: 0;
transform: translateY($sp-4);
}
.ss-tile__tag { opacity: .8; }
// 실사용: 진짜 IntersectionObserver 가 뷰포트에 들어올 때 .in 을 붙인다(위 <script>). 타일마다 delay 만 다르다
.ss-tile {
transition: opacity 400ms $easing, transform 400ms $easing;
}
.ss-tile.in { opacity: 1; transform: translateY(0); }
.ss-tile:nth-child(1).in { transition-delay: 0ms; }
.ss-tile:nth-child(2).in { transition-delay: $stagger; }
.ss-tile:nth-child(3).in { transition-delay: $stagger * 2; }
.ss-tile:nth-child(4).in { transition-delay: $stagger * 3; }
// 데모 루프: is-demo 는 transition 경로를 쓰지 않는다 — scroll-timeline·view() 대신 keyframes 로 같은 순차 등장을 무한 재생한다(캡처용)
@keyframes ss-tile-in {
0% { opacity: 0; transform: translateY($sp-4); }
30% { opacity: 1; transform: translateY(0); }
100% { opacity: 1; transform: translateY(0); }
}
.ss-grid.is-demo .ss-tile {
animation: ss-tile-in $duration $easing infinite;
animation-fill-mode: both;
}
.ss-grid.is-demo .ss-tile:nth-child(1) { animation-delay: 0ms; }
.ss-grid.is-demo .ss-tile:nth-child(2) { animation-delay: $stagger; }
.ss-grid.is-demo .ss-tile:nth-child(3) { animation-delay: $stagger * 2; }
.ss-grid.is-demo .ss-tile:nth-child(4) { animation-delay: $stagger * 3; }
@media (prefers-reduced-motion: reduce) {
.ss-tile, .ss-tile.in,
.ss-grid.is-demo .ss-tile {
animation: none !important; transition: none !important; opacity: 1 !important; transform: 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);
}
.ss-grid {
width: min(320px, 88vw);
aspect-ratio: 340/190;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 8px;
}
.ss-tile {
border-radius: 8px;
padding: 12px;
box-sizing: border-box;
color: #17141a;
background: rgba(23, 20, 26, 0.06);
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
display: flex;
align-items: flex-end;
opacity: 0;
transform: translateY(16px);
}
.ss-tile__tag {
opacity: 0.8;
}
.ss-tile {
transition: opacity 400ms ease-out, transform 400ms ease-out;
}
.ss-tile.in {
opacity: 1;
transform: translateY(0);
}
.ss-tile:nth-child(1).in {
transition-delay: 0ms;
}
.ss-tile:nth-child(2).in {
transition-delay: 160ms;
}
.ss-tile:nth-child(3).in {
transition-delay: 320ms;
}
.ss-tile:nth-child(4).in {
transition-delay: 480ms;
}
@keyframes ss-tile-in {
0% {
opacity: 0;
transform: translateY(16px);
}
30% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.ss-grid.is-demo .ss-tile {
animation: ss-tile-in 2s ease-out infinite;
animation-fill-mode: both;
}
.ss-grid.is-demo .ss-tile:nth-child(1) {
animation-delay: 0ms;
}
.ss-grid.is-demo .ss-tile:nth-child(2) {
animation-delay: 160ms;
}
.ss-grid.is-demo .ss-tile:nth-child(3) {
animation-delay: 320ms;
}
.ss-grid.is-demo .ss-tile:nth-child(4) {
animation-delay: 480ms;
}
@media (prefers-reduced-motion: reduce) {
.ss-tile, .ss-tile.in,
.ss-grid.is-demo .ss-tile {
animation: none !important;
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
}var tiles = document.querySelectorAll('.ss-tile');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
}, { threshold: 0.3 });
tiles.forEach(function (t) { io.observe(t); });import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./ScrollStaggerTiles.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--stagger"?: string;
};
export interface ScrollStaggerTilesProps {
duration?: string;
easing?: string;
stagger?: string;
}
const LABELS = ["Design", "Build", "Ship", "Scale"];
// 실사용: 진짜 IntersectionObserver 가 그리드 전체가 뷰포트에 들어올 때 in 을 켠다. 타일마다 transition-delay 만 다르다.
// scroll-timeline / view() 는 쓰지 않는다 — 브라우저마다 렌더가 갈린다
export default function ScrollStaggerTiles({
duration = "2s",
easing = "ease-out",
stagger = "160ms",
}: ScrollStaggerTilesProps) {
const gridRef = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = gridRef.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setInView(true); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--stagger": stagger,
};
return (
<div className={styles.grid} style={style} ref={gridRef}>
{LABELS.map((label, i) => (
<div
key={label}
className={`${styles.tile} ${inView ? styles.in : ""}`}
style={{ transitionDelay: inView ? `calc(${stagger} * ${i})` : undefined }}
>
<span className={styles.tag}>{label}</span>
</div>
))}
</div>
);
}08 스크롤 순차 등장 타일 — 측정 누적면적 36.644% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 벤토 그리드 레이아웃 호버 애니메이션 9종
이 데모가 속한 글
벤토 그리드 레이아웃 호버 애니메이션 9종포트폴리오나 제품 소개 페이지에서 크고 작은 네모 칸이 사진 콜라주처럼 이어 붙은 레이아웃을 본 적 있으신가요? 이런 벤토 그리드 레이아웃에 마우스를 올렸을 때 칸마다 살짝 반응하게 만드는 CSS 코드를 아홉 가지로 이 글의 데모 전부 보기


