CSS 이미지 슬라이드 애니메이션 9종
사진 위에 마우스를 올리면 커튼처럼 열리거나 원이 점점 커지며 드러나는 걸 본 적 있으신가요? 이런 CSS 이미지 슬라이드 애니메이션을 한 줄 복붙으로 쓸 수 있게 아홉 가지로 모았고, 그중 하나는 자바스크립트 없이 필터 탭까지 만듭니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 좌우 와이프
- 02 커튼 분할 리빌
- 03 원형 마스크 확장
- 04 메이슨리 순차 페이드
- 05 dialog 라이트박스
- 06 호버 캡션 그리드
- 07 썸네일→라이트박스 모핑
- 08 스크롤 마스크 리빌
- 09 :has 기반 필터 탭
아홉 개는 화려한 순서가 아니라 갤러리를 둘러보는 방문자가 실제로 거치는 순서로 배치했습니다. 먼저 사진 한 장이 덮개·커튼·원형 마스크로 드러나는 단일 리빌 기법(01~03)을 지나, 여러 장이 한꺼번에 놓인 그리드가 순서대로 나타나거나(04) 확대되어 보이는 순간(05, 07)을 거칩니다. 그다음은 개별 칸에 정보를 얹는 호버(06)와 스크롤을 따라 드러나는 큰 사진(08)을 지나, 마지막은 여러 장을 카테고리별로 골라보는 필터(09)로 마무리됩니다. 슬라이드로 드러나는 값의 종류가 금액이라면 CSS 가격표 애니메이션이 그 숫자를 다루고, 반투명한 판 위에서 같은 일을 하려면 글래스모피즘 효과가 재료입니다.
01좌우 와이프
이미지 갤러리 css 에서 어디에 쓰나
사진을 덮은 판이 오른쪽 끝을 축으로 폭이 점점 줄어들면서, 왼쪽부터 오른쪽으로 사진이 드러납니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드의 요점
transform-origin을 오른쪽에 고정하고 scaleX만 움직이는 게 핵심입니다.
.wipe-cover {
position: absolute;
inset: 0;
background: #fff7e6;
transform-origin: 100% 50%;
transform: scaleX(1);
transition: transform 600ms ease-out;
}
.wipe-focal:hover .wipe-cover {
transform: scaleX(0);
}
02커튼 분할 리빌
화면에서 벌어지는 일
반으로 나뉜 두 패널이 사진을 덮고 있다가, 마우스를 올리면 왼쪽은 왼쪽으로 오른쪽은 오른쪽으로 완전히 밀려나며 무대 커튼처럼 열립니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
동작 원리
.curtain-l { left: 0; }
.curtain-r { left: 50%; }
.curtain-l, .curtain-r {
position: absolute;
top: 0; bottom: 0;
width: 50%;
transform: translateX(0);
transition: transform 600ms cubic-bezier(.2, .8, .2, 1);
}
.gallery-focal:hover .curtain-l { transform: translateX(-100%); }
.gallery-focal:hover .curtain-r { transform: translateX(100%); }
03원형 마스크 확장
어떻게 보이나
사진 정중앙에서 시작한 원이 반지름 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
만드는 방식
.circle-photo {
clip-path: circle(0% at 50% 50%);
transition: clip-path 600ms ease-out;
}
.circle-focal:hover .circle-photo {
clip-path: circle(75% at 50% 50%);
}
04메이슨리 순차 페이드
언제 쓰나
높낮이가 다른 사진 타일이 벽돌처럼 쌓인 그리드가 화면에 들어오면, 타일마다 순서대로 나타납니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
왜 이렇게 만들었나
타일마다 다른 건 transition-delay 값 하나뿐입니다.
var tiles = document.querySelectorAll('.masonry-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()로 열립니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
구현의 핵심
닫기 버튼은 페이지가 갑자기 스크롤되지 않도록 preventScroll 옵션과 함께 포커스를 받습니다.
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(); });
06호버 캡션 그리드
무엇이 움직이나
세 장이 나란히 놓인 그리드에서 지금 가리킨 칸의 캡션만 아래에서 위로 올라오고 서서히 나타납니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
무엇이 핵심인가
나머지 칸은 그대로 있어 어느 사진을 보고 있는지 헷갈리지 않습니다.
.caption-bar {
transform: translateY(100%);
opacity: 0;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.gallery-tile:hover .caption-bar {
transform: translateY(0);
opacity: 1;
}
07썸네일→라이트박스 모핑
어디에 쓰나
썸네일을 클릭하면 그 위치를 getBoundingClientRect()로 잰 뒤, 오버레이 패널을 그 자리에 먼저 놓고 transform을 지워 확대된 자리까지 부드럽게 전환합니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
짚어 둘 점
FLIP이라 불리는 이 기법은 transform만 쓰므로 브라우저를 가리지 않습니다.
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스크롤 마스크 리빌
눈에 보이는 것
사진이 화면에 들어오면 mask-image가 위치를 오른쪽에서 왼쪽으로 쓸며 지나가, 딱딱한 경계 없이 부드러운 가장자리로 드러납니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떻게 만들었나
트리거는 IntersectionObserver가 붙이는 클래스 하나뿐입니다.
.mask-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;
}
.mask-frame.in .mask-photo {
mask-position: 100% 0;
}
09:has 기반 필터 탭
어떤 상황인가
숨겨진 라디오 버튼 3개와 :has() 셀렉터만으로, 탭을 고르면 다른 카테고리 타일이 옅어지고 살짝 줄어듭니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
자바스크립트 없이도 진짜로 동작하는 필터입니다.
.gallery:has(#f-photo:checked) .tile:not([data-cat="photo"]) {
opacity: .15;
transform: scale(.94);
pointer-events: none;
}
.gallery:has(#f-video:checked) .tile:not([data-cat="video"]) {
opacity: .15;
transform: scale(.94);
pointer-events: none;
}
CSS 이미지 슬라이드 애니메이션은 어디서 깨지는가
무엇이 문제인가
라이트박스는 이미지 슬라이드 효과와 자주 헷갈리는데, 그중에서도 가장 자주 오해하는 지점은 05번과 07번입니다. 라이트박스를 만들 때 dialog가 열리는 순간에 @starting-style로 멋진 등장 트랜지션을 넣고 싶어지지만, 이 기능은 Safari와 Firefox의 지원이 아직 들쭉날쭉해 이 글의 데모에서는 쓰지 않았습니다 — 대신 dialog는 브라우저 기본 방식으로 즉시 열리고, ::backdrop도 애니메이션 없이 정적인 반투명 막 하나로만 남겨 뒀습니다. 07번 역시 이름과 달리 document.startViewTransition() API를 쓰지 않는데, 이 API도 브라우저마다 지원과 결과 화면이 갈리기 때문입니다.
어떻게 피하나
뒤에 깔린 사진이 앞글자를 해치는 판단은 글자가 스스로 움직이는 키네틱 타이포그래피 랜딩에서 배경 가독성 문제로 되돌아옵니다.
대신 클릭한 순간의 좌표를 재서 transform만으로 확대되는 자리까지 옮기는 FLIP 기법을 썼습니다. 04번과 08번은 둘 다 실제 상호작용은 IntersectionObserver가 맡고, 캡처용 반복 재생만 별도의 @keyframes 경로로 흉내 낸다는 공통점이 있으며, 아홉 항목의 vanilla 코드와 zip 압축 비밀번호는 63vswp74 이니 이 문단을 천천히 읽으면 찾을 수 있습니다.
접근성 — 아홉 개가 reduced-motion에서 남기는 것
reduced-motion 에서 남는 것
시스템에서 동작 줄이기를 켜면 아홉 개 전부 애니메이션과 트랜지션을 끕니다. 01·02·03·06·07처럼 사진을 꾸며서 보여주는 반응은 움직임 없이 최종 모습(사진이 드러난 상태)으로 곧장 고정해도 정보 손실이 없습니다. 04(메이슨리 순차 등장)와 08(스크롤 마스크)도 순서나 경계가 움직임 자체에 담긴 정보는 아니라서, 타일과 사진을 처음부터 전부 드러난 상태로 두면 충분합니다. 다만 09(필터 탭)는 다릅니다 — 어떤 탭이 눌렸는지 자체가 정보라서 애니메이션을 꺼도 opacity·transform 값은 즉시 바뀌어야 하고, 실제로 이 항목은 :has()가 상태를 판정하는 구조라 트랜지션만 없앨 뿐 필터링 자체는 항상 그대로 작동합니다.
이어서 볼 글
리빌 말고 사진 자체가 반응하는 호버 효과를 찾는다면 CSS 이미지 호버 효과 글도 참고할 만하고, 전체 목록은 CSS 애니메이션 카테고리에서 모아 볼 수 있습니다. clip-path의 세부 문법은 MDN clip-path 문서에, :has() 셀렉터의 브라우저 지원 범위는 MDN :has() 문서에 정리돼 있습니다.
FAQ — 자주 묻는 질문
css 이미지 애니메이션 효과 없이 사진이 그냥 나타나면 안 되나요?
꼭 써야 하는 건 아니지만, 사진이 서서히 혹은 흥미로운 방식으로 드러나면 방문자의 시선이 그 순간에 머물러 포트폴리오나 상품 갤러리에서 클릭·호버를 유도하는 신호가 됩니다. 01~03처럼 단일 사진에 쓰는 가벼운 효과부터 07처럼 클릭까지 이어지는 효과까지, 화면 목적에 맞춰 고르면 됩니다.
마우스가 없는 터치 기기에서는 어떻게 동작하나요?
01·02·03·06처럼 :hover로 트리거되는 항목은 터치 기기에서 생략돼도 사진 자체는 이미 보이는 상태라 정보 손실이 없습니다. 반면 05·07·09는 클릭이나 탭으로 트리거되는 구조라 마우스가 없는 기기에서도 그대로 동작합니다.
zip 안의 vanilla와 react 코드가 다른가요?
동작은 같습니다. vanilla는 SCSS 변수를 파일 맨 위에 두고 실사용 경로와 캡처용 is-demo 경로를 함께 담고 있고, React 판은 is-demo 없이 실사용 경로만 컴포넌트 props로 받아 동일한 화면을 만듭니다. 정렬·확대·빛 상자까지 갖춘 갤러리 전체가 필요하면 이미지 갤러리 CSS 효과가 그 확장판입니다.









