一行でコピペOK CSS アニメーション ギャラリー 9選
写真にマウスを乗せると覆っていた板がすっと開いたり、円がじわっと広がって中身が見えたりするサイトを見たことはありませんか。この記事はそうしたcss アニメーション ギャラリーの動きを、一行でコピペできるコード9個にまとめたものです。うち1つはJavaScriptを使わずカテゴリー別のフィルタータブまで動きます。
01 image-reveal-gallery-wipe-left-right
02 image-reveal-gallery-curtain-split
03 image-reveal-gallery-circle-mask-grow
04 image-reveal-gallery-masonry-fade-stagger
05 image-reveal-gallery-lightbox-dialog
06 image-reveal-gallery-hover-caption-grid
07 image-reveal-gallery-view-transition-lightbox
08 image-reveal-gallery-scroll-reveal-mask
09 image-reveal-gallery-filter-tabs-has
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 左右ワイプ
- 02 カーテン分割リビール
- 03 円形マスク拡張
- 04 メイソンリー順次フェード
- 05 dialogライトボックス
- 06 ホバーキャプショングリッド
- 07 サムネイル→ライトボックスモーフ
- 08 スクロールマスクリビール
- 09 :hasベースのフィルタタブ
9個は派手さの順ではなく、ギャラリーを訪れた人が実際にたどる順番に並べています。まず01〜03は写真が1枚だけ覆いを解かれる単体のリビールで、04は複数の写真タイルが順番に並んで現れるグリッドの登場です。05と07はクリックして写真を大きく見る瞬間、06はカーソルを乗せた1枚だけに情報を足すホバーで、08はスクロールして画面に入った大きな写真がやわらかく現れる演出です。最後の09は複数の写真をカテゴリーで絞り込むフィルターで締めくくります。スライドで現れる値が金額ならCSS料金表のアニメーションがその数字を扱い、半透明な板の上で同じことをするならグラスモーフィズムの効果が材料になります。
01左右ワイプ
何が起きるか
写真を覆う板が右端を軸にscaleX(0)まで縮んでいき、左から右へ向かって写真が現れます。

<main class="stage">
<div class="wr-frame wr-focal is-demo">
<div class="wr-photo"></div>
<div class="wr-cover"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.wr-frame {
position: relative;
width: min(260px, 78vw);
aspect-ratio: 8 / 5;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.wr-photo {
position: absolute;
inset: 0;
background:
radial-gradient(120px circle at 72% 28%, rgba(255, 255, 255, .55), transparent 60%),
linear-gradient(150deg, $color, $subject-ink);
}
.wr-cover {
position: absolute;
inset: 0;
background: $subject-cream;
transform-origin: 100% 50%; // 규칙8: 오른쪽으로 걷힘
transform: scaleX(1);
}
// 실사용: 진짜 마우스 호버 — 오른쪽 끝을 축으로 scaleX(0) 까지 줄어들며 왼쪽부터 드러난다
.wr-cover { transition: transform $duration $easing; }
.wr-focal:hover .wr-cover { transform: scaleX(0); }
// 데모 루프: 같은 scaleX 경로를 keyframes 로 무한 재생 (캡처용)
@keyframes wr-wipe {
0% { transform: scaleX(1); }
8% { transform: scaleX(1); }
45% { transform: scaleX(0); }
60% { transform: scaleX(0); }
95% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
.wr-frame.is-demo .wr-cover { animation: wr-wipe $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.wr-focal:hover .wr-cover,
.wr-frame.is-demo .wr-cover {
animation: none !important;
transition: none !important;
transform: scaleX(0) !important; // 사진이 늘 보이는 상태로 고정 — 움직임 없이도 내용은 남는다
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wr-frame {
position: relative;
width: min(260px, 78vw);
aspect-ratio: 8/5;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.wr-photo {
position: absolute;
inset: 0;
background: radial-gradient(120px circle at 72% 28%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #2f6df6, #17141a);
}
.wr-cover {
position: absolute;
inset: 0;
background: #fff7e6;
transform-origin: 100% 50%;
transform: scaleX(1);
}
.wr-cover {
transition: transform 600ms ease-out;
}
.wr-focal:hover .wr-cover {
transform: scaleX(0);
}
@keyframes wr-wipe {
0% {
transform: scaleX(1);
}
8% {
transform: scaleX(1);
}
45% {
transform: scaleX(0);
}
60% {
transform: scaleX(0);
}
95% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.wr-frame.is-demo .wr-cover {
animation: wr-wipe 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.wr-focal:hover .wr-cover,
.wr-frame.is-demo .wr-cover {
animation: none !important;
transition: none !important;
transform: scaleX(0) !important;
}
}import { CSSProperties } from "react";
import styles from "./WipeLeftRight.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface WipeLeftRightProps {
duration?: string;
easing?: string;
color?: string;
}
export default function WipeLeftRight({
duration = "600ms",
easing = "ease-out",
color = "#2f6df6",
}: WipeLeftRightProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.frame} style={style}>
<div className={styles.photo} />
<div className={styles.cover} />
</div>
);
}01 · 左右ワイプ — 計測 累積面積 29.128% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの要点
動かしているのはtransform-originを右端に固定したscaleXだけです。
.wr-cover {
position: absolute;
inset: 0;
background: #fff7e6;
transform-origin: 100% 50%;
transform: scaleX(1);
transition: transform 600ms ease-out;
}
.wr-focal:hover .wr-cover {
transform: scaleX(0);
}
02カーテン分割リビール
画面で起きること
半分ずつに分かれた2枚のパネルが写真を覆っていて、マウスを乗せると左のパネルは左へ、右のパネルは右へtranslateXでそれぞれ100%押し出され、舞台のカーテンのように開きます。

<main class="stage">
<div class="cs-frame cs-focal is-demo">
<div class="cs-photo"></div>
<div class="cs-panel cs-panel--l"></div>
<div class="cs-panel cs-panel--r"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.cs-frame {
position: relative;
width: min(260px, 78vw);
aspect-ratio: 8 / 5;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.cs-photo {
position: absolute;
inset: 0;
background:
radial-gradient(110px circle at 30% 70%, rgba(255, 255, 255, .5), transparent 58%),
linear-gradient(135deg, $subject-blue, $subject-cream 130%);
}
.cs-panel {
position: absolute;
top: 0; bottom: 0;
width: 50%;
background: $color;
}
.cs-panel--l { left: 0; transform: translateX(0); }
.cs-panel--r { left: 50%; transform: translateX(0); }
// 실사용: 진짜 마우스 호버 — 왼쪽은 왼쪽으로, 오른쪽은 오른쪽으로 밀려나며 커튼처럼 열린다
.cs-panel { transition: transform $duration $easing; }
.cs-focal:hover .cs-panel--l { transform: translateX(-100%); }
.cs-focal:hover .cs-panel--r { transform: translateX(100%); }
// 데모 루프
@keyframes cs-open-l {
0%, 8% { transform: translateX(0); }
45%, 60% { transform: translateX(-100%); }
95%, 100% { transform: translateX(0); }
}
@keyframes cs-open-r {
0%, 8% { transform: translateX(0); }
45%, 60% { transform: translateX(100%); }
95%, 100% { transform: translateX(0); }
}
.cs-frame.is-demo .cs-panel--l { animation: cs-open-l $dur-loop $easing infinite; }
.cs-frame.is-demo .cs-panel--r { animation: cs-open-r $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.cs-focal:hover .cs-panel--l, .cs-frame.is-demo .cs-panel--l { animation: none !important; transition: none !important; transform: translateX(-100%) !important; }
.cs-focal:hover .cs-panel--r, .cs-frame.is-demo .cs-panel--r { animation: none !important; transition: none !important; transform: translateX(100%) !important; }
}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);
}
.cs-frame {
position: relative;
width: min(260px, 78vw);
aspect-ratio: 8/5;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cs-photo {
position: absolute;
inset: 0;
background: radial-gradient(110px circle at 30% 70%, rgba(255, 255, 255, 0.5), transparent 58%), linear-gradient(135deg, #2f6df6, #fff7e6 130%);
}
.cs-panel {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
background: #17141a;
}
.cs-panel--l {
left: 0;
transform: translateX(0);
}
.cs-panel--r {
left: 50%;
transform: translateX(0);
}
.cs-panel {
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cs-focal:hover .cs-panel--l {
transform: translateX(-100%);
}
.cs-focal:hover .cs-panel--r {
transform: translateX(100%);
}
@keyframes cs-open-l {
0%, 8% {
transform: translateX(0);
}
45%, 60% {
transform: translateX(-100%);
}
95%, 100% {
transform: translateX(0);
}
}
@keyframes cs-open-r {
0%, 8% {
transform: translateX(0);
}
45%, 60% {
transform: translateX(100%);
}
95%, 100% {
transform: translateX(0);
}
}
.cs-frame.is-demo .cs-panel--l {
animation: cs-open-l 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.cs-frame.is-demo .cs-panel--r {
animation: cs-open-r 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.cs-focal:hover .cs-panel--l, .cs-frame.is-demo .cs-panel--l {
animation: none !important;
transition: none !important;
transform: translateX(-100%) !important;
}
.cs-focal:hover .cs-panel--r, .cs-frame.is-demo .cs-panel--r {
animation: none !important;
transition: none !important;
transform: translateX(100%) !important;
}
}import { CSSProperties } from "react";
import styles from "./CurtainSplit.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface CurtainSplitProps {
duration?: string;
easing?: string;
color?: string;
}
export default function CurtainSplit({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: CurtainSplitProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.frame} style={style}>
<div className={styles.photo} />
<div className={`${styles.panel} ${styles.panelL}`} />
<div className={`${styles.panel} ${styles.panelR}`} />
</div>
);
}02 · カーテン分割リビール — 計測 累積面積 29.122% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
動く仕組み
.cs-panel {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
transition: transform 600ms cubic-bezier(.2, .8, .2, 1);
}
.cs-panel--l { left: 0; }
.cs-panel--r { left: 50%; }
.cs-focal:hover .cs-panel--l { transform: translateX(-100%); }
.cs-focal:hover .cs-panel--r { transform: translateX(100%); }
03円形マスク拡張
見え方
写真の中心から円形のclip-pathが半径0%から75%まで広がり、カメラの絞りが開くように写真を見せます。

<main class="stage">
<div class="cm-frame cm-focal is-demo">
<div class="cm-photo"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
.cm-frame {
position: relative;
width: min(260px, 78vw);
aspect-ratio: 8 / 5;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink; // 아직 안 드러난 부분이 보이는 바탕
}
.cm-photo {
position: absolute;
inset: 0;
background:
radial-gradient(100px circle at 60% 35%, rgba(255, 255, 255, .55), transparent 58%),
linear-gradient(150deg, $subject-blue, $color 140%);
clip-path: circle(0% at 50% 50%);
}
// 실사용: 진짜 마우스 호버 — 정중앙에서 원형 clip-path 가 커지며 조리개처럼 열린다
.cm-photo { transition: clip-path $duration $easing; }
.cm-focal:hover .cm-photo { clip-path: circle(75% at 50% 50%); }
// 데모 루프
@keyframes cm-grow {
0%, 8% { clip-path: circle(0% at 50% 50%); }
45%, 60% { clip-path: circle(75% at 50% 50%); }
95%, 100% { clip-path: circle(0% at 50% 50%); }
}
.cm-frame.is-demo .cm-photo { animation: cm-grow $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.cm-focal:hover .cm-photo,
.cm-frame.is-demo .cm-photo {
animation: none !important;
transition: none !important;
clip-path: circle(75% at 50% 50%) !important;
}
}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);
}
.cm-frame {
position: relative;
width: min(260px, 78vw);
aspect-ratio: 8/5;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.cm-photo {
position: absolute;
inset: 0;
background: radial-gradient(100px circle at 60% 35%, rgba(255, 255, 255, 0.55), transparent 58%), linear-gradient(150deg, #2f6df6, #fff7e6 140%);
clip-path: circle(0% at 50% 50%);
}
.cm-photo {
transition: clip-path 600ms ease-out;
}
.cm-focal:hover .cm-photo {
clip-path: circle(75% at 50% 50%);
}
@keyframes cm-grow {
0%, 8% {
clip-path: circle(0% at 50% 50%);
}
45%, 60% {
clip-path: circle(75% at 50% 50%);
}
95%, 100% {
clip-path: circle(0% at 50% 50%);
}
}
.cm-frame.is-demo .cm-photo {
animation: cm-grow 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.cm-focal:hover .cm-photo,
.cm-frame.is-demo .cm-photo {
animation: none !important;
transition: none !important;
clip-path: circle(75% at 50% 50%) !important;
}
}import { CSSProperties } from "react";
import styles from "./CircleMaskGrow.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface CircleMaskGrowProps {
duration?: string;
easing?: string;
color?: string;
}
export default function CircleMaskGrow({
duration = "550ms",
easing = "ease-out",
color = "#fff7e6",
}: CircleMaskGrowProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.frame} style={style}>
<div className={styles.photo} />
</div>
);
}03 · 円形マスク拡張 — 計測 累積面積 29.122% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
組み立て方
.cm-photo {
clip-path: circle(0% at 50% 50%);
transition: clip-path 600ms ease-out;
}
.cm-focal:hover .cm-photo {
clip-path: circle(75% at 50% 50%);
}
04メイソンリー順次フェード
css アニメーション フェードイン をどこで使うか
高さの異なる写真タイルがレンガのように積まれたグリッドが画面に入ると、IntersectionObserverがグリッド全体の到達を検知してタイルごとにinクラスを付け、transition-delayだけを140ms刻みでずらして順番に浮かび上がらせます。

<main class="stage">
<div class="mf-grid is-demo">
<div class="mf-tile mf-tile--a"></div>
<div class="mf-tile mf-tile--b"></div>
<div class="mf-tile mf-tile--c"></div>
<div class="mf-tile mf-tile--d"></div>
<div class="mf-tile mf-tile--e"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$stagger: 140ms;
@include stage($stage-paper);
.mf-grid {
width: min(260px, 78vw);
columns: 2;
column-gap: $sp-2;
}
.mf-tile {
break-inside: avoid;
margin-bottom: $sp-2;
border-radius: $r-sm;
box-shadow: $shadow-press;
// 진짜 등장 전 상태 — transition 경로와 keyframes 경로가 같은 시작점을 공유한다
opacity: 0;
transform: translateY($sp-4);
}
.mf-tile--a { height: 52px; background: radial-gradient(26px circle at 30% 30%, rgba(255,255,255,.6), transparent 60%), linear-gradient(150deg, $subject-blue, $subject-ink); }
.mf-tile--b { height: 36px; background: radial-gradient(22px circle at 70% 60%, rgba(255,255,255,.6), transparent 60%), linear-gradient(150deg, $subject-cream, $subject-blue); }
.mf-tile--c { height: 44px; background: radial-gradient(24px circle at 40% 70%, rgba(255,255,255,.6), transparent 60%), linear-gradient(150deg, $subject-ink, $subject-cream); }
.mf-tile--d { height: 56px; background: radial-gradient(26px circle at 60% 30%, rgba(255,255,255,.6), transparent 60%), linear-gradient(150deg, $subject-blue, $subject-cream); }
.mf-tile--e { height: 32px; background: radial-gradient(18px circle at 50% 50%, rgba(255,255,255,.6), transparent 60%), linear-gradient(150deg, $subject-cream, $subject-ink); }
// 실사용: 진짜 IntersectionObserver 가 뷰포트에 들어올 때 .in 을 붙인다(위 <script>). 타일마다 delay 만 다르다
.mf-tile { transition: opacity 400ms $easing, transform 400ms $easing; }
.mf-tile.in { opacity: 1; transform: translateY(0); }
.mf-tile--a.in { transition-delay: 0; }
.mf-tile--b.in { transition-delay: $stagger; }
.mf-tile--c.in { transition-delay: $stagger * 2; }
.mf-tile--d.in { transition-delay: $stagger * 3; }
.mf-tile--e.in { transition-delay: $stagger * 4; }
// 데모 루프: is-demo 는 transition 경로를 쓰지 않는다 — 같은 순차 등장을 keyframes 로 무한 재생(캡처용)
@keyframes mf-tile-in {
0% { opacity: 0; transform: translateY($sp-4); }
20% { opacity: 1; transform: translateY(0); }
100% { opacity: 1; transform: translateY(0); }
}
.mf-grid.is-demo .mf-tile { animation: mf-tile-in $duration $easing infinite; animation-fill-mode: both; }
.mf-grid.is-demo .mf-tile--a { animation-delay: 0ms; }
.mf-grid.is-demo .mf-tile--b { animation-delay: $stagger; }
.mf-grid.is-demo .mf-tile--c { animation-delay: $stagger * 2; }
.mf-grid.is-demo .mf-tile--d { animation-delay: $stagger * 3; }
.mf-grid.is-demo .mf-tile--e { animation-delay: $stagger * 4; }
@media (prefers-reduced-motion: reduce) {
.mf-tile, .mf-tile.in, .mf-grid.is-demo .mf-tile {
animation: none !important; transition: none !important; opacity: 1 !important; transform: none !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mf-grid {
width: min(260px, 78vw);
columns: 2;
column-gap: 8px;
}
.mf-tile {
break-inside: avoid;
margin-bottom: 8px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(16px);
}
.mf-tile--a {
height: 52px;
background: radial-gradient(26px circle at 30% 30%, rgba(255, 255, 255, 0.6), transparent 60%), linear-gradient(150deg, #2f6df6, #17141a);
}
.mf-tile--b {
height: 36px;
background: radial-gradient(22px circle at 70% 60%, rgba(255, 255, 255, 0.6), transparent 60%), linear-gradient(150deg, #fff7e6, #2f6df6);
}
.mf-tile--c {
height: 44px;
background: radial-gradient(24px circle at 40% 70%, rgba(255, 255, 255, 0.6), transparent 60%), linear-gradient(150deg, #17141a, #fff7e6);
}
.mf-tile--d {
height: 56px;
background: radial-gradient(26px circle at 60% 30%, rgba(255, 255, 255, 0.6), transparent 60%), linear-gradient(150deg, #2f6df6, #fff7e6);
}
.mf-tile--e {
height: 32px;
background: radial-gradient(18px circle at 50% 50%, rgba(255, 255, 255, 0.6), transparent 60%), linear-gradient(150deg, #fff7e6, #17141a);
}
.mf-tile {
transition: opacity 400ms ease-out, transform 400ms ease-out;
}
.mf-tile.in {
opacity: 1;
transform: translateY(0);
}
.mf-tile--a.in {
transition-delay: 0;
}
.mf-tile--b.in {
transition-delay: 140ms;
}
.mf-tile--c.in {
transition-delay: 280ms;
}
.mf-tile--d.in {
transition-delay: 420ms;
}
.mf-tile--e.in {
transition-delay: 560ms;
}
@keyframes mf-tile-in {
0% {
opacity: 0;
transform: translateY(16px);
}
20% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.mf-grid.is-demo .mf-tile {
animation: mf-tile-in 2s ease-out infinite;
animation-fill-mode: both;
}
.mf-grid.is-demo .mf-tile--a {
animation-delay: 0ms;
}
.mf-grid.is-demo .mf-tile--b {
animation-delay: 140ms;
}
.mf-grid.is-demo .mf-tile--c {
animation-delay: 280ms;
}
.mf-grid.is-demo .mf-tile--d {
animation-delay: 420ms;
}
.mf-grid.is-demo .mf-tile--e {
animation-delay: 560ms;
}
@media (prefers-reduced-motion: reduce) {
.mf-tile, .mf-tile.in, .mf-grid.is-demo .mf-tile {
animation: none !important;
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
}var tiles = document.querySelectorAll('.mf-tile');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
}, { threshold: 0.3 });
tiles.forEach(function (t) { io.observe(t); });import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./MasonryFadeStagger.module.scss";
export interface MasonryFadeStaggerProps {
duration?: string;
easing?: string;
stagger?: number;
}
const TILES = ["a", "b", "c", "d", "e"] as const;
export default function MasonryFadeStagger({
duration = "400ms",
easing = "ease-out",
stagger = 140,
}: MasonryFadeStaggerProps) {
const ref = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => entries.forEach((e) => e.isIntersecting && setInView(true)),
{ threshold: 0.3 }
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div ref={ref} className={styles.grid}>
{TILES.map((t, i) => {
const style: CSSProperties = {
transitionDuration: duration,
transitionTimingFunction: easing,
transitionDelay: inView ? `${i * stagger}ms` : "0ms",
};
return (
<div
key={t}
className={`${styles.tile} ${styles["tile" + t.toUpperCase()]} ${inView ? styles.in : ""}`}
style={style}
/>
);
})}
</div>
);
}04 · メイソンリー順次フェード — 計測 累積面積 25.641% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
なぜこう書くか
var tiles = document.querySelectorAll('.mf-tile');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
});
}, { threshold: 0.3 });
tiles.forEach(function (t) { io.observe(t); });
05dialogライトボックス
どの画面か
サムネイルのボタンを押すとネイティブのdialog要素がshowModal()で開いて写真が拡大表示され、閉じるボタンはページを勝手にスクロールさせないpreventScroll付きでフォーカスを受け取ります。

<main class="stage">
<div class="lb-frame is-demo">
<button class="lb-thumb" type="button" aria-haspopup="dialog">
<span class="lb-thumb__photo"></span>
<span class="lb-thumb__label">View</span>
</button>
<dialog class="lb-dialog" aria-labelledby="lb-title">
<div class="lb-dialog__photo"></div>
<h2 id="lb-title" class="lb-dialog__title">Studio 04</h2>
<button class="lb-close" type="button">Close</button>
</dialog>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.lb-frame { position: relative; }
.lb-thumb {
border: 0; border-radius: $r-card; overflow: hidden; cursor: pointer; padding: 0;
width: min(220px, 64vw); aspect-ratio: 4 / 3; position: relative;
-webkit-tap-highlight-color: transparent;
}
.lb-thumb__photo {
position: absolute; inset: 0;
background:
radial-gradient(90px circle at 66% 30%, rgba(255, 255, 255, .55), transparent 60%),
linear-gradient(150deg, $subject-blue, $stage-ink);
}
.lb-thumb__label {
position: absolute; right: $sp-2; bottom: $sp-2;
padding: $sp-1 $sp-3; border-radius: $r-pill;
background: rgba(23, 20, 26, .55); color: $color;
font: 600 11px/1 -apple-system, "Segoe UI", sans-serif;
}
// dialog: 실제 open 은 브라우저 기본(즉시)으로 둔다 — @starting-style 은 브라우저마다 지원이 갈려 쓰지 않는다(함정 섹션 참고)
dialog.lb-dialog {
border: 0; margin: auto; position: fixed; inset: 0;
width: min(80vw, 300px); padding: $sp-5;
border-radius: $r-card; background: $stage-paper; color: $subject-ink;
box-shadow: $shadow-raised;
}
// ::backdrop 은 정적 rgba 하나로 둔다 — 애니메이션시키지 않는다
dialog.lb-dialog::backdrop { background: rgba(23, 20, 26, .55); }
.lb-dialog__photo {
width: 100%; aspect-ratio: 4 / 3; border-radius: $r-sm; margin-bottom: $sp-3;
background:
radial-gradient(90px circle at 66% 30%, rgba(255, 255, 255, .55), transparent 60%),
linear-gradient(150deg, $subject-blue, $stage-ink);
}
.lb-dialog__title { margin: 0 0 $sp-3; font-size: 14px; }
.lb-close {
border: 0; border-radius: $r-control; background: $subject-blue; color: $stage-paper;
padding: $sp-2 $sp-4; font: 600 12px/1 -apple-system, "Segoe UI", sans-serif; cursor: pointer;
}
// 데모 캡처: 진짜 showModal() 로 연 뒤(위 <script>), 시각적 열림만 keyframes 로 반복한다 — backdrop 은 정적인 채로 둔다
.lb-frame.is-demo dialog.lb-dialog {
animation: lb-pop $dur-loop $easing infinite;
}
@keyframes lb-pop {
0% { opacity: 0; transform: scale(.85); }
15% { opacity: 1; transform: scale(1); }
70% { opacity: 1; transform: scale(1); }
85% { opacity: 0; transform: scale(.85); }
100% { opacity: 0; transform: scale(.85); }
}
@media (prefers-reduced-motion: reduce) {
.lb-frame.is-demo dialog.lb-dialog { animation: none !important; opacity: 1 !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lb-frame {
position: relative;
}
.lb-thumb {
border: 0;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
padding: 0;
width: min(220px, 64vw);
aspect-ratio: 4/3;
position: relative;
-webkit-tap-highlight-color: transparent;
}
.lb-thumb__photo {
position: absolute;
inset: 0;
background: radial-gradient(90px circle at 66% 30%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #2f6df6, #17141a);
}
.lb-thumb__label {
position: absolute;
right: 8px;
bottom: 8px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.55);
color: #fff7e6;
font: 600 11px/1 -apple-system, "Segoe UI", sans-serif;
}
dialog.lb-dialog {
border: 0;
margin: auto;
position: fixed;
inset: 0;
width: min(80vw, 300px);
padding: 20px;
border-radius: 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
dialog.lb-dialog::backdrop {
background: rgba(23, 20, 26, 0.55);
}
.lb-dialog__photo {
width: 100%;
aspect-ratio: 4/3;
border-radius: 8px;
margin-bottom: 12px;
background: radial-gradient(90px circle at 66% 30%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #2f6df6, #17141a);
}
.lb-dialog__title {
margin: 0 0 12px;
font-size: 14px;
}
.lb-close {
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #ffffff;
padding: 8px 16px;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
}
.lb-frame.is-demo dialog.lb-dialog {
animation: lb-pop 2s ease-out infinite;
}
@keyframes lb-pop {
0% {
opacity: 0;
transform: scale(0.85);
}
15% {
opacity: 1;
transform: scale(1);
}
70% {
opacity: 1;
transform: scale(1);
}
85% {
opacity: 0;
transform: scale(0.85);
}
100% {
opacity: 0;
transform: scale(0.85);
}
}
@media (prefers-reduced-motion: reduce) {
.lb-frame.is-demo dialog.lb-dialog {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}var dlg = document.querySelector('.lb-dialog'), btn = document.querySelector('.lb-close'), frame = document.querySelector('.lb-frame');
document.querySelector('.lb-thumb').addEventListener('click', function () {
frame.classList.remove('is-demo');
if (!dlg.open) { dlg.showModal(); btn.focus({ preventScroll: true }); }
});
btn.addEventListener('click', function () { dlg.close(); });
if (frame.classList.contains('is-demo')) { dlg.showModal(); btn.focus({ preventScroll: true }); }import { useRef } from "react";
import styles from "./LightboxDialog.module.scss";
export interface LightboxDialogProps {
duration?: string;
easing?: string;
color?: string;
}
export default function LightboxDialog({
duration = "300ms",
easing = "ease-out",
}: LightboxDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
function open() {
dialogRef.current?.showModal();
closeRef.current?.focus({ preventScroll: true });
}
function close() {
dialogRef.current?.close();
}
return (
<div className={styles.frame} style={{ transitionDuration: duration, transitionTimingFunction: easing }}>
<button className={styles.thumb} type="button" aria-haspopup="dialog" onClick={open}>
<span className={styles.thumbPhoto} />
<span className={styles.thumbLabel}>View</span>
</button>
<dialog ref={dialogRef} className={styles.dialog} aria-labelledby="lb-title-react">
<div className={styles.dialogPhoto} />
<h2 id="lb-title-react" className={styles.dialogTitle}>Studio 04</h2>
<button ref={closeRef} className={styles.close} type="button" onClick={close}>Close</button>
</dialog>
</div>
);
}05 · dialogライトボックス — 計測 累積面積 70.732% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
実装の核
var dlg = document.querySelector('.lb-dialog');
var btn = document.querySelector('.lb-close');
document.querySelector('.lb-thumb').addEventListener('click', function () {
dlg.showModal();
btn.focus({ preventScroll: true });
});
btn.addEventListener('click', function () {
dlg.close();
});
06ホバーキャプショングリッド
何が動くか
3枚並んだグリッドでマウスを乗せた1枚だけキャプションバーがtranslateYで下から上へ上がってきてopacityでふわっと現れ、残りの2枚はそのままです。

<main class="stage">
<div class="hc-grid is-demo">
<div class="hc-tile hc-tile--a"><span class="hc-cap">Interior</span></div>
<div class="hc-tile hc-tile--b"><span class="hc-cap">Outdoor</span></div>
<div class="hc-tile hc-tile--c"><span class="hc-cap">Detail</span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.hc-grid {
width: min(260px, 78vw);
aspect-ratio: 8 / 5;
display: flex;
gap: $sp-2;
}
.hc-tile {
position: relative;
flex: 1 1 0;
border-radius: $r-sm;
overflow: hidden;
}
.hc-tile--a { background: radial-gradient(60px circle at 30% 30%, rgba(255,255,255,.55), transparent 60%), linear-gradient(150deg, $subject-blue, $subject-ink); }
.hc-tile--b { background: radial-gradient(60px circle at 70% 40%, rgba(255,255,255,.55), transparent 60%), linear-gradient(150deg, $subject-cream, $subject-blue); }
.hc-tile--c { background: radial-gradient(60px circle at 50% 60%, rgba(255,255,255,.55), transparent 60%), linear-gradient(150deg, $subject-ink, $subject-cream); }
.hc-cap {
position: absolute; left: 0; right: 0; bottom: 0;
padding: $sp-2;
background: rgba($color, .78);
color: $subject-cream;
font: 600 11px/1 -apple-system, "Segoe UI", sans-serif;
transform: translateY(100%);
opacity: 0;
}
// 실사용: 진짜 마우스 호버 — 지금 가리킨 칸만 캡션이 올라온다, 나머지는 그대로
.hc-cap { transition: transform $duration $easing, opacity $duration $easing; }
.hc-tile:hover .hc-cap { transform: translateY(0); opacity: 1; }
// 데모 루프: 세 칸을 순서대로 도는 캡션 (동시가 아니라 한 번에 하나씩)
@keyframes hc-cap-cycle {
0% { transform: translateY(100%); opacity: 0; }
6% { transform: translateY(0); opacity: 1; }
26% { transform: translateY(0); opacity: 1; }
33% { transform: translateY(100%); opacity: 0; }
100% { transform: translateY(100%); opacity: 0; }
}
.hc-grid.is-demo .hc-tile--a .hc-cap { animation: hc-cap-cycle $dur-loop $easing infinite; animation-delay: 0s; }
.hc-grid.is-demo .hc-tile--b .hc-cap { animation: hc-cap-cycle $dur-loop $easing infinite; animation-delay: .66s; }
.hc-grid.is-demo .hc-tile--c .hc-cap { animation: hc-cap-cycle $dur-loop $easing infinite; animation-delay: 1.32s; }
@media (prefers-reduced-motion: reduce) {
.hc-tile:hover .hc-cap,
.hc-grid.is-demo .hc-tile .hc-cap {
animation: none !important; transition: none !important; transform: translateY(0) !important; opacity: 1 !important;
}
}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);
}
.hc-grid {
width: min(260px, 78vw);
aspect-ratio: 8/5;
display: flex;
gap: 8px;
}
.hc-tile {
position: relative;
flex: 1 1 0;
border-radius: 8px;
overflow: hidden;
}
.hc-tile--a {
background: radial-gradient(60px circle at 30% 30%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #2f6df6, #17141a);
}
.hc-tile--b {
background: radial-gradient(60px circle at 70% 40%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #fff7e6, #2f6df6);
}
.hc-tile--c {
background: radial-gradient(60px circle at 50% 60%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #17141a, #fff7e6);
}
.hc-cap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font: 600 11px/1 -apple-system, "Segoe UI", sans-serif;
transform: translateY(100%);
opacity: 0;
}
.hc-cap {
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.hc-tile:hover .hc-cap {
transform: translateY(0);
opacity: 1;
}
@keyframes hc-cap-cycle {
0% {
transform: translateY(100%);
opacity: 0;
}
6% {
transform: translateY(0);
opacity: 1;
}
26% {
transform: translateY(0);
opacity: 1;
}
33% {
transform: translateY(100%);
opacity: 0;
}
100% {
transform: translateY(100%);
opacity: 0;
}
}
.hc-grid.is-demo .hc-tile--a .hc-cap {
animation: hc-cap-cycle 2s ease-out infinite;
animation-delay: 0s;
}
.hc-grid.is-demo .hc-tile--b .hc-cap {
animation: hc-cap-cycle 2s ease-out infinite;
animation-delay: 0.66s;
}
.hc-grid.is-demo .hc-tile--c .hc-cap {
animation: hc-cap-cycle 2s ease-out infinite;
animation-delay: 1.32s;
}
@media (prefers-reduced-motion: reduce) {
.hc-tile:hover .hc-cap,
.hc-grid.is-demo .hc-tile .hc-cap {
animation: none !important;
transition: none !important;
transform: translateY(0) !important;
opacity: 1 !important;
}
}import { CSSProperties } from "react";
import styles from "./HoverCaptionGrid.module.scss";
export interface HoverCaptionGridProps {
duration?: string;
easing?: string;
color?: string;
}
const TILES = [
{ id: "a", label: "Interior" },
{ id: "b", label: "Outdoor" },
{ id: "c", label: "Detail" },
] as const;
export default function HoverCaptionGrid({
duration = "300ms",
easing = "ease-out",
color = "#17141a",
}: HoverCaptionGridProps) {
const capStyle: CSSProperties = { transitionDuration: duration, transitionTimingFunction: easing, background: `rgba(23, 20, 26, .78)` };
return (
<div className={styles.grid}>
{TILES.map((t) => (
<div key={t.id} className={`${styles.tile} ${styles["tile" + t.id.toUpperCase()]}`}>
<span className={styles.cap} style={capStyle}>{t.label}</span>
</div>
))}
</div>
);
}06 · ホバーキャプショングリッド — 計測 累積面積 4.412% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
何が肝心か
.hc-cap {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: 8px;
transform: translateY(100%);
opacity: 0;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.hc-tile:hover .hc-cap {
transform: translateY(0);
opacity: 1;
}
07サムネイル→ライトボックスモーフ
どこで使うか
サムネイルをクリックした瞬間にgetBoundingClientRect()でその位置を測り、オーバーレイのパネルをまずそこへtransformで重ねてから値を消すことで、中央の大きなライトボックスの位置までFLIPと呼ばれる手法で滑らかに拡大します。

<main class="stage">
<div class="fl-frame fl-focal is-demo">
<button class="fl-thumb" type="button"><span class="fl-thumb__photo"></span></button>
<div class="fl-panel"><span class="fl-panel__photo"></span></div>
<div class="fl-panel fl-panel--demo" aria-hidden="true"><span class="fl-panel__photo"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.fl-frame {
position: relative;
width: 260px;
height: 165px;
}
.fl-thumb {
position: absolute; left: 0; top: 0;
width: 90px; height: 60px;
border: 0; border-radius: $r-sm; overflow: hidden; cursor: pointer; padding: 0;
box-shadow: $shadow-press;
-webkit-tap-highlight-color: transparent;
}
.fl-thumb__photo {
position: absolute; inset: 0;
background:
radial-gradient(40px circle at 35% 35%, rgba(255, 255, 255, .55), transparent 60%),
linear-gradient(150deg, $color, $subject-ink);
}
// 최종(자연) 위치 — FLIP 의 Last. transform-origin 을 top left 로 둬야 translate·scale 계산이 맞는다
.fl-panel {
position: absolute; left: 44px; top: 28px;
width: 172px; height: 110px;
border-radius: $r-sm; overflow: hidden;
box-shadow: $shadow-raised;
transform-origin: top left; // 규칙8: FLIP 계산 기준점
opacity: 0; pointer-events: none;
}
.fl-panel.is-open { opacity: 1; pointer-events: auto; }
.fl-panel:not(.fl-panel--demo) { transition: transform $duration $easing, opacity $duration $easing; }
.fl-panel__photo {
position: absolute; inset: 0;
background:
radial-gradient(70px circle at 35% 35%, rgba(255, 255, 255, .55), transparent 60%),
linear-gradient(150deg, $color, $subject-ink);
}
// 데모 캡처: 클릭 없이 같은 First→Last 지오메트리를 keyframes 로 무한 재생하는 복제 패널
// First(썸네일: left 0 top 0 90×60) 를 Last(패널: left 44 top 28 172×110) 기준으로 환산한 상수
// deltaX = 0-44 = -44px · deltaY = 0-28 = -28px · deltaW = 90/172 ≈ .523 · deltaH = 60/110 ≈ .545
.fl-panel--demo { opacity: 0; }
.fl-frame.is-demo .fl-panel:not(.fl-panel--demo) { display: none; } // 캡처 중엔 실사용 패널을 숨겨 겹치지 않게
.fl-frame.is-demo .fl-panel--demo {
animation: fl-flip $dur-loop $easing infinite;
}
@keyframes fl-flip {
0% { transform: translate(-44px, -28px) scale(.523, .545); opacity: 0; }
8% { opacity: 1; }
45% { transform: translate(0, 0) scale(1, 1); opacity: 1; }
60% { transform: translate(0, 0) scale(1, 1); opacity: 1; }
92% { opacity: 0; }
100% { transform: translate(-44px, -28px) scale(.523, .545); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.fl-panel { transition: none !important; }
.fl-frame.is-demo .fl-panel--demo {
animation: none !important; transform: translate(0, 0) scale(1, 1) !important; opacity: 1 !important;
}
}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);
}
.fl-frame {
position: relative;
width: 260px;
height: 165px;
}
.fl-thumb {
position: absolute;
left: 0;
top: 0;
width: 90px;
height: 60px;
border: 0;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
padding: 0;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
-webkit-tap-highlight-color: transparent;
}
.fl-thumb__photo {
position: absolute;
inset: 0;
background: radial-gradient(40px circle at 35% 35%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #2f6df6, #17141a);
}
.fl-panel {
position: absolute;
left: 44px;
top: 28px;
width: 172px;
height: 110px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top left;
opacity: 0;
pointer-events: none;
}
.fl-panel.is-open {
opacity: 1;
pointer-events: auto;
}
.fl-panel:not(.fl-panel--demo) {
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fl-panel__photo {
position: absolute;
inset: 0;
background: radial-gradient(70px circle at 35% 35%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #2f6df6, #17141a);
}
.fl-panel--demo {
opacity: 0;
}
.fl-frame.is-demo .fl-panel:not(.fl-panel--demo) {
display: none;
}
.fl-frame.is-demo .fl-panel--demo {
animation: fl-flip 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes fl-flip {
0% {
transform: translate(-44px, -28px) scale(0.523, 0.545);
opacity: 0;
}
8% {
opacity: 1;
}
45% {
transform: translate(0, 0) scale(1, 1);
opacity: 1;
}
60% {
transform: translate(0, 0) scale(1, 1);
opacity: 1;
}
92% {
opacity: 0;
}
100% {
transform: translate(-44px, -28px) scale(0.523, 0.545);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.fl-panel {
transition: none !important;
}
.fl-frame.is-demo .fl-panel--demo {
animation: none !important;
transform: translate(0, 0) scale(1, 1) !important;
opacity: 1 !important;
}
}var thumb = document.querySelector('.fl-thumb'), panel = document.querySelector('.fl-panel'), frame = document.querySelector('.fl-frame');
thumb.addEventListener('click', function () {
frame.classList.remove('is-demo');
var t = thumb.getBoundingClientRect(), p = panel.getBoundingClientRect();
panel.style.transform = 'translate(' + (t.left - p.left) + 'px,' + (t.top - p.top) + 'px) scale(' + (t.width / p.width) + ',' + (t.height / p.height) + ')';
panel.classList.add('is-open');
requestAnimationFrame(function () { panel.style.transform = 'translate(0,0) scale(1,1)'; });
});
panel.addEventListener('click', function () { panel.classList.remove('is-open'); panel.style.transform = ''; });import { useRef, useState } from "react";
import styles from "./ThumbnailLightboxMorph.module.scss";
export interface ThumbnailLightboxMorphProps {
duration?: string;
easing?: string;
color?: string;
}
export default function ThumbnailLightboxMorph({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
}: ThumbnailLightboxMorphProps) {
const thumbRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
function openFlip() {
const t = thumbRef.current?.getBoundingClientRect();
const p = panelRef.current?.getBoundingClientRect();
if (!t || !p || !panelRef.current) return;
panelRef.current.style.transform = `translate(${t.left - p.left}px,${t.top - p.top}px) scale(${t.width / p.width},${t.height / p.height})`;
setOpen(true);
requestAnimationFrame(() => {
if (panelRef.current) panelRef.current.style.transform = "translate(0,0) scale(1,1)";
});
}
function closeFlip() {
setOpen(false);
if (panelRef.current) panelRef.current.style.transform = "";
}
return (
<div className={styles.frame} style={{ transitionDuration: duration, transitionTimingFunction: easing }}>
<button ref={thumbRef} className={styles.thumb} type="button" onClick={openFlip}>
<span className={styles.thumbPhoto} />
</button>
<div
ref={panelRef}
className={`${styles.panel} ${open ? styles.isOpen : ""}`}
style={{ transitionDuration: duration, transitionTimingFunction: easing }}
onClick={closeFlip}
>
<span className={styles.panelPhoto} />
</div>
</div>
);
}07 · サムネイル→ライトボックスモーフ — 計測 累積面積 21.074% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
押さえておく点
var t = thumb.getBoundingClientRect();
var p = panel.getBoundingClientRect();
panel.style.transform =
'translate(' + (t.left - p.left) + 'px,' + (t.top - p.top) + 'px) ' +
'scale(' + (t.width / p.width) + ',' + (t.height / p.height) + ')';
panel.classList.add('is-open');
requestAnimationFrame(function () {
panel.style.transform = 'translate(0,0) scale(1,1)';
});
08スクロールマスクリビール
見えるもの
写真が画面に入るとIntersectionObserverが付けたクラスに合わせてmask-imageのmask-positionが動き、輪郭が硬く切り替わるのではなくグラデーションのやわらかい縁で写真が現れます。

<main class="stage">
<div class="mk-frame is-demo">
<div class="mk-photo"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.mk-frame {
position: relative;
width: min(260px, 78vw);
aspect-ratio: 8 / 5;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.mk-photo {
position: absolute;
inset: 0;
background:
radial-gradient(110px circle at 34% 32%, rgba(255, 255, 255, .55), transparent 60%),
linear-gradient(150deg, $color, $subject-ink);
mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
-webkit-mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
mask-size: 260% 100%;
-webkit-mask-size: 260% 100%;
// 기본(트리거 전) = 숨김. mask-size 가 260% 라 position 0% 는 그라디언트의 transparent(0~38%) 구간이 창 전체를 덮는다
mask-position: 0 0;
-webkit-mask-position: 0 0;
}
// 실사용: 진짜 IntersectionObserver 가 뷰포트에 들어올 때 .in 을 붙인다(위 <script>) — mask-position 이 오른쪽에서 왼쪽으로 쓸고 지나가며 부드러운 가장자리로 드러난다
.mk-photo { transition: mask-position 700ms $easing, -webkit-mask-position 700ms $easing; }
.mk-frame.in .mk-photo { mask-position: 100% 0; -webkit-mask-position: 100% 0; } // position 100% 는 black(58~100%) 구간이 창 전체를 덮어 완전히 드러난다
// 데모 루프: is-demo 는 같은 마스크 스윕을 keyframes 로 무한 재생한다(캡처용, 실제 스크롤 없이도 재현)
@keyframes mk-sweep {
0%, 8% { mask-position: 0 0; -webkit-mask-position: 0 0; }
45%, 60% { mask-position: 100% 0; -webkit-mask-position: 100% 0; }
95%, 100% { mask-position: 0 0; -webkit-mask-position: 0 0; }
}
.mk-frame.is-demo .mk-photo { animation: mk-sweep $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.mk-frame.in .mk-photo,
.mk-frame.is-demo .mk-photo {
animation: none !important;
transition: none !important;
mask-position: 100% 0 !important;
-webkit-mask-position: 100% 0 !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mk-frame {
position: relative;
width: min(260px, 78vw);
aspect-ratio: 8/5;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mk-photo {
position: absolute;
inset: 0;
background: radial-gradient(110px circle at 34% 32%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #2f6df6, #17141a);
mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
-webkit-mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
mask-size: 260% 100%;
-webkit-mask-size: 260% 100%;
mask-position: 0 0;
-webkit-mask-position: 0 0;
}
.mk-photo {
transition: mask-position 700ms ease-out, -webkit-mask-position 700ms ease-out;
}
.mk-frame.in .mk-photo {
mask-position: 100% 0;
-webkit-mask-position: 100% 0;
}
@keyframes mk-sweep {
0%, 8% {
mask-position: 0 0;
-webkit-mask-position: 0 0;
}
45%, 60% {
mask-position: 100% 0;
-webkit-mask-position: 100% 0;
}
95%, 100% {
mask-position: 0 0;
-webkit-mask-position: 0 0;
}
}
.mk-frame.is-demo .mk-photo {
animation: mk-sweep 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.mk-frame.in .mk-photo,
.mk-frame.is-demo .mk-photo {
animation: none !important;
transition: none !important;
mask-position: 100% 0 !important;
-webkit-mask-position: 100% 0 !important;
}
}var frame = document.querySelector('.mk-frame');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(frame);import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./ScrollRevealMask.module.scss";
export interface ScrollRevealMaskProps {
duration?: string;
easing?: string;
color?: string;
}
export default function ScrollRevealMask({
duration = "700ms",
easing = "ease-out",
color = "#2f6df6",
}: ScrollRevealMaskProps) {
const ref = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => entries.forEach((e) => e.isIntersecting && setInView(true)),
{ threshold: 0.3 }
);
io.observe(el);
return () => io.disconnect();
}, []);
const style: CSSProperties = {
transitionDuration: duration,
transitionTimingFunction: easing,
maskPosition: inView ? "100% 0" : "0 0",
WebkitMaskPosition: inView ? "100% 0" : "0 0",
};
return (
<div ref={ref} className={styles.frame}>
<div className={styles.photo} style={{ ...style, background: `linear-gradient(150deg, ${color}, #17141a)` }} />
</div>
);
}08 · スクロールマスクリビール — 計測 累積面積 29.122% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どう作るか
.mk-photo {
mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
mask-size: 260% 100%;
mask-position: 0 0;
transition: mask-position 700ms ease-out;
}
.mk-frame.in .mk-photo {
mask-position: 100% 0;
}
09:hasベースのフィルタータブ
どんな場面か
隠したラジオボタン3個と:has()セレクターだけで、タブを選ぶと他のカテゴリーのタイルがopacityで薄くなりscaleでわずかに縮みます。

<main class="stage">
<div class="ft-frame is-demo">
<input type="radio" name="ft-cat" id="ft-1" class="ft-radio" checked>
<input type="radio" name="ft-cat" id="ft-2" class="ft-radio">
<input type="radio" name="ft-cat" id="ft-3" class="ft-radio">
<div class="ft-tabs">
<span class="ft-indicator" aria-hidden="true"></span>
<label class="ft-tab" for="ft-1">All</label>
<label class="ft-tab" for="ft-2">Photo</label>
<label class="ft-tab" for="ft-3">Video</label>
</div>
<div class="ft-gallery">
<div class="ft-tile" data-cat="photo"></div>
<div class="ft-tile" data-cat="video"></div>
<div class="ft-tile" data-cat="photo"></div>
<div class="ft-tile" data-cat="video"></div>
<div class="ft-tile" data-cat="photo"></div>
<div class="ft-tile" data-cat="video"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.ft-frame { width: min(260px, 78vw); }
.ft-radio { position: absolute; opacity: 0; pointer-events: none; }
.ft-tabs {
position: relative;
display: flex;
gap: 0;
width: fit-content;
padding: $sp-1;
margin-bottom: $sp-2;
background: rgba(255, 255, 255, .1);
border-radius: $r-pill;
}
// 탭은 내용 폭이 아니라 고정 64px — 인디케이터의 translateX 가 이 값의 정확한 배수가 되게 한다(BD19)
.ft-tab {
position: relative; z-index: 1;
flex: 0 0 64px;
text-align: center;
padding: $sp-2 0;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
color: $subject-cream;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.ft-indicator {
position: absolute; top: $sp-1; bottom: $sp-1; left: $sp-1;
width: 64px;
border-radius: $r-pill;
background: $color;
transform: translateX(0);
transition: transform $duration $easing;
}
#ft-1:checked ~ .ft-tabs .ft-indicator { transform: translateX(0); }
#ft-2:checked ~ .ft-tabs .ft-indicator { transform: translateX(64px); }
#ft-3:checked ~ .ft-tabs .ft-indicator { transform: translateX(128px); }
.ft-gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
}
.ft-tile {
aspect-ratio: 4 / 3;
border-radius: $r-sm;
transition: opacity $duration $easing, transform $duration $easing;
}
.ft-tile[data-cat="photo"] { background: radial-gradient(30px circle at 35% 35%, rgba(255,255,255,.55), transparent 60%), linear-gradient(150deg, $subject-blue, $subject-ink); }
.ft-tile[data-cat="video"] { background: radial-gradient(30px circle at 60% 40%, rgba(255,255,255,.55), transparent 60%), linear-gradient(150deg, $subject-cream, $subject-blue); }
// 실사용: JS 0줄 — 숨긴 라디오와 :has() 만으로 다른 카테고리 타일이 옅어지고 줄어든다
// (.ft-frame 이 공통 조상 — 라디오와 갤러리는 형제라 :has() 로 라디오의 :checked 를 먼저 확인해야 한다)
.ft-frame:has(#ft-2:checked) .ft-tile:not([data-cat="photo"]) { opacity: .15; transform: scale(.94); pointer-events: none; }
.ft-frame:has(#ft-3:checked) .ft-tile:not([data-cat="video"]) { opacity: .15; transform: scale(.94); pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.ft-indicator, .ft-tile { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ft-frame {
width: min(260px, 78vw);
}
.ft-radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.ft-tabs {
position: relative;
display: flex;
gap: 0;
width: fit-content;
padding: 4px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 999px;
}
.ft-tab {
position: relative;
z-index: 1;
flex: 0 0 64px;
text-align: center;
padding: 8px 0;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
color: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.ft-indicator {
position: absolute;
top: 4px;
bottom: 4px;
left: 4px;
width: 64px;
border-radius: 999px;
background: #2f6df6;
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
#ft-1:checked ~ .ft-tabs .ft-indicator {
transform: translateX(0);
}
#ft-2:checked ~ .ft-tabs .ft-indicator {
transform: translateX(64px);
}
#ft-3:checked ~ .ft-tabs .ft-indicator {
transform: translateX(128px);
}
.ft-gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.ft-tile {
aspect-ratio: 4/3;
border-radius: 8px;
transition: opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ft-tile[data-cat=photo] {
background: radial-gradient(30px circle at 35% 35%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #2f6df6, #17141a);
}
.ft-tile[data-cat=video] {
background: radial-gradient(30px circle at 60% 40%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #fff7e6, #2f6df6);
}
.ft-frame:has(#ft-2:checked) .ft-tile:not([data-cat=photo]) {
opacity: 0.15;
transform: scale(0.94);
pointer-events: none;
}
.ft-frame:has(#ft-3:checked) .ft-tile:not([data-cat=video]) {
opacity: 0.15;
transform: scale(0.94);
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.ft-indicator, .ft-tile {
transition: none !important;
}
}var frame = document.querySelector('.ft-frame');
if (frame.classList.contains('is-demo')) {
var radios = document.querySelectorAll('.ft-radio'), i = 0;
var iv = setInterval(function () { i = (i + 1) % radios.length; radios[i].checked = true; }, 700);
radios.forEach(function (r) {
r.addEventListener('change', function () { frame.classList.remove('is-demo'); clearInterval(iv); });
});
}import { useState } from "react";
import styles from "./FilterTabsHas.module.scss";
export interface FilterTabsHasProps {
duration?: string;
easing?: string;
color?: string;
}
const TABS = ["All", "Photo", "Video"] as const;
const TILES: { cat: "photo" | "video" }[] = [
{ cat: "photo" }, { cat: "video" }, { cat: "photo" },
{ cat: "video" }, { cat: "photo" }, { cat: "video" },
];
export default function FilterTabsHas({
duration = "300ms",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: FilterTabsHasProps) {
const [active, setActive] = useState(0);
const filterCat = active === 1 ? "photo" : active === 2 ? "video" : null;
return (
<div className={styles.frame}>
<div className={styles.tabs}>
<span
className={styles.indicator}
style={{ transform: `translateX(${active * 64}px)`, transitionDuration: duration, transitionTimingFunction: easing, background: color }}
/>
{TABS.map((label, i) => (
<button key={label} type="button" className={styles.tab} onClick={() => setActive(i)}>{label}</button>
))}
</div>
<div className={styles.gallery}>
{TILES.map((t, i) => {
const dim = filterCat !== null && t.cat !== filterCat;
return (
<div
key={i}
className={`${styles.tile} ${styles["tile-" + t.cat]}`}
style={{ opacity: dim ? 0.15 : 1, transform: dim ? "scale(.94)" : "scale(1)", transitionDuration: duration, transitionTimingFunction: easing }}
/>
);
})}
</div>
</div>
);
}09 · :hasベースのフィルタタブ — 計測 累積面積 24.351% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
インジケーターは64px固定幅の正確な倍数でtranslateXするので、タブの数が増えても位置がずれません。
.ft-frame:has(#ft-2:checked) .ft-tile:not([data-cat="photo"]) {
opacity: .15;
transform: scale(.94);
pointer-events: none;
}
.ft-frame:has(#ft-3:checked) .ft-tile:not([data-cat="video"]) {
opacity: .15;
transform: scale(.94);
pointer-events: none;
}
CSS アニメーション ギャラリーはどこで壊れるか
何が問題か
css アニメーション 作り方 として一番つまずいたのは08番でした。mask-size: 260%のようにマスク画像を要素より大きくすると、mask-positionのパーセンテージが直感と逆向きに効きます。最初はmask-position: 100% 0のほうが写真を隠す値で0 0のほうが写真を見せる値だろうと思い込んでいましたが、実際にレンダリングしたフレームを1枚ずつ確認すると逆でした。100% 0のときはグラデーションの黒い部分(見える部分)が要素全体を覆い、0 0のときは透明な部分が要素全体を覆って写真が完全に隠れていたのです。base側と.in側の値を入れ替えてようやく、スクロールで写真が現れる向きになりました。
どう避けるか
背後に敷いた写真が前の文字を損なう判断は、文字そのものが動くキネティックタイポグラフィのLPで背景の可読性問題として戻ってきます。
05番と07番にも別の注意点があります。dialogが開く瞬間に@starting-styleで登場アニメーションを付けたくなりますが、SafariとFirefoxでの対応がまだ揃っていないためこのデモでは使わず、dialogはブラウザ標準のとおり瞬時に開き、::backdropもアニメーションさせない静的な半透明幕1枚に留めています。07番も名前とは裏腹にdocument.startViewTransition()は使っておらず、クリック位置を測ってtransformだけで拡大するFLIP手法にしているのは、ブラウザによって対応や見え方が割れるためです。mask-sizeを100%より大きくする効果を使うときは、まず1枚だけ実際にレンダリングしてmask-positionの両端を目で確認してから値を決めると、同じ思い込みを避けられます。9項目のvanillaコードとzipの解凍パスワードは63vswp74で、この段落をゆっくり読めば見つかります。
アクセシビリティ — reduced-motionで9個が何を残すか
reduced-motion で残るもの
OSで「視差効果を減らす」など動作を減らす設定をオンにすると、9個ともprefers-reduced-motion: reduceでアニメーションとトランジションが止まります。01・02・03・06のように写真やキャプションを見せる演出は、ホバーした瞬間の最終形(覆いが外れた写真、上がりきったキャプション)へ動きなしでそのまま切り替わるため情報は欠けません。04(メイソンリー)と08(スクロールマスク)も同様で、タイルの登場順や境界のやわらかさそのものが情報ではないので、最初から完全に現れた状態へ直接切り替えれば十分です。05のdialogはもともとブラウザ標準の即時表示なので変化はほとんどなく、07はクリック後のtransform遷移だけが止まり、拡大した写真という最終結果は変わりません。
そのほかに気をつけること
09だけは事情が違い、どのタブが選ばれているか自体が情報なので、トランジションを止めてもopacityとtransformの値は:has()によって即座に切り替わり、フィルター機能自体は変わらず働きます。写真そのものが動くホバー効果を探しているならCSS画像ホバー効果の記事も参考になり、一覧はCSSカテゴリーからまとめて見られます。clip-pathの詳しい構文はMDNのclip-pathドキュメントに、:has()セレクターの対応状況はMDNの:has()ドキュメントにまとまっています。
FAQ — よくある質問
なぜ写真をそのまま出さずにリビール効果を使うのですか?
必須ではありませんが、写真がゆっくり、あるいは面白い形で現れると訪問者の視線がその瞬間に留まりやすくなります。01〜03のように写真1枚に添える軽い演出から、07のようにクリックまでつながる演出まで、画面の目的に合わせて選べます。
05番と07番はどちらもライトボックスですが、何が違いますか?
05番はネイティブのdialog要素とshowModal()だけで完結し、フォーカスの管理やEscキーで閉じる動作をブラウザが面倒を見てくれます。07番はサムネイルの位置から拡大していく演出を重視した作りで、自前のdivパネルをtransformで動かすぶんコードは長くなりますが、写真がどこから出てきたかが視覚的にわかります。
zip内の css アニメーション コピペ 用コードはvanillaとreactで違いますか?
見た目の動きは同じです。vanilla側はSCSS変数を使い、実際に使う経路とキャプチャ用のis-demo経路を1つのファイルに両方持っていますが、React版はis-demoを持たず実際に使う経路だけをコンポーネントのpropsとして受け取る形にしています。並べ替え・拡大・ライトボックスまで揃えたギャラリー全体が要るなら、画像ギャラリーのCSS効果がその拡張版です。









