이미지 갤러리 CSS 9종 — 메이슨리부터 라이트박스까지
이미지 갤러리 CSS는 사진 여러 장을 그리드나 워터폴 모양으로 배치하고 호버·클릭에 반응하게 만드는 레이아웃 기법입니다. 핀터레스트에 저장해 둔 그 갤러리처럼 예쁘지만, 막상 코드로 옮기려면 그리드 수식과 크롭 처리에서 막히기 쉽습니다.
01 gallery-grid-2col
02 gallery-grid-3col-object-fit
03 gallery-auto-fit-4col
04 gallery-masonry-columns
05 gallery-masonry-grid-rows
06 gallery-aspect-ratio-lock
07 gallery-featured-first
08 gallery-hover-caption
09 gallery-lightbox-dialog
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 2단 균등 그리드
- 02 3단 그리드 · 비율 통일
- 03 자동 반응형 그리드
- 04 멀티 컬럼 워터폴
- 05 그리드 워터폴 (읽는 순서 유지)
- 06 정사각 그리드 고정
- 07 첫 장 강조 배치
- 08 호버 캡션 오버레이
- 09 클릭 시 라이트박스 (JS 없이)
아홉 개는 인기 순서가 아니라 갤러리 하나를 실제로 만들 때 부딪히는 순서로 놓았습니다. 먼저 칸을 똑같이 나누는 가장 기본적인 css 그리드 레이아웃부터 시작해(01), 사진 비율이 제각각이라는 현실적인 문제를 풀고(02), 화면 폭에 맞춰 칸 수가 스스로 바뀌는 반응형을 더하고(03), 높이가 다른 사진을 빈틈없이 쌓는 워터폴을 두 가지 방식으로 비교하고(04~05), 인스타그램처럼 정사각형만 고집하거나(06) 대표 사진 하나를 키우는 편집 레이아웃을 얹고(07), 마지막으로 캡션과 클릭 확대라는 상호작용을 붙입니다(08~09). 두 장을 겹쳐 놓고 손잡이로 비교하는 배치는 이미지 비교 슬라이더 UI에, 칸 자체를 끌어 넓히는 쪽은 화면 분할 패널에 따로 모아 두었습니다.
| 번호 | 이름 | 무엇이 움직이나 | JS |
|---|---|---|---|
| 01 | 2단 균등 그리드 | 타일 translateY·scale |
없음 |
| 02 | 3단 그리드 · 비율 통일 | 사진 scale, 프레임 translateY |
없음 |
| 03 | 자동 반응형 그리드 | 타일 opacity·scale 진입 |
없음 |
| 04 | 멀티 컬럼 워터폴 | 타일 translateY |
없음 |
| 05 | 그리드 워터폴 | 타일 scale |
없음 |
| 06 | 정사각 그리드 고정 | 사진 scale, 프레임 translateY |
없음 |
| 07 | 첫 장 강조 배치 | 타일 scale |
없음 |
| 08 | 호버 캡션 오버레이 | 캡션 translateY·opacity |
없음 |
| 09 | 클릭 시 라이트박스 | 패널 scale·opacity |
없음 |
012단 균등 그리드
무엇이 움직이나
사진 네 장을 2×2 칸에 똑같은 크기로 나눠 놓고, 커서가 올라간 칸만 살짝 떠오르며 그림자가 짙어져 지금 보고 있는 사진이 어디인지 알려줍니다.

<main class="stage">
<ul class="gallery is-demo" aria-label="사진 갤러리">
<li class="tile" tabindex="0"></li>
<li class="tile" tabindex="0"></li>
<li class="tile" tabindex="0"></li>
<li class="tile" tabindex="0"></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 타일이 뜨는 시간
$easing: $ease-out;
$color: $subject-blue; // 타일 그러데이션
@include stage($stage-ink);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: 188px;
max-width: 100%;
}
.tile {
aspect-ratio: 4 / 3;
border-radius: $r-sm;
outline: none;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.tile:nth-child(1) { background: linear-gradient(155deg, $color 0%, $stage-ink 100%); }
.tile:nth-child(2) { background: linear-gradient(205deg, $subject-cream 0%, $color 100%); }
.tile:nth-child(3) { background: linear-gradient(115deg, $color 0%, $subject-cream 60%); }
.tile:nth-child(4) { background: linear-gradient(250deg, $stage-ink 0%, $color 100%); }
.tile:hover, .tile:focus-visible, .tile:active {
transform: translateY(-4px) scale(1.03);
box-shadow: $shadow-raised;
}
.gallery.is-demo .tile { animation: lift-loop $dur-loop $easing infinite; }
.gallery.is-demo .tile:nth-child(2) { animation-delay: -0.5s; }
.gallery.is-demo .tile:nth-child(3) { animation-delay: -1.0s; }
.gallery.is-demo .tile:nth-child(4) { animation-delay: -1.5s; }
@keyframes lift-loop {
0%, 10% { transform: translateY(0) scale(1); box-shadow: $shadow-press; }
25%, 35% { transform: translateY(-4px) scale(1.03); box-shadow: $shadow-raised; }
50%, 100% { transform: translateY(0) scale(1); box-shadow: $shadow-press; }
}
@media (hover: none) {
.tile:active { transform: translateY(-4px) scale(1.03); box-shadow: $shadow-raised; }
}
@media (prefers-reduced-motion: reduce) {
.tile { transition: none; }
.gallery.is-demo .tile { animation: 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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: 188px;
max-width: 100%;
}
.tile {
aspect-ratio: 4/3;
border-radius: 8px;
outline: none;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms ease-out, box-shadow 220ms ease-out;
}
.tile:nth-child(1) {
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.tile:nth-child(2) {
background: linear-gradient(205deg, #fff7e6 0%, #2f6df6 100%);
}
.tile:nth-child(3) {
background: linear-gradient(115deg, #2f6df6 0%, #fff7e6 60%);
}
.tile:nth-child(4) {
background: linear-gradient(250deg, #17141a 0%, #2f6df6 100%);
}
.tile:hover, .tile:focus-visible, .tile:active {
transform: translateY(-4px) scale(1.03);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.gallery.is-demo .tile {
animation: lift-loop 2s ease-out infinite;
}
.gallery.is-demo .tile:nth-child(2) {
animation-delay: -0.5s;
}
.gallery.is-demo .tile:nth-child(3) {
animation-delay: -1s;
}
.gallery.is-demo .tile:nth-child(4) {
animation-delay: -1.5s;
}
@keyframes lift-loop {
0%, 10% {
transform: translateY(0) scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
25%, 35% {
transform: translateY(-4px) scale(1.03);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
50%, 100% {
transform: translateY(0) scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@media (hover: none) {
.tile:active {
transform: translateY(-4px) scale(1.03);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.tile {
transition: none;
}
.gallery.is-demo .tile {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./TwoColGallery.module.scss";
export type TwoColGalleryProps = {
/** 타일이 뜨는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 타일 그러데이션 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 사진 4장을 2×2 칸에 나누고, 커서가 올라간 칸만 떠오르며 그림자가 짙어진다 */
export default function TwoColGallery({
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: TwoColGalleryProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="Photo gallery">
{[0, 1, 2, 3].map((i) => (
<li key={i} className={`${styles.tile} ${styles["tile" + i]}`} tabIndex={0} />
))}
</ul>
);
}01 · 2단 균등 그리드 — 측정 누적면적 3.564% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
포트폴리오 섬네일이나 블로그 글 속 사진 나열처럼 가장 단순한 갤러리에 씁니다.
.gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-2;
}
.tile {
transition: transform $duration $easing, box-shadow $duration $easing;
}
.tile:hover, .tile:focus-visible {
transform: translateY(-4px) scale(1.03);
box-shadow: $shadow-raised;
}
023단 그리드 · 비율 통일
무엇이 움직이나
세로 사진·가로 사진·정사각 사진이 섞여 있어도 각 이미지에 aspect-ratio: 4/3과 object-fit: cover를 걸면 모든 칸이 같은 크기로 맞춰지고, 호버하면 프레임이 살짝 떠오르며 안쪽 사진도 함께 확대됩니다.

<main class="stage">
<ul class="gallery is-demo" aria-label="사진 갤러리">
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Crect width='400' height='300' fill='%232f6df6'/%3E%3C/svg%3E"></li>
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='420'%3E%3Crect width='300' height='420' fill='%2317141a'/%3E%3C/svg%3E"></li>
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='360'%3E%3Crect width='360' height='360' fill='%23fff7e6'/%3E%3C/svg%3E"></li>
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='420'%3E%3Crect width='260' height='420' fill='%232f6df6'/%3E%3C/svg%3E"></li>
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='440' height='280'%3E%3Crect width='440' height='280' fill='%2317141a'/%3E%3C/svg%3E"></li>
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='340' height='340'%3E%3Crect width='340' height='340' fill='%23fff7e6'/%3E%3C/svg%3E"></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 프레임 안에서 확대되는 시간
$easing: $ease-out;
$color: $stage-ink; // 프레임 배경(로딩 중 틈)
@include stage($stage-yellow);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: 208px;
max-width: 100%;
}
.cell {
aspect-ratio: 4 / 3;
border-radius: $r-sm;
overflow: hidden;
outline: none;
cursor: pointer;
background: $color;
box-shadow: $shadow-press;
transform: translateY(0);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.photo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1);
transition: transform $duration $easing;
}
// 프레임(.cell) 자체도 살짝 떠올라야 움직임이 보인다 — 단색 사진만 확대하면 크롭된 색이 그대로라 픽셀 차이가 0 이다 (132 실측)
.cell:hover, .cell:focus-visible, .cell:active { transform: translateY(-3px); box-shadow: $shadow-raised; }
.cell:hover .photo, .cell:focus-visible .photo, .cell:active .photo { transform: scale(1.12); }
.gallery.is-demo .cell { animation: lift-loop $dur-loop $easing infinite; }
.gallery.is-demo .photo { animation: crop-zoom $dur-loop $easing infinite; }
.gallery.is-demo .cell:nth-child(2) { animation-delay: -0.33s; }
.gallery.is-demo .cell:nth-child(3) { animation-delay: -0.66s; }
.gallery.is-demo .cell:nth-child(4) { animation-delay: -1.0s; }
.gallery.is-demo .cell:nth-child(5) { animation-delay: -1.33s; }
.gallery.is-demo .cell:nth-child(6) { animation-delay: -1.66s; }
.gallery.is-demo .cell:nth-child(2) .photo { animation-delay: -0.33s; }
.gallery.is-demo .cell:nth-child(3) .photo { animation-delay: -0.66s; }
.gallery.is-demo .cell:nth-child(4) .photo { animation-delay: -1.0s; }
.gallery.is-demo .cell:nth-child(5) .photo { animation-delay: -1.33s; }
.gallery.is-demo .cell:nth-child(6) .photo { animation-delay: -1.66s; }
@keyframes lift-loop {
0%, 8% { transform: translateY(0); box-shadow: $shadow-press; }
25%, 40% { transform: translateY(-3px); box-shadow: $shadow-raised; }
58%, 100% { transform: translateY(0); box-shadow: $shadow-press; }
}
@keyframes crop-zoom {
0%, 8% { transform: scale(1); }
25%, 40% { transform: scale(1.12); }
58%, 100% { transform: scale(1); }
}
@media (hover: none) {
.cell:active .photo { transform: scale(1.12); }
}
@media (prefers-reduced-motion: reduce) {
.photo { transition: none; }
.gallery.is-demo .photo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: 208px;
max-width: 100%;
}
.cell {
aspect-ratio: 4/3;
border-radius: 8px;
overflow: hidden;
outline: none;
cursor: pointer;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(0);
transition: transform 300ms ease-out, box-shadow 300ms ease-out;
}
.photo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1);
transition: transform 300ms ease-out;
}
.cell:hover, .cell:focus-visible, .cell:active {
transform: translateY(-3px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cell:hover .photo, .cell:focus-visible .photo, .cell:active .photo {
transform: scale(1.12);
}
.gallery.is-demo .cell {
animation: lift-loop 2s ease-out infinite;
}
.gallery.is-demo .photo {
animation: crop-zoom 2s ease-out infinite;
}
.gallery.is-demo .cell:nth-child(2) {
animation-delay: -0.33s;
}
.gallery.is-demo .cell:nth-child(3) {
animation-delay: -0.66s;
}
.gallery.is-demo .cell:nth-child(4) {
animation-delay: -1s;
}
.gallery.is-demo .cell:nth-child(5) {
animation-delay: -1.33s;
}
.gallery.is-demo .cell:nth-child(6) {
animation-delay: -1.66s;
}
.gallery.is-demo .cell:nth-child(2) .photo {
animation-delay: -0.33s;
}
.gallery.is-demo .cell:nth-child(3) .photo {
animation-delay: -0.66s;
}
.gallery.is-demo .cell:nth-child(4) .photo {
animation-delay: -1s;
}
.gallery.is-demo .cell:nth-child(5) .photo {
animation-delay: -1.33s;
}
.gallery.is-demo .cell:nth-child(6) .photo {
animation-delay: -1.66s;
}
@keyframes lift-loop {
0%, 8% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
25%, 40% {
transform: translateY(-3px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
58%, 100% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@keyframes crop-zoom {
0%, 8% {
transform: scale(1);
}
25%, 40% {
transform: scale(1.12);
}
58%, 100% {
transform: scale(1);
}
}
@media (hover: none) {
.cell:active .photo {
transform: scale(1.12);
}
}
@media (prefers-reduced-motion: reduce) {
.photo {
transition: none;
}
.gallery.is-demo .photo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./ObjectFitGallery.module.scss";
export type Photo = { src: string; alt?: string };
export type ObjectFitGalleryProps = {
photos?: Photo[];
/** 프레임 확대 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 프레임 배경(로딩 중 틈) */
color?: string;
};
// 예시 기본값 — 외부 요청 없이 렌더되도록 인라인 SVG를 썼다. 실제로는 photos prop 으로 진짜 사진 src 를 넘긴다
const swatch = (w: number, h: number, hex: string) =>
`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}'%3E%3Crect width='${w}' height='${h}' fill='%23${hex}'/%3E%3C/svg%3E`;
const DEFAULT_PHOTOS: Photo[] = [
{ src: swatch(400, 300, "2f6df6") },
{ src: swatch(300, 420, "17141a") },
{ src: swatch(360, 360, "fff7e6") },
{ src: swatch(260, 420, "2f6df6") },
{ src: swatch(440, 280, "17141a") },
{ src: swatch(340, 340, "fff7e6") },
];
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 세로·가로·정사각 사진이 섞여도 aspect-ratio + object-fit: cover 로 모든 칸을 같은 크기로 맞춘다 */
export default function ObjectFitGallery({
photos = DEFAULT_PHOTOS,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: ObjectFitGalleryProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="Photo gallery">
{photos.map((p, i) => (
<li key={i} className={styles.cell} tabIndex={0}>
<img className={styles.photo} src={p.src} alt={p.alt ?? ""} />
</li>
))}
</ul>
);
}02 · 3단 그리드 · 비율 통일 — 측정 누적면적 3.778% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
휴대폰으로 찍어 비율이 제각각인 사진을 올리는 갤러리에 맞습니다.
.cell {
aspect-ratio: 4 / 3;
overflow: hidden;
}
.photo {
width: 100%; height: 100%;
object-fit: cover;
transform: scale(1);
transition: transform $duration $easing;
}
.cell:hover .photo, .cell:focus-within .photo {
transform: scale(1.12);
}
03자동 반응형 그리드
무엇이 움직이나
grid-template-columns: repeat(auto-fit, minmax(56px, 1fr)) 한 줄만 걸어 두면 미디어쿼리 없이도 화면 폭에 맞춰 한 줄에 들어가는 칸 수가 저절로 바뀌고, 새 타일은 옅게 나타나며 자리를 잡습니다.

<main class="stage">
<ul class="gallery is-demo" aria-label="사진 갤러리">
<li class="tile" tabindex="0"></li>
<li class="tile" tabindex="0"></li>
<li class="tile" tabindex="0"></li>
<li class="tile" tabindex="0"></li>
<li class="tile" tabindex="0"></li>
<li class="tile" tabindex="0"></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 타일이 자리를 잡는 시간
$easing: $ease-spring;
$color: $subject-cream; // 타일 그러데이션
@include stage($stage-orange);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(56px, 1fr));
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: 210px;
max-width: 100%;
}
.tile {
aspect-ratio: 1 / 1;
border-radius: $r-sm;
outline: none;
cursor: pointer;
box-shadow: $shadow-press;
opacity: 1;
transform: scale(1);
transition: transform $duration $easing;
}
.tile:nth-child(odd) { background: linear-gradient(160deg, $color 0%, $stage-orange 100%); }
.tile:nth-child(even) { background: linear-gradient(20deg, $stage-ink 0%, $color 100%); }
.tile:hover, .tile:focus-visible, .tile:active { transform: scale(1.08); }
.gallery.is-demo .tile { animation: enter-loop $dur-loop $easing infinite; }
.gallery.is-demo .tile:nth-child(2) { animation-delay: -0.2s; }
.gallery.is-demo .tile:nth-child(3) { animation-delay: -0.4s; }
.gallery.is-demo .tile:nth-child(4) { animation-delay: -0.6s; }
.gallery.is-demo .tile:nth-child(5) { animation-delay: -0.8s; }
.gallery.is-demo .tile:nth-child(6) { animation-delay: -1.0s; }
@keyframes enter-loop {
0% { opacity: .35; transform: scale(.88); }
16%, 50% { opacity: 1; transform: scale(1); }
66% { opacity: 1; transform: scale(1.08); }
100% { opacity: .35; transform: scale(.88); }
}
@media (hover: none) {
.tile:active { transform: scale(1.08); }
}
@media (prefers-reduced-motion: reduce) {
.tile { transition: none; opacity: 1; transform: scale(1); }
.gallery.is-demo .tile { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(56px, 1fr));
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: 210px;
max-width: 100%;
}
.tile {
aspect-ratio: 1/1;
border-radius: 8px;
outline: none;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 1;
transform: scale(1);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tile:nth-child(odd) {
background: linear-gradient(160deg, #fff7e6 0%, #ff4d1f 100%);
}
.tile:nth-child(even) {
background: linear-gradient(20deg, #17141a 0%, #fff7e6 100%);
}
.tile:hover, .tile:focus-visible, .tile:active {
transform: scale(1.08);
}
.gallery.is-demo .tile {
animation: enter-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.gallery.is-demo .tile:nth-child(2) {
animation-delay: -0.2s;
}
.gallery.is-demo .tile:nth-child(3) {
animation-delay: -0.4s;
}
.gallery.is-demo .tile:nth-child(4) {
animation-delay: -0.6s;
}
.gallery.is-demo .tile:nth-child(5) {
animation-delay: -0.8s;
}
.gallery.is-demo .tile:nth-child(6) {
animation-delay: -1s;
}
@keyframes enter-loop {
0% {
opacity: 0.35;
transform: scale(0.88);
}
16%, 50% {
opacity: 1;
transform: scale(1);
}
66% {
opacity: 1;
transform: scale(1.08);
}
100% {
opacity: 0.35;
transform: scale(0.88);
}
}
@media (hover: none) {
.tile:active {
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.tile {
transition: none;
opacity: 1;
transform: scale(1);
}
.gallery.is-demo .tile {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./AutoFitGallery.module.scss";
export type AutoFitGalleryProps = {
/** 타일 개수 (기본 6) */
count?: number;
/** 타일이 자리를 잡는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 타일 그러데이션 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** grid-template-columns: repeat(auto-fit, minmax()) 한 줄로 미디어쿼리 없이 칸 수가 스스로 바뀐다 */
export default function AutoFitGallery({
count = 6,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: AutoFitGalleryProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="Photo gallery">
{Array.from({ length: count }, (_, i) => (
<li key={i} className={`${styles.tile} ${i % 2 ? styles.even : styles.odd}`} tabIndex={0} />
))}
</ul>
);
}03 · 자동 반응형 그리드 — 측정 누적면적 23.517% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
카테고리마다 사진 수가 다른 상품 갤러리에 씁니다.
.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(56px, 1fr));
gap: $sp-2;
}
.tile {
opacity: .35;
transform: scale(.88);
transition: transform $duration $easing, opacity $duration $easing;
}
.tile.is-visible {
opacity: 1;
transform: scale(1);
}
04멀티 컬럼 워터폴
무엇이 움직이나
사진 다섯 장에 columns: 2만 걸면 자바스크립트 없이도 높이가 제각각인 사진이 컬럼을 따라 빈틈없이 쌓이는 워터폴 레이아웃이 되고, break-inside: avoid로 사진이 컬럼 경계에서 반으로 잘리는 걸 막습니다. 핀터레스트 스타일 무드보드에 어울립니다.

<main class="stage">
<ul class="gallery is-demo" aria-label="사진 갤러리">
<li class="tile tile--a" tabindex="0"></li>
<li class="tile tile--b" tabindex="0"></li>
<li class="tile tile--c" tabindex="0"></li>
<li class="tile tile--d" tabindex="0"></li>
<li class="tile tile--e" tabindex="0"></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 타일이 나타나는 시간
$easing: $ease-out;
$color: $subject-blue; // 타일 그러데이션
@include stage($stage-paper);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.gallery {
columns: 2;
column-gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: 176px;
max-width: 100%;
}
.tile {
break-inside: avoid;
margin: 0 0 $sp-2;
border-radius: $r-sm;
outline: none;
cursor: pointer;
box-shadow: $shadow-press;
opacity: 1;
transform: translateY(0);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.tile--a { height: 40px; background: linear-gradient(150deg, $color 0%, $stage-ink 100%); }
.tile--b { height: 52px; background: linear-gradient(210deg, $subject-cream 0%, $color 100%); }
.tile--c { height: 34px; background: linear-gradient(60deg, $stage-ink 0%, $color 100%); }
.tile--d { height: 46px; background: linear-gradient(160deg, $color 0%, $subject-cream 100%); }
.tile--e { height: 38px; background: linear-gradient(320deg, $subject-cream 0%, $stage-ink 100%); }
.tile:hover, .tile:focus-visible, .tile:active {
transform: translateY(-3px);
box-shadow: $shadow-raised;
}
.gallery.is-demo .tile { animation: rise-loop $dur-loop $easing infinite; }
.gallery.is-demo .tile--b { animation-delay: -0.4s; }
.gallery.is-demo .tile--c { animation-delay: -0.8s; }
.gallery.is-demo .tile--d { animation-delay: -1.2s; }
.gallery.is-demo .tile--e { animation-delay: -1.6s; }
@keyframes rise-loop {
0%, 8% { transform: translateY(0); box-shadow: $shadow-press; }
24%, 40% { transform: translateY(-3px); box-shadow: $shadow-raised; }
56%, 100% { transform: translateY(0); box-shadow: $shadow-press; }
}
@media (hover: none) {
.tile:active { transform: translateY(-3px); box-shadow: $shadow-raised; }
}
@media (prefers-reduced-motion: reduce) {
.tile { transition: none; }
.gallery.is-demo .tile { animation: none; }
}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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.gallery {
columns: 2;
column-gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: 176px;
max-width: 100%;
}
.tile {
break-inside: avoid;
margin: 0 0 8px;
border-radius: 8px;
outline: none;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 1;
transform: translateY(0);
transition: transform 220ms ease-out, box-shadow 220ms ease-out;
}
.tile--a {
height: 40px;
background: linear-gradient(150deg, #2f6df6 0%, #17141a 100%);
}
.tile--b {
height: 52px;
background: linear-gradient(210deg, #fff7e6 0%, #2f6df6 100%);
}
.tile--c {
height: 34px;
background: linear-gradient(60deg, #17141a 0%, #2f6df6 100%);
}
.tile--d {
height: 46px;
background: linear-gradient(160deg, #2f6df6 0%, #fff7e6 100%);
}
.tile--e {
height: 38px;
background: linear-gradient(320deg, #fff7e6 0%, #17141a 100%);
}
.tile:hover, .tile:focus-visible, .tile:active {
transform: translateY(-3px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.gallery.is-demo .tile {
animation: rise-loop 2s ease-out infinite;
}
.gallery.is-demo .tile--b {
animation-delay: -0.4s;
}
.gallery.is-demo .tile--c {
animation-delay: -0.8s;
}
.gallery.is-demo .tile--d {
animation-delay: -1.2s;
}
.gallery.is-demo .tile--e {
animation-delay: -1.6s;
}
@keyframes rise-loop {
0%, 8% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
24%, 40% {
transform: translateY(-3px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
56%, 100% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@media (hover: none) {
.tile:active {
transform: translateY(-3px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.tile {
transition: none;
}
.gallery.is-demo .tile {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./MasonryColumnsGallery.module.scss";
export type MasonryColumnsGalleryProps = {
/** 타일 높이(px) 배열 — 사진마다 다른 높이 */
heights?: number[];
/** 타일이 나타나는 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** columns: 2 만으로 자바스크립트 없이 워터폴을 만든다. break-inside: avoid 로 사진이 반으로 잘리지 않는다 */
export default function MasonryColumnsGallery({
heights = [40, 52, 34, 46, 38],
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: MasonryColumnsGalleryProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="Photo gallery">
{heights.map((h, i) => (
<li key={i} className={styles.tile} style={{ height: h }} tabIndex={0} />
))}
</ul>
);
}04 · 멀티 컬럼 워터폴 — 측정 누적면적 3.547% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
.gallery {
columns: 2;
column-gap: $sp-2;
}
.tile {
break-inside: avoid;
margin: 0 0 $sp-2;
transition: transform $duration $easing;
}
.tile:hover, .tile:focus-visible {
transform: translateY(-3px);
}
05그리드 워터폴 (읽는 순서 유지)
무엇이 움직이나
grid-auto-rows를 4px 같은 아주 작은 단위로 쪼개고 사진마다 자기 높이에 맞는 grid-row-end: span N을 미리 계산해 주면, 컬럼 방식과 달리 사진이 위→아래가 아니라 왼쪽→오른쪽 읽는 순서 그대로 배치되는 워터폴이 됩니다.

<main class="stage">
<ul class="gallery is-demo" aria-label="사진 갤러리">
<li class="tile tile--1" tabindex="0"></li>
<li class="tile tile--2" tabindex="0"></li>
<li class="tile tile--3" tabindex="0"></li>
<li class="tile tile--4" tabindex="0"></li>
<li class="tile tile--5" tabindex="0"></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 타일이 나타나는 시간
$easing: $ease-out;
$color: $subject-cream; // 타일 그러데이션
@include stage($stage-ink);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.gallery {
display: grid;
grid-auto-flow: row dense;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 4px;
column-gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: 176px;
max-width: 100%;
}
.tile {
margin-bottom: $sp-2;
border-radius: $r-sm;
outline: none;
cursor: pointer;
box-shadow: $shadow-press;
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
}
// 사진마다 자기 높이에 맞는 span — 읽는 순서(1→2→3→4→5)대로 배치된다
.tile--1 { grid-row-end: span 10; background: linear-gradient(150deg, $color 0%, $stage-ink 100%); }
.tile--2 { grid-row-end: span 13; background: linear-gradient(210deg, $subject-blue 0%, $color 100%); }
.tile--3 { grid-row-end: span 9; background: linear-gradient(60deg, $stage-ink 0%, $color 100%); }
.tile--4 { grid-row-end: span 12; background: linear-gradient(300deg, $color 0%, $subject-blue 100%); }
.tile--5 { grid-row-end: span 10; background: linear-gradient(20deg, $subject-blue 0%, $stage-ink 100%); }
.tile:hover, .tile:focus-visible, .tile:active {
transform: scale(1.05);
box-shadow: $shadow-raised;
}
.gallery.is-demo .tile { animation: pop-loop $dur-loop $easing infinite; }
.gallery.is-demo .tile--2 { animation-delay: -0.4s; }
.gallery.is-demo .tile--3 { animation-delay: -0.8s; }
.gallery.is-demo .tile--4 { animation-delay: -1.2s; }
.gallery.is-demo .tile--5 { animation-delay: -1.6s; }
@keyframes pop-loop {
0%, 8% { transform: scale(1); box-shadow: $shadow-press; }
24%, 40% { transform: scale(1.05); box-shadow: $shadow-raised; }
56%, 100% { transform: scale(1); box-shadow: $shadow-press; }
}
@media (hover: none) {
.tile:active { transform: scale(1.05); box-shadow: $shadow-raised; }
}
@media (prefers-reduced-motion: reduce) {
.tile { transition: none; }
.gallery.is-demo .tile { animation: 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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.gallery {
display: grid;
grid-auto-flow: row dense;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 4px;
column-gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: 176px;
max-width: 100%;
}
.tile {
margin-bottom: 8px;
border-radius: 8px;
outline: none;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: scale(1);
transition: transform 220ms ease-out, box-shadow 220ms ease-out;
}
.tile--1 {
grid-row-end: span 10;
background: linear-gradient(150deg, #fff7e6 0%, #17141a 100%);
}
.tile--2 {
grid-row-end: span 13;
background: linear-gradient(210deg, #2f6df6 0%, #fff7e6 100%);
}
.tile--3 {
grid-row-end: span 9;
background: linear-gradient(60deg, #17141a 0%, #fff7e6 100%);
}
.tile--4 {
grid-row-end: span 12;
background: linear-gradient(300deg, #fff7e6 0%, #2f6df6 100%);
}
.tile--5 {
grid-row-end: span 10;
background: linear-gradient(20deg, #2f6df6 0%, #17141a 100%);
}
.tile:hover, .tile:focus-visible, .tile:active {
transform: scale(1.05);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.gallery.is-demo .tile {
animation: pop-loop 2s ease-out infinite;
}
.gallery.is-demo .tile--2 {
animation-delay: -0.4s;
}
.gallery.is-demo .tile--3 {
animation-delay: -0.8s;
}
.gallery.is-demo .tile--4 {
animation-delay: -1.2s;
}
.gallery.is-demo .tile--5 {
animation-delay: -1.6s;
}
@keyframes pop-loop {
0%, 8% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
24%, 40% {
transform: scale(1.05);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
56%, 100% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@media (hover: none) {
.tile:active {
transform: scale(1.05);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.tile {
transition: none;
}
.gallery.is-demo .tile {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./MasonryGridGallery.module.scss";
export type MasonryGridGalleryProps = {
/** 4px 행 단위로 각 사진이 차지할 span 수 — 자기 높이에 맞게 미리 계산한다 */
spans?: number[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** grid-auto-rows 를 작은 단위로 쪼개고 grid-row-end: span N 을 주면, 컬럼 방식과 달리 읽는 순서(왼→오)가 유지된다 */
export default function MasonryGridGallery({
spans = [10, 13, 9, 12, 10],
duration = 220,
easing = "ease-out",
color = "#fff7e6",
}: MasonryGridGalleryProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="Photo gallery">
{spans.map((s, i) => (
<li key={i} className={styles.tile} style={{ gridRowEnd: `span ${s}` }} tabIndex={0} />
))}
</ul>
);
}05 · 그리드 워터폴 (읽는 순서 유지) — 측정 누적면적 2.089% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
발행 순서가 중요한 뉴스·포트폴리오 갤러리에 맞습니다.
.gallery {
display: grid;
grid-auto-flow: row dense;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 4px;
column-gap: $sp-2;
}
.tile--1 { grid-row-end: span 10; }
.tile--2 { grid-row-end: span 13; }
.tile--3 { grid-row-end: span 9; }
06정사각 그리드 고정
무엇이 움직이나
모든 타일에 aspect-ratio: 1 / 1과 object-fit: cover를 걸어 원본 사진 비율과 상관없이 인스타그램 피드처럼 완벽한 정사각형만 나열되게 하고, 호버하면 타일이 살짝 떠오르면서 안쪽 사진도 함께 확대됩니다.

<main class="stage">
<ul class="gallery is-demo" aria-label="사진 갤러리">
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='420' height='300'%3E%3Crect width='420' height='300' fill='%2317141a'/%3E%3C/svg%3E"></li>
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='420'%3E%3Crect width='300' height='420' fill='%232f6df6'/%3E%3C/svg%3E"></li>
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='360'%3E%3Crect width='360' height='360' fill='%2317141a'/%3E%3C/svg%3E"></li>
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='420'%3E%3Crect width='260' height='420' fill='%232f6df6'/%3E%3C/svg%3E"></li>
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='440' height='280'%3E%3Crect width='440' height='280' fill='%2317141a'/%3E%3C/svg%3E"></li>
<li class="cell" tabindex="0"><img class="photo" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='340' height='340'%3E%3Crect width='340' height='340' fill='%232f6df6'/%3E%3C/svg%3E"></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 프레임 안에서 확대되는 시간
$easing: $ease-out;
$color: $stage-ink; // 프레임 배경(로딩 중 틈)
@include stage($stage-yellow);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: 184px;
max-width: 100%;
}
.cell {
aspect-ratio: 1 / 1;
border-radius: $r-sm;
overflow: hidden;
outline: none;
cursor: pointer;
background: $color;
box-shadow: $shadow-press;
transform: translateY(0);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.photo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1);
transition: transform $duration $easing;
}
// 프레임(.cell) 자체도 살짝 떠올라야 움직임이 보인다 — 단색 사진만 확대하면 크롭된 색이 그대로라 픽셀 차이가 0 이다 (132 실측)
.cell:hover, .cell:focus-visible, .cell:active { transform: translateY(-3px); box-shadow: $shadow-raised; }
.cell:hover .photo, .cell:focus-visible .photo, .cell:active .photo { transform: scale(1.1); }
.gallery.is-demo .cell { animation: lift-loop $dur-loop $easing infinite; }
.gallery.is-demo .photo { animation: crop-zoom $dur-loop $easing infinite; }
.gallery.is-demo .cell:nth-child(2) { animation-delay: -0.33s; }
.gallery.is-demo .cell:nth-child(3) { animation-delay: -0.66s; }
.gallery.is-demo .cell:nth-child(4) { animation-delay: -1.0s; }
.gallery.is-demo .cell:nth-child(5) { animation-delay: -1.33s; }
.gallery.is-demo .cell:nth-child(6) { animation-delay: -1.66s; }
.gallery.is-demo .cell:nth-child(2) .photo { animation-delay: -0.33s; }
.gallery.is-demo .cell:nth-child(3) .photo { animation-delay: -0.66s; }
.gallery.is-demo .cell:nth-child(4) .photo { animation-delay: -1.0s; }
.gallery.is-demo .cell:nth-child(5) .photo { animation-delay: -1.33s; }
.gallery.is-demo .cell:nth-child(6) .photo { animation-delay: -1.66s; }
@keyframes lift-loop {
0%, 8% { transform: translateY(0); box-shadow: $shadow-press; }
25%, 40% { transform: translateY(-3px); box-shadow: $shadow-raised; }
58%, 100% { transform: translateY(0); box-shadow: $shadow-press; }
}
@keyframes crop-zoom {
0%, 8% { transform: scale(1); }
25%, 40% { transform: scale(1.1); }
58%, 100% { transform: scale(1); }
}
@media (hover: none) {
.cell:active .photo { transform: scale(1.1); }
}
@media (prefers-reduced-motion: reduce) {
.photo { transition: none; }
.gallery.is-demo .photo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: 184px;
max-width: 100%;
}
.cell {
aspect-ratio: 1/1;
border-radius: 8px;
overflow: hidden;
outline: none;
cursor: pointer;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(0);
transition: transform 300ms ease-out, box-shadow 300ms ease-out;
}
.photo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1);
transition: transform 300ms ease-out;
}
.cell:hover, .cell:focus-visible, .cell:active {
transform: translateY(-3px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cell:hover .photo, .cell:focus-visible .photo, .cell:active .photo {
transform: scale(1.1);
}
.gallery.is-demo .cell {
animation: lift-loop 2s ease-out infinite;
}
.gallery.is-demo .photo {
animation: crop-zoom 2s ease-out infinite;
}
.gallery.is-demo .cell:nth-child(2) {
animation-delay: -0.33s;
}
.gallery.is-demo .cell:nth-child(3) {
animation-delay: -0.66s;
}
.gallery.is-demo .cell:nth-child(4) {
animation-delay: -1s;
}
.gallery.is-demo .cell:nth-child(5) {
animation-delay: -1.33s;
}
.gallery.is-demo .cell:nth-child(6) {
animation-delay: -1.66s;
}
.gallery.is-demo .cell:nth-child(2) .photo {
animation-delay: -0.33s;
}
.gallery.is-demo .cell:nth-child(3) .photo {
animation-delay: -0.66s;
}
.gallery.is-demo .cell:nth-child(4) .photo {
animation-delay: -1s;
}
.gallery.is-demo .cell:nth-child(5) .photo {
animation-delay: -1.33s;
}
.gallery.is-demo .cell:nth-child(6) .photo {
animation-delay: -1.66s;
}
@keyframes lift-loop {
0%, 8% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
25%, 40% {
transform: translateY(-3px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
58%, 100% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@keyframes crop-zoom {
0%, 8% {
transform: scale(1);
}
25%, 40% {
transform: scale(1.1);
}
58%, 100% {
transform: scale(1);
}
}
@media (hover: none) {
.cell:active .photo {
transform: scale(1.1);
}
}
@media (prefers-reduced-motion: reduce) {
.photo {
transition: none;
}
.gallery.is-demo .photo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./SquareGridGallery.module.scss";
export type Photo = { src: string; alt?: string };
export type SquareGridGalleryProps = {
photos?: Photo[];
duration?: number;
easing?: string;
color?: string;
};
const swatch = (hex: string) =>
`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Crect width='400' height='400' fill='%23${hex}'/%3E%3C/svg%3E`;
const DEFAULT_PHOTOS: Photo[] = ["17141a", "2f6df6", "17141a", "2f6df6", "17141a", "2f6df6"].map((h) => ({ src: swatch(h) }));
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** aspect-ratio: 1/1 + object-fit: cover 로 원본 비율과 상관없이 정사각형만 나열한다 (인스타그램 피드형) */
export default function SquareGridGallery({
photos = DEFAULT_PHOTOS,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: SquareGridGalleryProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="Photo gallery">
{photos.map((p, i) => (
<li key={i} className={styles.cell} tabIndex={0}>
<img className={styles.photo} src={p.src} alt={p.alt ?? ""} />
</li>
))}
</ul>
);
}06 · 정사각 그리드 고정 — 측정 누적면적 3.329% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
.cell {
aspect-ratio: 1 / 1;
overflow: hidden;
}
.photo {
width: 100%; height: 100%;
object-fit: cover;
transform: scale(1);
transition: transform $duration $easing;
}
.cell:hover .photo {
transform: scale(1.1);
}
07첫 장 강조 배치
무엇이 움직이나
첫 번째 사진에만 grid-column: span 2와 grid-row: span 2를 줘서 나머지보다 네 배 크게 띄우고, 그 옆으로 작은 사진들이 채워지는 잡지 레이아웃을 순수 CSS 선택자 하나로 만듭니다.

<main class="stage">
<ul class="gallery is-demo" aria-label="사진 갤러리">
<li class="tile tile--featured" tabindex="0"></li>
<li class="tile tile--small" tabindex="0"></li>
<li class="tile tile--small" tabindex="0"></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 강조 타일이 커지는 시간
$easing: $ease-spring;
$color: $subject-cream; // 타일 그러데이션
@include stage($stage-orange);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.gallery {
display: grid;
grid-template-columns: repeat(3, 56px);
grid-template-rows: repeat(2, 56px);
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: 184px;
max-width: 100%;
}
.tile {
border-radius: $r-sm;
outline: none;
cursor: pointer;
box-shadow: $shadow-press;
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.tile--featured {
grid-column: 1 / 3;
grid-row: 1 / 3;
background: linear-gradient(150deg, $color 0%, $stage-orange 100%);
}
.tile--small:nth-of-type(2) { background: linear-gradient(60deg, $stage-ink 0%, $color 100%); }
.tile--small:nth-of-type(3) { background: linear-gradient(200deg, $color 0%, $stage-ink 100%); }
.tile:hover, .tile:focus-visible, .tile:active {
transform: scale(1.04);
box-shadow: $shadow-raised;
}
.gallery.is-demo .tile--featured { animation: pop-loop-lg $dur-loop $easing infinite; }
.gallery.is-demo .tile--small { animation: pop-loop-sm $dur-loop $easing infinite; }
.gallery.is-demo .tile--small:nth-of-type(3) { animation-delay: -0.3s; }
@keyframes pop-loop-lg {
0%, 8% { transform: scale(1); box-shadow: $shadow-press; }
30%, 50% { transform: scale(1.03); box-shadow: $shadow-raised; }
70%, 100% { transform: scale(1); box-shadow: $shadow-press; }
}
@keyframes pop-loop-sm {
0%, 40% { transform: scale(1); box-shadow: $shadow-press; }
60%, 80% { transform: scale(1.06); box-shadow: $shadow-raised; }
100% { transform: scale(1); box-shadow: $shadow-press; }
}
@media (hover: none) {
.tile:active { transform: scale(1.04); box-shadow: $shadow-raised; }
}
@media (prefers-reduced-motion: reduce) {
.tile { transition: none; }
.gallery.is-demo .tile--featured, .gallery.is-demo .tile--small { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.gallery {
display: grid;
grid-template-columns: repeat(3, 56px);
grid-template-rows: repeat(2, 56px);
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: 184px;
max-width: 100%;
}
.tile {
border-radius: 8px;
outline: none;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: scale(1);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tile--featured {
grid-column: 1/3;
grid-row: 1/3;
background: linear-gradient(150deg, #fff7e6 0%, #ff4d1f 100%);
}
.tile--small:nth-of-type(2) {
background: linear-gradient(60deg, #17141a 0%, #fff7e6 100%);
}
.tile--small:nth-of-type(3) {
background: linear-gradient(200deg, #fff7e6 0%, #17141a 100%);
}
.tile:hover, .tile:focus-visible, .tile:active {
transform: scale(1.04);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.gallery.is-demo .tile--featured {
animation: pop-loop-lg 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.gallery.is-demo .tile--small {
animation: pop-loop-sm 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.gallery.is-demo .tile--small:nth-of-type(3) {
animation-delay: -0.3s;
}
@keyframes pop-loop-lg {
0%, 8% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
30%, 50% {
transform: scale(1.03);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
70%, 100% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@keyframes pop-loop-sm {
0%, 40% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
60%, 80% {
transform: scale(1.06);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
100% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@media (hover: none) {
.tile:active {
transform: scale(1.04);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.tile {
transition: none;
}
.gallery.is-demo .tile--featured, .gallery.is-demo .tile--small {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./FeaturedGallery.module.scss";
export type FeaturedGalleryProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 첫 번째 사진에만 grid-column/grid-row span 2 를 줘서 네 배 크게 띄우는 잡지형 레이아웃 */
export default function FeaturedGallery({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: FeaturedGalleryProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="Photo gallery">
<li className={styles.featured} tabIndex={0} />
<li className={styles.small} tabIndex={0} />
<li className={styles.small} tabIndex={0} />
</ul>
);
}07 · 첫 장 강조 배치 — 측정 누적면적 7.026% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
대표 사진 하나를 강조하는 블로그 헤더 갤러리에 씁니다.
.gallery {
display: grid;
grid-template-columns: repeat(3, 56px);
grid-template-rows: repeat(2, 56px);
gap: $sp-2;
}
.tile:first-child {
grid-column: 1 / 3;
grid-row: 1 / 3;
}
08호버 캡션 오버레이
무엇이 움직이나
평소엔 사진 아래쪽에 살짝 가려져 있던 반투명 띠가 호버하면 translateY로 완전히 올라오면서 제목과 위치 같은 캡션 글자가 함께 드러납니다.

<main class="stage">
<ul class="gallery is-demo" aria-label="사진 갤러리">
<li class="tile" tabindex="0">
<span class="tile__media" aria-hidden="true"></span>
<div class="tile__caption">
<h3 class="tile__title">노을 지는 해변</h3>
<p class="tile__desc">저녁 6시 촬영</p>
</div>
</li>
<li class="tile" tabindex="0">
<span class="tile__media tile__media--b" aria-hidden="true"></span>
<div class="tile__caption">
<h3 class="tile__title">숲 속 오두막</h3>
<p class="tile__desc">이른 아침 산책</p>
</div>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 캡션이 올라오는 시간
$easing: $ease-out;
$color: $subject-blue; // 사진 그러데이션
@include stage($stage-paper);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: 228px;
max-width: 100%;
}
.tile {
position: relative;
aspect-ratio: 4 / 3;
border-radius: $r-sm;
overflow: hidden;
outline: none;
cursor: pointer;
box-shadow: $shadow-press;
}
.tile__media {
position: absolute; inset: 0;
background: linear-gradient(155deg, $color 0%, $stage-ink 100%);
}
.tile__media--b { background: linear-gradient(200deg, $subject-cream 0%, $stage-ink 100%); }
.tile__caption {
position: absolute; left: 0; right: 0; bottom: 0;
padding: $sp-1 $sp-2;
background: linear-gradient(0deg, rgba($stage-ink, .88), rgba($stage-ink, 0));
transform: translateY(64%);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.tile__title { margin: 0; font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.tile__desc { margin: 0; font-weight: 500; font-size: clamp(9px, 2.6vw, 10px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .85); }
.tile:hover .tile__caption, .tile:focus-visible .tile__caption, .tile:active .tile__caption {
transform: translateY(0);
opacity: 1;
}
.gallery.is-demo .tile__caption { animation: caption-loop $dur-loop $easing infinite; }
.gallery.is-demo .tile:nth-child(2) .tile__caption { animation-delay: -1s; }
@keyframes caption-loop {
0%, 10% { transform: translateY(64%); opacity: 0; }
40%, 60% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(64%); opacity: 0; }
}
@media (hover: none) {
.tile:active .tile__caption { transform: translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.tile__caption { transition: none; transform: translateY(0); opacity: 1; }
.gallery.is-demo .tile__caption { animation: none; }
}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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: 228px;
max-width: 100%;
}
.tile {
position: relative;
aspect-ratio: 4/3;
border-radius: 8px;
overflow: hidden;
outline: none;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.tile__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.tile__media--b {
background: linear-gradient(200deg, #fff7e6 0%, #17141a 100%);
}
.tile__caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 4px 8px;
background: linear-gradient(0deg, rgba(23, 20, 26, 0.88), rgba(23, 20, 26, 0));
transform: translateY(64%);
opacity: 0;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.tile__title {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.tile__desc {
margin: 0;
font-weight: 500;
font-size: clamp(9px, 2.6vw, 10px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.85);
}
.tile:hover .tile__caption, .tile:focus-visible .tile__caption, .tile:active .tile__caption {
transform: translateY(0);
opacity: 1;
}
.gallery.is-demo .tile__caption {
animation: caption-loop 2s ease-out infinite;
}
.gallery.is-demo .tile:nth-child(2) .tile__caption {
animation-delay: -1s;
}
@keyframes caption-loop {
0%, 10% {
transform: translateY(64%);
opacity: 0;
}
40%, 60% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(64%);
opacity: 0;
}
}
@media (hover: none) {
.tile:active .tile__caption {
transform: translateY(0);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tile__caption {
transition: none;
transform: translateY(0);
opacity: 1;
}
.gallery.is-demo .tile__caption {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./HoverCaptionGallery.module.scss";
export type CaptionTile = { title: string; desc: string };
export type HoverCaptionGalleryProps = {
items?: CaptionTile[];
duration?: number;
easing?: string;
color?: string;
};
const DEFAULT_ITEMS: CaptionTile[] = [
{ title: "Sunset beach", desc: "Shot at 6 PM" },
{ title: "Cabin in the woods", desc: "Early morning walk" },
];
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 평소엔 살짝 가려진 캡션 띠가 호버하면 translateY 로 완전히 올라오며 제목·설명이 드러난다 */
export default function HoverCaptionGallery({
items = DEFAULT_ITEMS,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: HoverCaptionGalleryProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="Photo gallery">
{items.map((it, i) => (
<li key={i} className={styles.tile} tabIndex={0}>
<span className={styles.media} aria-hidden="true" />
<div className={styles.caption}>
<h3 className={styles.title}>{it.title}</h3>
<p className={styles.desc}>{it.desc}</p>
</div>
</li>
))}
</ul>
);
}08 · 호버 캡션 오버레이 — 측정 누적면적 4.308% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
여행 사진마다 장소 이름을 붙이는 갤러리에 맞습니다.
.caption {
position: absolute; left: 0; right: 0; bottom: 0;
transform: translateY(64%);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.tile:hover .caption, .tile:focus-within .caption {
transform: translateY(0);
opacity: 1;
}
09클릭 시 라이트박스 (JS 없이)
무엇이 움직이나
타일을 클릭하면 popovertarget 속성 하나로 원본 크기 사진이 담긴 팝오버가 뜨고, ::backdrop이 배경을 어둡게 덮는 동안 @starting-style이 사진을 작게·투명하게 시작해 커지며 나타나게 합니다.

<main class="stage">
<div class="lightbox-demo">
<ul class="gallery is-demo" aria-label="사진 갤러리">
<li>
<button class="thumb" type="button" popovertarget="lb-01" aria-label="사진 확대해서 보기">
<img class="thumb__img" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='%232f6df6'/%3E%3C/svg%3E">
</button>
</li>
<li>
<button class="thumb" type="button" popovertarget="lb-02" aria-label="사진 확대해서 보기">
<img class="thumb__img" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='%2317141a'/%3E%3C/svg%3E">
</button>
</li>
<li>
<button class="thumb" type="button" popovertarget="lb-03" aria-label="사진 확대해서 보기">
<img class="thumb__img" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='%23fff7e6'/%3E%3C/svg%3E">
</button>
</li>
<li>
<button class="thumb" type="button" popovertarget="lb-04" aria-label="사진 확대해서 보기">
<img class="thumb__img" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='%232f6df6'/%3E%3C/svg%3E">
</button>
</li>
</ul>
<div class="lightbox" id="lb-01" popover>
<img class="lightbox__img" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='600'%3E%3Crect width='600' height='600' fill='%232f6df6'/%3E%3C/svg%3E">
<button class="lightbox__close" type="button" popovertarget="lb-01" popovertargetaction="hide" aria-label="닫기">×</button>
</div>
<div class="lightbox" id="lb-02" popover>
<img class="lightbox__img" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='600'%3E%3Crect width='600' height='600' fill='%2317141a'/%3E%3C/svg%3E">
<button class="lightbox__close" type="button" popovertarget="lb-02" popovertargetaction="hide" aria-label="닫기">×</button>
</div>
<div class="lightbox" id="lb-03" popover>
<img class="lightbox__img" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='600'%3E%3Crect width='600' height='600' fill='%23fff7e6'/%3E%3C/svg%3E">
<button class="lightbox__close" type="button" popovertarget="lb-03" popovertargetaction="hide" aria-label="닫기">×</button>
</div>
<div class="lightbox" id="lb-04" popover>
<img class="lightbox__img" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='600'%3E%3Crect width='600' height='600' fill='%232f6df6'/%3E%3C/svg%3E">
<button class="lightbox__close" type="button" popovertarget="lb-04" popovertargetaction="hide" aria-label="닫기">×</button>
</div>
<div class="preview-backdrop" aria-hidden="true"></div>
<img class="preview-panel" aria-hidden="true" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='%232f6df6'/%3E%3C/svg%3E">
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 라이트박스가 커지며 나타나는 시간
$easing: $ease-out;
$color: $subject-cream; // 미리보기 배경
@include stage($stage-ink);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.lightbox-demo { position: relative; }
.gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: 148px;
max-width: 100%;
}
.thumb {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
padding: 0;
border: 0;
border-radius: $r-sm;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-press;
background: none;
transition: transform $duration $easing;
}
.thumb__img { display: block; width: 100%; height: 100%; object-fit: cover; }
.thumb:hover, .thumb:focus-visible, .thumb:active { transform: scale(1.05); }
// 실제 라이트박스 — popovertarget 하나로 열고 닫는다, 자바스크립트 없음
.lightbox {
position: fixed;
inset: 0;
margin: auto;
width: min(180px, 60vw);
aspect-ratio: 1 / 1;
padding: 0;
border: 0;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
opacity: 0;
transform: scale(.8);
transition: opacity $duration $easing, transform $duration $easing, overlay $duration $easing allow-discrete, display $duration $easing allow-discrete;
@starting-style { opacity: 0; transform: scale(.8); }
}
.lightbox:popover-open {
opacity: 1;
transform: scale(1);
}
.lightbox::backdrop {
background: rgba($stage-ink, .7);
opacity: 0;
transition: opacity $duration $easing, overlay $duration $easing allow-discrete, display $duration $easing allow-discrete;
@starting-style { opacity: 0; }
}
.lightbox:popover-open::backdrop { opacity: 1; }
.lightbox__img { display: block; width: 100%; height: 100%; object-fit: cover; }
.lightbox__close {
position: absolute; top: $sp-2; right: $sp-2;
width: 22px; height: 22px;
border: 0; border-radius: $r-pill;
background: rgba($stage-ink, .6);
color: $stage-paper;
font-size: 14px; line-height: 1;
cursor: pointer;
}
// 자동 미리보기 루프 — 실제 팝오버는 클릭해야 열리므로, is-demo 동안엔 따로 겹쳐진 미리보기가 열림·닫힘을 흉내낸다
.preview-backdrop {
position: absolute; inset: 0;
border-radius: $r-card;
background: rgba($stage-ink, .7);
opacity: 0;
pointer-events: none;
}
.preview-panel {
position: absolute;
top: 50%; left: 50%;
width: 84px; height: 84px;
border-radius: $r-control;
object-fit: cover;
box-shadow: $shadow-raised;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) scale(.8);
}
.gallery.is-demo ~ .preview-backdrop { animation: backdrop-loop $dur-loop $easing infinite; }
.gallery.is-demo ~ .preview-panel { animation: panel-loop $dur-loop $easing infinite; }
@keyframes backdrop-loop {
0%, 35% { opacity: 0; }
50%, 80% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes panel-loop {
0%, 35% { opacity: 0; transform: translate(-50%, -50%) scale(.8); }
50%, 80% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(.8); }
}
@media (prefers-reduced-motion: reduce) {
.lightbox, .lightbox::backdrop, .thumb { transition: none; }
.gallery.is-demo ~ .preview-backdrop, .gallery.is-demo ~ .preview-panel { animation: 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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.lightbox-demo {
position: relative;
}
.gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: 148px;
max-width: 100%;
}
.thumb {
display: block;
width: 100%;
aspect-ratio: 1/1;
padding: 0;
border: 0;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: none;
transition: transform 300ms ease-out;
}
.thumb__img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb:hover, .thumb:focus-visible, .thumb:active {
transform: scale(1.05);
}
.lightbox {
position: fixed;
inset: 0;
margin: auto;
width: min(180px, 60vw);
aspect-ratio: 1/1;
padding: 0;
border: 0;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: scale(0.8);
transition: opacity 300ms ease-out, transform 300ms ease-out, overlay 300ms ease-out allow-discrete, display 300ms ease-out allow-discrete;
}
@starting-style {
.lightbox {
opacity: 0;
transform: scale(0.8);
}
}
.lightbox:popover-open {
opacity: 1;
transform: scale(1);
}
.lightbox::backdrop {
background: rgba(23, 20, 26, 0.7);
opacity: 0;
transition: opacity 300ms ease-out, overlay 300ms ease-out allow-discrete, display 300ms ease-out allow-discrete;
}
@starting-style {
.lightbox::backdrop {
opacity: 0;
}
}
.lightbox:popover-open::backdrop {
opacity: 1;
}
.lightbox__img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.lightbox__close {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.6);
color: #ffffff;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.preview-backdrop {
position: absolute;
inset: 0;
border-radius: 16px;
background: rgba(23, 20, 26, 0.7);
opacity: 0;
pointer-events: none;
}
.preview-panel {
position: absolute;
top: 50%;
left: 50%;
width: 84px;
height: 84px;
border-radius: 12px;
object-fit: cover;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) scale(0.8);
}
.gallery.is-demo ~ .preview-backdrop {
animation: backdrop-loop 2s ease-out infinite;
}
.gallery.is-demo ~ .preview-panel {
animation: panel-loop 2s ease-out infinite;
}
@keyframes backdrop-loop {
0%, 35% {
opacity: 0;
}
50%, 80% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes panel-loop {
0%, 35% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
50%, 80% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
}
@media (prefers-reduced-motion: reduce) {
.lightbox, .lightbox::backdrop, .thumb {
transition: none;
}
.gallery.is-demo ~ .preview-backdrop, .gallery.is-demo ~ .preview-panel {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./LightboxGallery.module.scss";
export type Photo = { id: string; src: string; full: string };
export type LightboxGalleryProps = {
photos?: Photo[];
duration?: number;
easing?: string;
};
const swatch = (hex: string, size = 200) =>
`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${size}' height='${size}'%3E%3Crect width='${size}' height='${size}' fill='%23${hex}'/%3E%3C/svg%3E`;
const DEFAULT_PHOTOS: Photo[] = ["2f6df6", "17141a", "fff7e6", "2f6df6"].map((hex, i) => ({
id: `lb-${i + 1}`,
src: swatch(hex, 200),
full: swatch(hex, 600),
}));
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
/**
* popovertarget 하나로 여는 라이트박스 — 자바스크립트 없음.
* ::backdrop 이 배경을 어둡게 덮고, @starting-style 이 작게·투명하게 시작해 커지며 나타난다.
*/
export default function LightboxGallery({
photos = DEFAULT_PHOTOS,
duration = 300,
easing = "ease-out",
}: LightboxGalleryProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<div className={styles.wrap} style={vars}>
<ul className={styles.gallery} aria-label="Photo gallery">
{photos.map((p) => (
<li key={p.id}>
<button className={styles.thumb} type="button" popoverTarget={p.id} aria-label="View enlarged photo">
<img className={styles.thumbImg} src={p.src} alt="" />
</button>
</li>
))}
</ul>
{photos.map((p) => (
<div key={p.id} className={styles.lightbox} id={p.id} popover="auto">
<img className={styles.lightboxImg} src={p.full} alt="" />
<button
className={styles.close}
type="button"
popoverTarget={p.id}
popoverTargetAction="hide"
aria-label="Close"
>
×
</button>
</div>
))}
</div>
);
}09 · 클릭 시 라이트박스 (JS 없이) — 측정 누적면적 11.947% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
<button popovertarget="lb-01">과 <div id="lb-01" popover>만 짝지으면 열고 닫는 로직 전부를 브라우저가 대신합니다.
.lightbox {
opacity: 0;
transform: scale(.8);
transition: opacity $duration $easing, transform $duration $easing,
overlay $duration $easing allow-discrete, display $duration $easing allow-discrete;
@starting-style { opacity: 0; transform: scale(.8); }
}
.lightbox:popover-open { opacity: 1; transform: scale(1); }
.lightbox::backdrop { background: rgba($stage-ink, .7); opacity: 0; }
.lightbox:popover-open::backdrop { opacity: 1; }
어디서 깨지는가 — 함정 하나
css 갤러리, 어디서 깨지나요?
05를 처음 렌더링했을 때 두 번째 컬럼 위쪽에 예상치 못한 빈 칸이 생겼습니다. grid-auto-flow: row(dense 없이)로 두면 브라우저는 span이 큰 타일 뒤에 빈 칸을 그냥 남겨 두고 다음 줄로 넘어가 버리기 때문입니다. dense를 붙이자 그 구멍은 메워졌지만, dense는 DOM 순서보다 빈 칸 채우기를 우선하기 때문에 스팬 조합에 따라 타일이 실제 DOM 순서와 다른 위치에 그려질 수 있습니다. 04번(멀티 컬럼)이라면 애초에 순서가 위→아래·왼쪽 컬럼부터라 이런 문제가 없지만, 05번(그리드 행 스팬)은 dense를 켜는 순간 이 위험을 떠안게 된다는 뜻입니다. 칸 안에서 사진이 마우스를 따라 기울기까지 하는 연출은 마우스를 따라오는 3D 회전 장면 쪽에서 다뤘습니다. 발행 날짜처럼 순서 자체가 의미 있는 갤러리라면 dense를 빼고 빈 칸을 감수하거나, 압축 파일 비밀번호 24p2nyh2과 함께 들어 있는 vanilla 폴더의 span 값을 직접 조정해 빈 칸이 생기지 않게 미리 계산해야 합니다.
접근성
챙긴 것
prefers-reduced-motion: reduce를 켠 화면에서는 아홉 개 모두 전환·루프만 꺼지고 결과 상태는 그대로 남도록 맞췄습니다. 01·02·06은 확대·이동이 순간적으로 적용되고, 03은 타일이 전환 없이 바로 제자리(불투명·원래 크기)로 나타나고, 04·05는 떠오름 없이 바로 제자리에 멈추고, 07은 강조 크기가 즉시 적용되고, 08은 캡션이 늘 펼쳐진 채로 고정되고, 09는 라이트박스가 전환 없이 바로 열리고 닫힙니다. 09의 클릭 트리거는 <button> 요소라 마우스뿐 아니라 키보드 Tab·Enter로도 열리고, popovertargetaction="hide" 닫기 버튼도 같은 방식으로 접근합니다. 나머지 여덟 개도 호버 전용 동작을 :focus-visible이나 :focus-within에도 걸어 두어 키보드 사용자가 마우스 없이도 같은 결과를 봅니다. CSS 그리드 메이슨리 표준화 논의는 MDN 문서에서, Popover API 지원 범위는 MDN Popover API에서 확인할 수 있습니다. 카드형 호버 효과가 더 궁금하면 이전에 올린 카드 호버 모음과 글래스모피즘 모음도 함께 보면 좋습니다.
FAQ
09 라이트박스는 Popover API를 지원하지 않는 브라우저에서 어떻게 됩니까?
popovertarget 속성을 브라우저가 모르면 그 속성은 그냥 무시되어 버튼이 평범한 버튼으로 남고 클릭해도 아무 일이 일어나지 않습니다. 레이아웃이 깨지거나 에러 없이 기능만 조용히 빠지는 안전한 방식이고, 최신 Chrome·Edge·Safari·Firefox는 이미 지원합니다.
이미지 갤러리 만들기에서 04와 05 중 어떤 워터폴을 써야 합니까?
사진이 위에서 아래로 읽혀도 상관없다면(무드보드·갤러리) columns 방식(04)이 코드가 짧고 관리하기 쉽습니다. 최신순 정렬처럼 왼쪽 위부터 순서대로 읽혀야 한다면 그리드 방식(05)을 쓰고, 함정 문단에서 다룬 dense의 순서 어긋남 위험을 감안해야 합니다. 정렬 순서를 사람이 직접 끌어 바꾸는 갤러리는 포트폴리오 갤러리 UI에서 따로 다뤘습니다.
React 판에서 03 자동 반응형 그리드는 칸 수를 어떻게 계산합니까?
AutoFitGallery 컴포넌트는 칸 수를 계산하지 않습니다. grid-template-columns: repeat(auto-fit, minmax())가 컨테이너 폭만 보고 브라우저 안에서 스스로 계산하므로, 리사이즈 이벤트를 듣거나 상태를 두지 않아도 창 크기가 바뀌면 자동으로 다시 배치됩니다.









