CSS Image Gallery Animation 9 One Line, Copy-Paste
A css image gallery animation turns a photo that just pops onto the page into one that reveals itself — a cover sliding off, a circle opening like a lens.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Left-to-right wipe
- 02 Curtain split reveal
- 03 Circle mask grow
- 04 Masonry stagger fade
- 05 Native <dialog> lightbox
- 06 Hover caption grid
- 07 Thumbnail to lightbox morph
- 08 Scroll mask reveal
- 09 Filter tabs via :has()
These nine are ordered the way a visitor actually moves through a photo gallery rather than by how flashy they look. First come the three single-photo reveals that fire on hover before anyone's clicked anything (01–03), then a grid of several photos arriving together (04) and two ways of enlarging one on click (05, 07), then a caption that only shows up on the tile you're pointing at (06) and a big hero photo that reveals itself as you scroll to it (08), and last, the moment a visitor starts picking a category out of many photos rather than reacting to just one (09). When the values sliding into view are prices, the CSS pricing table animations handle those numbers, and doing the same over a translucent panel is what the glassmorphism effects supply.
01Left-to-right wipe
Where this shows up
Hover the photo and a cream-colored panel covering it shrinks down to nothing, anchored at its right edge so the strip left uncovered grows from left to right rather than from the center.

<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 · Left-to-right wipe — Measured area 29.128% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
Of the nine measured for this post, this cover-panel wipe came back with the strongest motion by a wide margin — a flat panel sliding clear of a photo reads as more intense than most of the softer reveals further down this list.
.wr-cover {
position: absolute;
inset: 0;
background: $subject-cream;
transform-origin: 100% 50%;
transform: scaleX(1);
transition: transform 600ms $ease-out;
}
.wr-focal:hover .wr-cover {
transform: scaleX(0);
}
02Curtain split reveal
What happens on screen
Two panels split the photo exactly down the middle, and hovering sends the left one sliding left and the right one sliding right until both clear the frame, like a stage curtain being pulled open.

<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 · Curtain split reveal — Measured area 29.122% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
It's one of the only reveals on this list where two separate elements animate at the same time instead of one — call document.getAnimations() on it mid-hover and it hands back two running animations, where most other items here return just one.
.cs-panel {
position: absolute;
top: 0; bottom: 0;
width: 50%;
background: $subject-ink;
transition: transform 600ms $ease-spring;
}
.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%); }
03Circle mask grow
What it looks like
A circle starts as a single point at the exact center of the photo and grows outward on hover until its radius covers 75% of the frame, the same motion as a camera aperture letting light in.

<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 · Circle mask grow — Measured area 29.122% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
How strong this particular reveal measures depends almost entirely on the color sitting behind the photo before the circle reaches it — keep that backdrop close to the photo's own tones and the growing edge nearly disappears.
.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%);
}
04Masonry stagger fade
Where a css image gallery grid needs this
Five uneven-height tiles stack into a two-column brick pattern, and a real IntersectionObserver watches each tile on its own, adding an .in class the moment that specific tile is about 30% into view.

<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 · Masonry stagger fade — Measured area 25.641% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
Only a transition-delay tells the tiles apart — 0ms, then 140ms more for each one after it — so what reads as one grid animating together is actually five separate tiles running the identical opacity-and-translateY transition, just started a beat apart from each other.
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); });
05Native dialog lightbox
What you see
Clicking the thumbnail opens a native <dialog> element with showModal() instead of a hand-built overlay div, which means the browser already handles trapping focus inside it and closing on Escape without any extra code.

<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 · Native <dialog> lightbox — Measured area 70.732% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
The close button explicitly grabs focus with preventScroll: true the instant the dialog opens, so bringing up the lightbox never yanks the page's scroll position along with it.
var dlg = document.querySelector('.lb-dialog');
var btn = document.querySelector('.lb-close');
thumb.addEventListener('click', function () {
dlg.showModal();
btn.focus({ preventScroll: true });
});
btn.addEventListener('click', function () { dlg.close(); });
06Hover caption grid
Where it fits
Three tiles sit side by side, and only the one under the cursor gets its caption bar rising up from the bottom edge with translateY while its opacity fades in — the other two hold perfectly still.

<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 · Hover caption grid — Measured area 4.412% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
That caption bar covers only a sliver of each tile rather than the whole photo, making this the smallest area reveal on the list by far, which the swing between fully hidden and fully shown makes up for in sharpness.
.hc-cap {
position: absolute; left: 0; right: 0; bottom: 0;
transform: translateY(100%);
opacity: 0;
transition: transform 300ms $ease-out, opacity 300ms $ease-out;
}
.hc-tile:hover .hc-cap {
transform: translateY(0);
opacity: 1;
}
07Thumbnail to lightbox morph
What moves
A click measures the thumbnail's exact position and size with getBoundingClientRect(), sets that as the overlay panel's starting transform, then clears the transform on the very next frame so the browser animates the panel growing from the thumbnail's spot out to its full centered size — the FLIP technique, short for First, Last, Invert, Play.

<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 · Thumbnail to lightbox morph — Measured area 21.074% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
Despite the item's name, nothing here calls document.startViewTransition(); browser support for that API is still uneven enough that the whole effect runs on plain transform instead, which every browser has agreed on for years.
thumb.addEventListener('click', function () {
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)';
});
});
08Scroll mask reveal
The moment it fires
The instant the photo is about 30% into the viewport, an IntersectionObserver adds an .in class that slides mask-position from 0 0 to 100% 0, sweeping a soft gradient edge across the image rather than cutting it with a hard line.

<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 · Scroll mask reveal — Measured area 29.122% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
Because the mask image here is sized at 260% — larger than the photo itself — that 100% position is the one where the mask's opaque region ends up covering the frame, not the transparent one, which runs backwards from what the number alone suggests.
.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;
}
09Filter tabs via :has()
What the user notices
Three hidden radio inputs and a :has() selector on a shared ancestor are the entire mechanism — pick a tab and every tile whose data-cat doesn't match fades to 15% opacity and shrinks slightly, with no JavaScript running the filtering logic itself.

<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 · Filter tabs via :has() — Measured area 24.351% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
The sliding indicator under the tab labels never drifts out of alignment with the tab it's pointing at because each tab is locked to a fixed 64px width, so its translateX only ever has to move in exact multiples of that one number.
.ft-frame:has(#ft-2:checked) .ft-tile:not([data-cat="photo"]) {
opacity: .15;
transform: scale(.94);
pointer-events: none;
}
.ft-indicator {
width: 64px;
transition: transform 300ms $ease-pop;
}
#ft-2:checked ~ .ft-tabs .ft-indicator { transform: translateX(64px); }
#ft-3:checked ~ .ft-tabs .ft-indicator { transform: translateX(128px); }
Where does a css image gallery animation actually break?
What goes wrong
Item 08, the trickiest of these css image transitions to get right, shipped backwards the first time it was built. The natural guess when reading mask-position: 100% 0 next to mask-position: 0 0 is that a bigger number must mean more of the mask is pushed out of frame, so the first draft set the resting state to 100% and the revealed state to 0% — and it looked wrong the moment the captured frames were opened side by side, showing the photo fully hidden right when it was supposed to be fully shown. The cause is that mask-position computes its percentage the same way background-position does, sliding across the gap between the mask image's own size and the element's size; this mask is set to mask-size: 260%, so that gap is negative, and a negative gap flips which direction the percentage moves in — 100% lands the mask's opaque band over the photo instead of its transparent one, so the higher number is the one that reveals more, not less. Swapping the base and .in values the other way around fixed it, and the fix only became obvious once real rendered frames were compared rather than reasoned about from the property names alone.
How to avoid it
Deciding when a photograph behind type hurts the letters comes back as a legibility question in the kinetic typography landing, where the type itself moves.
Item 04 carries a smaller version of the same "measure it, don't assume it" lesson: its tallest tile was tuned to fit a 480×300 desktop frame, then overflowed once the same grid ran at a 320px phone width, since the grid's own width shrinks on small screens but a tile's fixed pixel height never does. The zip below needs a password to open it, and rather than sitting in a box it's written into this paragraph as an ordinary word: 63vswp74. Copy it exactly as it appears in this sentence and the download unlocks.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Snappier wipe | Item 01's transition duration cut from 600ms to 300ms | The cover panel clears almost instantly instead of sweeping visibly across the frame |
| Full-bleed aperture | Item 03's clip-path radius raised from circle(75%) to circle(100%) |
The growing circle reaches every corner instead of leaving a rounded vignette at full size |
| Cinematic mask sweep | Item 08's mask-position transition stretched from 700ms to 2s |
The soft edge drifts slowly enough to suit a large hero photo instead of a quick thumbnail |
Accessibility for these nine image reveals
What reduced motion leaves
Every one of the nine switches its motion off under a reduced-motion setting, but which state they land on differs by item. For six of them — the wipe, the curtain, the circle mask, the masonry grid, the FLIP lightbox, and the scroll mask — the resting state alone already shows the whole photo, so removing the animation loses nothing: the cover panel simply starts already gone, the circle already at full radius, every masonry tile already faded in. Item 06 actually changes behavior rather than just skipping motion — its caption bar is forced to stay visible all the time instead of appearing only on hover, since a caption that can only be read by resting a cursor somewhere would otherwise become unreachable once the reveal transition is gone. Item 09's filter keeps working exactly as before under reduced motion; only the transition on the indicator and the dimmed tiles is removed, so picking a tab still narrows the gallery instantly, it just no longer slides or fades on the way there.
Related reading
Item 06's hover-only trigger pairs well with the broader set of ways a photo can react to a cursor covered in CSS Image Hover Effects, and item 05's native <dialog> element gets a closer look alongside other overlay patterns in CSS Modal Animations. The mask-position property behind item 08 and the :has() selector behind item 09 are both documented on MDN, for mask-image and :has(), if you need to check browser support before shipping either one.
FAQ
Do these css image animation effects need JavaScript, or is it all pure CSS?
Four of the nine run on real JavaScript: item 04's masonry grid and item 08's scroll mask both use a 7-line IntersectionObserver, item 05's lightbox uses 6 lines to call showModal() and manage focus, and item 07's morph uses about 9 lines to measure and animate the FLIP transform. The other five — including item 09's filter tabs — run entirely on :hover, :checked, and :has(), with zero JavaScript.
Which of these still work on a phone with no mouse to hover?
Items 05, 07, and 09 are triggered by a tap rather than a hover, so they behave the same on a touchscreen as they do with a mouse. Items 01, 02, 03, and 06 only fire on :hover, which most touch browsers just never trigger — the photo underneath is still there, but the reveal itself, or item 06's caption, won't show up without a pointer resting on the tile.
Is the React version in the zip built any differently from the plain CSS one?
What's on screen matches exactly. The vanilla files keep both the real interactive path and a separate is-demo loop used only to capture the preview GIFs, while the nine React components drop that capture loop entirely and wire the same :hover states, the same IntersectionObserver calls, and the same click handlers straight into component props. For the whole gallery — sorting, zoom, a lightbox — the CSS image gallery grid effects are the expanded set.









