9 CSS Image Gallery Grid Effects
CSS image gallery grid effects arrange photos into a grid or waterfall that reacts to hover and click, as pretty as the gallery you saved from Pinterest — but
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Even 2-column grid
- 02 3-column grid, cropped to match
- 03 Auto-responsive grid, no media query
- 04 Multi-column waterfall (columns)
- 05 Grid-row-span waterfall (keeps reading order)
- 06 Square grid lock (1:1)
- 07 Featured-first layout
- 08 Hover caption overlay
- 09 Click-to-open lightbox, zero JS
These nine are ordered the way you actually hit problems while building a real gallery, not by popularity. Start with the plainest even grid (01), solve the real-world mess of mismatched photo ratios (02), add a responsive layout that resizes itself (03), compare two ways to stack photos of different heights into a waterfall (04-05), lock every tile into a perfect square or blow up one hero photo (06-07), then finish with the interactions people actually click: captions and a full-size lightbox (08-09). Stacking two photos and dragging a handle between them is its own set, 9 Before After Slider UIs, and widening the panes themselves belongs to 9 Resizable Split Pane Layouts.
| # | Name | What moves | JS |
|---|---|---|---|
| 01 | Even 2-column grid | Tile translateY/scale |
None |
| 02 | 3-col grid, cropped to match | Photo scale, frame translateY |
None |
| 03 | Auto-responsive grid | Tile opacity/scale entrance |
None |
| 04 | Multi-column waterfall | Tile translateY |
None |
| 05 | Grid-row-span waterfall | Tile scale |
None |
| 06 | Square grid lock (1:1) | Photo scale, frame translateY |
None |
| 07 | Featured-first layout | Tile scale |
None |
| 08 | Hover caption overlay | Caption translateY/opacity |
None |
| 09 | Click-to-open lightbox | Panel scale/opacity |
None |
01Even 2-column grid
What moves
Four photos sit in an even 2×2 grid, and the css image hover lifts only the tile under the cursor, deepening its shadow to mark which one you're looking at.

<main class="stage">
<ul class="gallery is-demo" aria-label="Photo gallery">
<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 · Even 2-column grid — Measured area 3.564% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
It's the plainest layout there is, good for a 4-6 photo portfolio row or photos scattered through a blog post.
.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-col grid, cropped to match
What moves
Even when tall, wide, and square photos are mixed together, giving every image aspect-ratio: 4/3 and object-fit: cover locks every cell to the same size, and hovering zooms in only inside that frame.

<main class="stage">
<ul class="gallery is-demo" aria-label="Photo gallery">
<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-column grid, cropped to match — Measured area 3.778% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
Built for a gallery of phone photos with mismatched ratios.
.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);
}
03Auto-responsive grid
What moves
A single grid-template-columns: repeat(auto-fit, minmax(56px, 1fr)) line lets the column count adjust itself to the container width with zero media queries, and each new tile fades and scales into place.

<main class="stage">
<ul class="gallery is-demo" aria-label="Photo gallery">
<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 · Auto-responsive grid, no media query — Measured area 23.517% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
Good for product galleries where photo count varies by category.
.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);
}
04Multi-column waterfall
What moves
Giving five photos just columns: 2 stacks photos of different heights into a gapless waterfall down each column with zero JavaScript, and break-inside: avoid stops a photo from being sliced in half across a column break.

<main class="stage">
<ul class="gallery is-demo" aria-label="Photo gallery">
<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 · Multi-column waterfall (columns) — Measured area 3.547% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
This is the classic Pinterest-style moodboard layout.
.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);
}
05Grid-row-span waterfall (keeps reading order)
What moves
Slicing grid-auto-rows into a tiny unit like 4px and pre-computing each photo's own grid-row-end: span N produces a waterfall that, unlike the column version, lays photos out left-to-right in reading order instead of top-to-bottom-then-next-column.

<main class="stage">
<ul class="gallery is-demo" aria-label="Photo gallery">
<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 · Grid-row-span waterfall (keeps reading order) — Measured area 2.089% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
Use it when publish order matters, like a news feed.
.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; }
06Square grid lock (1:1)
What moves
Setting aspect-ratio: 1 / 1 and object-fit: cover on every tile lines up perfect squares like an Instagram feed regardless of the source photo's ratio, and hovering lifts the tile slightly while the photo inside zooms in.

<main class="stage">
<ul class="gallery is-demo" aria-label="Photo gallery">
<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 · Square grid lock (1:1) — Measured area 3.329% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
.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);
}
07Featured-first layout
What moves
Giving only the first photo grid-column: span 2 and grid-row: span 2 makes it four times larger than the rest, filling in smaller photos around it for a magazine-style layout from a single CSS selector.

<main class="stage">
<ul class="gallery is-demo" aria-label="Photo gallery">
<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 · Featured-first layout — Measured area 7.026% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
Good for a blog header gallery that spotlights one hero photo.
.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;
}
08Hover caption overlay
What moves
A translucent band that's normally tucked just below the photo's edge slides fully into view via translateY on hover, bringing the caption text (title, location) up with it.

<main class="stage">
<ul class="gallery is-demo" aria-label="Photo gallery">
<li class="tile" tabindex="0">
<span class="tile__media" aria-hidden="true"></span>
<div class="tile__caption">
<h3 class="tile__title">Sunset beach</h3>
<p class="tile__desc">Shot at 6 PM</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">Cabin in the woods</h3>
<p class="tile__desc">Early morning walk</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 · Hover caption overlay — Measured area 4.308% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
Made for a travel gallery that labels each photo with a place name.
.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;
}
09Click-to-open lightbox, zero JS
What moves
Clicking a tile opens a popover holding the full-size photo using nothing but a popovertarget attribute, ::backdrop dims the page behind it, and @starting-style makes the photo start small and transparent before growing into view.

<main class="stage">
<div class="lightbox-demo">
<ul class="gallery is-demo" aria-label="Photo gallery">
<li>
<button class="thumb" type="button" popovertarget="lb-01" aria-label="View enlarged photo">
<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="View enlarged photo">
<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="View enlarged photo">
<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="View enlarged photo">
<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="Close">×</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="Close">×</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="Close">×</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="Close">×</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 · Click-to-open lightbox, zero JS — Measured area 11.947% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
Pair <button popovertarget="lb-01"> with <div id="lb-01" popover> and the browser handles every bit of the open/close logic.
.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; }
Where it breaks — one real trap
css image gallery examples — where does it break?
With plain grid-auto-flow: row (no dense), the browser's placement cursor only moves forward, so a gap that opens after a tall span can stay unfilled for the rest of the layout instead of getting backfilled. Adding dense closes gaps like that by re-scanning from the top for every tile, but that same re-scan is what breaks strict DOM order: dense prioritizes filling gaps over DOM order, so depending on the span combination, a tile can render in a position that doesn't match its DOM order. This demo's own span values (10/13/9/12/10) happen to leave no gap either way, so here dense is cheap insurance rather than a fix for a visible hole — change the span numbers and that stops being true. Layout 04 (multi-column) never hits this because its order is strictly top-to-bottom-then-next-column by definition; layout 05 (grid row-span) carries the reordering risk the moment dense is switched on. Tiles that also tilt toward the cursor inside their own cell are covered in 3D Hover Effect CSS. If the order itself carries meaning — a publish date, say — either drop dense and accept the occasional gap, or open the vanilla folder guarded by the zip password 24p2nyh2 and hand-tune the span numbers so no gap forms in the first place.
Accessibility
What we checked
With prefers-reduced-motion: reduce on, all nine turn off their transitions and loops and land straight on the finished state instead of a half-animated one. 01, 02, and 06 apply their zoom or lift instantly, 03's tiles appear already in place (full opacity, full size) with no fade-in, 04 and 05 skip the lift and sit still, 07's featured tile keeps its emphasis size without the pop, 08 leaves the caption permanently expanded, and 09's lightbox opens and closes without the scale-in transition. Item 09's trigger is a real <button>, so it opens from the keyboard (Tab, then Enter) exactly as it does from a click, and its popovertargetaction="hide" close button works the same way. The other eight items also wire their hover-only motion to :focus-visible or :focus-within so a keyboard user sees the same result a mouse user does. Browser support for the CSS Grid masonry proposal is tracked on MDN, and the Popover API's support table lives on MDN's Popover API page. If card-style hover effects are more your speed, the card hover collection and the glassmorphism collection cover that ground.
FAQ
What happens to the 09 lightbox in a browser that doesn't support the Popover API?
The popovertarget attribute is simply ignored by browsers that don't recognize it, so the button stays a plain button and clicking it does nothing — no layout breakage, no console error. Current Chrome, Edge, Safari, and Firefox already support it.
Should I use the 04 or 05 css masonry grid for my waterfall?
If reading photos top-to-bottom-then-next-column is fine (a moodboard, a general gallery), the columns approach (04) is shorter and easier to maintain. If the layout needs to read left-to-right in strict order — a "newest first" feed, say — use the grid version (05), and budget for the dense reordering risk covered above. Galleries where a person drags the order around by hand are collected in Portfolio Gallery HTML CSS.
How does the React version of 03's auto-responsive grid know the column count?
It doesn't compute one. AutoFitGallery never touches a resize listener or state — grid-template-columns: repeat(auto-fit, minmax()) reads only the container's width inside the browser's own layout engine, so it reflows on its own whenever the window changes size.









