CSS フォト ギャラリー 9選 — メイソンリーからライトボックスまで
CSS フォト ギャラリーは、複数の写真をグリッドやウォーターフォール状に並べ、ホバーやクリックに反応させるレイアウト技法です。Pinterestで保存したあのギャラリーのように見えても、実際にコードに落とすとグリッドの数式や画像のクロップでつまずきがちです。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 均等2カラムグリッド
- 02 3カラムグリッド・比率統一
- 03 自動レスポンシブグリッド
- 04 マルチカラム・ウォーターフォール
- 05 グリッド行スパン・ウォーターフォール
- 06 正方形グリッド固定
- 07 先頭強調レイアウト
- 08 ホバーキャプションオーバーレイ
- 09 クリックで開くライトボックス(JSなし)
9つは人気順ではなく、css ギャラリー テンプレートを実際に作るときにぶつかる順番に並べました。まず一番シンプルな均等グリッドから始め(01)、写真の比率がバラバラという現実的な問題を解決し(02)、画面幅に合わせて列数が自動で変わるレスポンシブを加え(03)、高さの違う写真を隙間なく積むウォーターフォールを2通りで比べ(04〜05)、Instagramのように正方形だけに揃え(06)、代表写真1枚を大きく見せる編集レイアウトを乗せ(07)、最後にキャプションとクリック拡大というインタラクションを付け加えます(08〜09)。2枚を重ねてつまみで見比べる並べ方はビフォーアフター スライダー UI 9選に、枠そのものをドラッグで広げる方式は画面分割 css レイアウト9選にまとめてあります。
| # | 名前 | 何が動くか | 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 |
なし |
01均等2カラムグリッド
何が動くか
写真4枚を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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
ポートフォリオのサムネイル4〜6枚や、ブログ記事内の写真の並びのような、一番シンプルなギャラリーに使います。
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
スマホで撮って比率がバラバラな写真を並べるギャラリーに向いています。
.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)) の一行だけで、メディアクエリなしで画面幅に応じて1行に入るマス数が自動で変わり、新しいタイルは薄く現れて定位置に収まります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
カテゴリごとに写真枚数が違う商品ギャラリーに使います。
.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マルチカラム・ウォーターフォール
何が動くか
写真5枚に columns: 2 を指定するだけで、JavaScriptなしでも高さがバラバラな写真がカラムに沿って隙間なく積み重なるウォーターフォールになり、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
Pinterest風のムードボードの定番レイアウトです。
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
ニュース一覧のように掲載順が意味を持つ場面に向いています。
.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 を指定し、元の写真の比率に関係なくInstagramのフィードのような完全な正方形だけを並べます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
ホバーするとタイルがわずかに浮き上がり、中の写真も一緒に拡大します。
.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 を与えて他の4倍の大きさで目立たせ、その周りに小さな写真を並べる雑誌風レイアウトを、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
代表写真1枚を強調するブログのヘッダーギャラリーに使います。
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
<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を最初にレンダリングしたとき、カラムの並び順が想定と違っていました。dense を付けない普通の grid-auto-flow: row では、大きくスパンするタイルの後ろにできた隙間をブラウザが埋めずに次の行へ進んでしまうことがあり、dense を付けるとその隙間を後ろのタイルが埋めてくれます。ただし dense はDOMの順番より隙間を埋めることを優先するため、スパンの組み合わせによってはタイルが実際のDOM順序と異なる位置に描画されることがあります。04(マルチカラム)は定義からして上から下・左カラムからの順なのでこの問題は起きませんが、05(グリッド行スパン)は dense を有効にした時点でその可能性を抱えることになります。枠の中で写真自体がマウスを追って傾く演出はマウス 追従 アニメーションの3D場面のほうで扱いました。掲載日のように順番そのものに意味があるギャラリーなら、dense を外して隙間を許容するか、圧縮ファイルのパスワード24p2nyh2で開けるvanillaフォルダのspan値を自分で調整し、あらかじめ隙間が出ないようにしておく必要があります。
アクセシビリティ
押さえた点
prefers-reduced-motion: reduce が有効な画面では、9つとも遷移とループだけを止め、途中状態ではなく完成した状態がそのまま残るようにしています。01・02・06は拡大や移動が瞬時に適用され、03のタイルはフェードインなしで最初から定位置(不透明・原寸)に現れ、04・05は浮き上がらずに静止したままで、07の強調タイルは弾む演出なしでそのままの大きさを保ち、08はキャプションが常に開いた状態で固定され、09のライトボックスは拡大の遷移なしで開閉します。09のトリガーはネイティブの <button> 要素なので、クリックと同じようにキーボード(Tab、続けてEnter)でも開き、popovertargetaction="hide" の閉じるボタンも同じ方法で操作できます。残り8項目もホバー専用の動きを :focus-visible や :focus-within にも適用しているため、キーボード操作でもマウス操作と同じ結果が得られます。CSS Grid メイソンリー提案のブラウザ対応状況はMDNで、Popover APIの対応表はMDNのPopover APIページで確認できます。カード型のホバー効果に興味があれば、カードホバー集やグラスモーフィズム集も参考になります。
FAQ
09のライトボックスはPopover APIに対応していないブラウザではどうなりますか?
対応していないブラウザは popovertarget 属性を単に無視するため、ボタンはただのボタンのままでクリックしても何も起きません。レイアウトが崩れたりエラーが出たりしない、安全な性能劣化です。最新のChrome・Edge・Safari・Firefoxはすでに対応しています。
画像 ギャラリー cssとして、04と05のどちらのウォーターフォールを使えばいいですか?
写真が上から下・次のカラムへという順で読まれても構わない場合(ムードボードや一般的なギャラリー)は、columns 方式(04)のほうがコードが短く保守しやすいです。「新着順」のように左上から順番どおりに読ませたい場合はグリッド方式(05)を使い、上の落とし穴で触れた dense による順序の入れ替わりを見込んでおいてください。並び順を手でドラッグして入れ替えるギャラリーはポートフォリオ ギャラリー UI 9選で別に扱っています。
React版の03自動レスポンシブグリッドは、カラム数をどうやって把握していますか?
計算していません。AutoFitGallery はリサイズイベントも状態も持たず、grid-template-columns: repeat(auto-fit, minmax()) がブラウザのレイアウトエンジンの中でコンテナ幅だけを見て決めているため、ウィンドウサイズが変われば自動的に並びが変わります。









