CSS 이미지 호버 효과 9종 — 한 줄 복붙으로
이미지 호버 효과는 마우스를 올리거나 초점을 맞췄을 때 사진이 어떻게 반응하는지 정하는 짧은 전환입니다. 아홉 개 중 하나(07)는 사진 두 장을 겹쳐 원 모양 창을 키워 드러내는데, CSS에는 그 반지름을 끝까지 키우는 코너 키워드가 없어서 값을 직접 잡아야 합니다.
01 image-hover-zoom-in
02 image-hover-grayscale-to-color
03 image-hover-caption-slide
04 image-hover-overlay-fade
05 image-hover-tilt-3d
06 image-hover-brightness-sweep
07 image-hover-clip-circle-zoom
08 image-hover-cross-fade
09 image-hover-shake
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개 css 이미지 애니메이션 효과는 인기 순이 아니라 사진이 카드 안에서 하는 일 순서로 놓았습니다. 존재감을 키우는 것(01~02), 정보를 더 얹는 것(03~04), 입체감과 고급스러움을 더하는 것(05~06), 다른 사진으로 갈아 끼우는 것(07~08), 마지막으로 시선을 붙잡는 것(09)입니다. 데모는 실제 사진 파일 없이 CSS 그러데이션으로 "사진 자리"만 채웠으니, 실서비스에 쓸 때는 배경 대신 <img> 하나만 끼워 넣으면 됩니다. 사진 뒤에 깔리는 면이 스스로 움직여야 하면 CSS 배경 애니메이션 9종이고, 사진 옆에 붙는 수치가 올라가야 하면 CSS 숫자 증가 애니메이션이 그 몫을 맡습니다.
01확대 줌인
css 이미지 효과로 무엇이 바뀌나
사진을 감싼 카드에 overflow: hidden을 걸고 안쪽 사진만 transform: scale로 14% 키웁니다.
어디에 쓰나
카드 테두리는 그대로라 사진만 도드라져 보입니다.

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<figcaption class="photo__cap">핸드메이드 머그</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 확대가 끝나는 시간
$easing: $ease-spring; // 탄력 있게 붙는 느낌
$color: $subject-cream; // 사진 프레임 색
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden; // 확대된 사진이 프레임 밖으로 안 나가게
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
transform: scale(1);
transition: transform $duration $easing;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $stage-ink;
background: $color;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
transform: scale(1.14);
}
// 데모 · 문서용: 가운데 2초 주기로 자동 반복
.photo.is-demo .photo__pic {
animation: zoom-loop $dur-loop $easing infinite;
}
@keyframes zoom-loop {
0%, 20% { transform: scale(1); }
50%, 75% { transform: scale(1.14); }
100% { transform: scale(1); }
}
// 터치 기기: hover 가 없으니 탭(:active)이 곧 트리거
@media (hover: none) {
.photo:active .photo__pic { transform: scale(1.14); }
}
@media (prefers-reduced-motion: reduce) {
.photo__pic { transition: none; }
.photo.is-demo .photo__pic { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #fff7e6;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
transform: scale(1);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
background: #fff7e6;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
transform: scale(1.14);
}
.photo.is-demo .photo__pic {
animation: zoom-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes zoom-loop {
0%, 20% {
transform: scale(1);
}
50%, 75% {
transform: scale(1.14);
}
100% {
transform: scale(1);
}
}
@media (hover: none) {
.photo:active .photo__pic {
transform: scale(1.14);
}
}
@media (prefers-reduced-motion: reduce) {
.photo__pic {
transition: none;
}
.photo.is-demo .photo__pic {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./ZoomInPhoto.module.scss";
export type ZoomInPhotoProps = {
caption: string;
/** 확대가 끝나는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ZoomInPhoto({
caption,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ZoomInPhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}01 · 확대 줌인 — 측정 누적면적 3.017% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.photo { overflow: hidden; }
.photo__pic {
transform: scale(1);
transition: transform $duration $easing;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
transform: scale(1.14);
}
02흑백→컬러
어떻게 움직이나
filter: grayscale(1)로 채도를 뺀 사진이 호버·포커스 시 grayscale(0)으로 트랜지션됩니다.
어디에 쓰나
원래 색이 서서히 되살아나는 것처럼 보입니다.

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<figcaption class="photo__cap">봄의 정원</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 채도가 돌아오는 시간
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$color: $subject-ink; // 사진 프레임 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
filter: grayscale(1);
transition: filter $duration $easing;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-orange;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $subject-cream;
background: $color;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
filter: grayscale(0);
}
.photo.is-demo .photo__pic {
animation: color-loop $dur-loop $easing infinite;
}
@keyframes color-loop {
0%, 20% { filter: grayscale(1); }
50%, 75% { filter: grayscale(0); }
100% { filter: grayscale(1); }
}
@media (hover: none) {
.photo:active .photo__pic { filter: grayscale(0); }
}
@media (prefers-reduced-motion: reduce) {
.photo__pic { transition: none; }
.photo.is-demo .photo__pic { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #17141a;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
filter: grayscale(1);
transition: filter 300ms ease-out;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ff4d1f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
background: #17141a;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
filter: grayscale(0);
}
.photo.is-demo .photo__pic {
animation: color-loop 2s ease-out infinite;
}
@keyframes color-loop {
0%, 20% {
filter: grayscale(1);
}
50%, 75% {
filter: grayscale(0);
}
100% {
filter: grayscale(1);
}
}
@media (hover: none) {
.photo:active .photo__pic {
filter: grayscale(0);
}
}
@media (prefers-reduced-motion: reduce) {
.photo__pic {
transition: none;
}
.photo.is-demo .photo__pic {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./GrayscaleToColorPhoto.module.scss";
export type GrayscaleToColorPhotoProps = {
caption: string;
/** 채도가 돌아오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GrayscaleToColorPhoto({
caption,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: GrayscaleToColorPhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}02 · 흑백→컬러 — 측정 누적면적 12.272% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.photo__pic {
filter: grayscale(1);
transition: filter $duration $easing;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
filter: grayscale(0);
}
03캡션 슬라이드
어떻게 움직이나
사진 아래 숨어 있던 캡션 바가 translateY(100%)에서 translateY(0)으로 밀려 올라오며 제목과 설명을 드러냅니다.
어디에 쓰나
캡션은 콘텐츠라 터치 기기에선 기본으로 보이게 했습니다.

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<figcaption class="photo__cap">
<strong class="photo__cap-title">카페 브루잉 가이드</strong>
<span class="photo__cap-desc">5분 만에 읽기</span>
</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 캡션이 올라오는 시간
$easing: $ease-out;
$color: $subject-cream; // 캡션 배경 색
@include stage($stage-orange);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden; // 캡션이 밖으로 안 나가게
box-shadow: $shadow-raised;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-blue 0%, $subject-ink 100%);
}
.photo__sun {
position: absolute;
top: 14%;
right: 16%;
width: 18%;
aspect-ratio: 1;
border-radius: 50%;
background: $subject-cream;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 46%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $stage-orange;
opacity: .55;
}
.photo__cap {
word-break: keep-all;
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: grid;
gap: 2px;
padding: 12px 14px;
background: $color;
transform: translateY(100%); // 아래로 숨겨 둔다
transition: transform $duration $easing;
}
.photo__cap-title {
word-break: keep-all;
font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $stage-ink;
}
.photo__cap-desc {
word-break: keep-all;
font-weight: 500; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.3; font-family: inherit;
color: $stage-ink;
}
.photo:hover .photo__cap,
.photo:focus-visible .photo__cap,
.photo:active .photo__cap {
word-break: keep-all;
transform: translateY(0);
}
.photo.is-demo .photo__cap {
word-break: keep-all;
animation: slide-loop $dur-loop $easing infinite;
}
@keyframes slide-loop {
0%, 20% { transform: translateY(100%); }
50%, 75% { transform: translateY(0); }
100% { transform: translateY(100%); }
}
// 터치: 캡션은 콘텐츠라 hover 가 없는 기기에선 기본으로 보이게 한다
@media (hover: none) {
.photo__cap {
word-break: keep-all; transform: translateY(0); }
.photo.is-demo .photo__cap {
word-break: keep-all; animation: none; }
}
@media (prefers-reduced-motion: reduce) {
.photo__cap {
word-break: keep-all; transition: none; }
.photo.is-demo .photo__cap {
word-break: keep-all; animation: none; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.photo__sun {
position: absolute;
top: 14%;
right: 16%;
width: 18%;
aspect-ratio: 1;
border-radius: 50%;
background: #fff7e6;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 46%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #ff4d1f;
opacity: 0.55;
}
.photo__cap {
word-break: keep-all;
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: grid;
gap: 2px;
padding: 12px 14px;
background: #fff7e6;
transform: translateY(100%);
transition: transform 300ms ease-out;
}
.photo__cap-title {
word-break: keep-all;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.photo__cap-desc {
word-break: keep-all;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.photo:hover .photo__cap,
.photo:focus-visible .photo__cap,
.photo:active .photo__cap {
word-break: keep-all;
transform: translateY(0);
}
.photo.is-demo .photo__cap {
word-break: keep-all;
animation: slide-loop 2s ease-out infinite;
}
@keyframes slide-loop {
0%, 20% {
transform: translateY(100%);
}
50%, 75% {
transform: translateY(0);
}
100% {
transform: translateY(100%);
}
}
@media (hover: none) {
.photo__cap {
word-break: keep-all;
transform: translateY(0);
}
.photo.is-demo .photo__cap {
word-break: keep-all;
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
.photo__cap {
word-break: keep-all;
transition: none;
}
.photo.is-demo .photo__cap {
word-break: keep-all;
animation: none;
transform: translateY(0);
}
}import type { CSSProperties } from "react";
import styles from "./CaptionSlidePhoto.module.scss";
export type CaptionSlidePhotoProps = {
title: string;
desc: string;
/** 캡션이 올라오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 캡션 배경 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CaptionSlidePhoto({
title,
desc,
duration = 300,
easing = "ease-out",
color = "#fff7e6",
}: CaptionSlidePhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<figcaption className={styles.cap}>
<strong className={styles.title}>{title}</strong>
<span className={styles.desc}>{desc}</span>
</figcaption>
</figure>
);
}03 · 캡션 슬라이드 — 측정 누적면적 6.603% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.photo__cap {
position: absolute;
inset: auto 0 0 0;
transform: translateY(100%);
transition: transform $duration $easing;
}
.photo:hover .photo__cap,
.photo:focus-visible .photo__cap,
.photo:active .photo__cap {
transform: translateY(0);
}
@media (hover: none) {
.photo__cap { transform: translateY(0); }
}
04오버레이 페이드
어떻게 움직이나
사진 위에 반투명 색 레이어가 opacity: 0에서 1로 트랜지션되며 덮이고, 가운데 "자세히 보기" 문구가 함께 떠오릅니다.

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<span class="photo__overlay">
<span class="photo__overlay-text">자세히 보기</span>
</span>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 오버레이가 덮이는 시간
$easing: $ease-out;
$color: $subject-blue; // 오버레이 색
@include stage($stage-paper);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $subject-ink;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba($color, .82);
opacity: 0; // 평소엔 투명
transition: opacity $duration $easing;
}
.photo__overlay-text {
word-break: keep-all;
padding: 8px 18px;
border-radius: $r-pill;
font-weight: 700; font-size: clamp(11px, 3vw, 13px); line-height: 1; font-family: inherit;
color: $subject-cream;
border: 1px solid $subject-cream;
}
.photo:hover .photo__overlay,
.photo:focus-visible .photo__overlay,
.photo:active .photo__overlay {
opacity: 1;
}
.photo.is-demo .photo__overlay {
animation: fade-loop $dur-loop $easing infinite;
}
@keyframes fade-loop {
0%, 20% { opacity: 0; }
50%, 75% { opacity: 1; }
100% { opacity: 0; }
}
@media (hover: none) {
.photo:active .photo__overlay { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.photo__overlay { transition: none; }
.photo.is-demo .photo__overlay { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #17141a;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(47, 109, 246, 0.82);
opacity: 0;
transition: opacity 300ms ease-out;
}
.photo__overlay-text {
word-break: keep-all;
padding: 8px 18px;
border-radius: 999px;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
border: 1px solid #fff7e6;
}
.photo:hover .photo__overlay,
.photo:focus-visible .photo__overlay,
.photo:active .photo__overlay {
opacity: 1;
}
.photo.is-demo .photo__overlay {
animation: fade-loop 2s ease-out infinite;
}
@keyframes fade-loop {
0%, 20% {
opacity: 0;
}
50%, 75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (hover: none) {
.photo:active .photo__overlay {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.photo__overlay {
transition: none;
}
.photo.is-demo .photo__overlay {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./OverlayFadePhoto.module.scss";
export type OverlayFadePhotoProps = {
overlayText: string;
/** 오버레이가 덮이는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 오버레이 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function OverlayFadePhoto({
overlayText,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: OverlayFadePhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<span className={styles.overlay}>
<span className={styles.overlayText}>{overlayText}</span>
</span>
</figure>
);
}04 · 오버레이 페이드 — 측정 누적면적 13.208% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.photo__overlay {
position: absolute;
inset: 0;
background: rgba($color, .82);
opacity: 0;
transition: opacity $duration $easing;
}
.photo:hover .photo__overlay,
.photo:focus-visible .photo__overlay,
.photo:active .photo__overlay {
opacity: 1;
}
053D 틸트
어떻게 움직이나
부모에 perspective를 주고 카드에 transform-style: preserve-3d를 걸면, 호버 시 rotateX·rotateY로 살짝 기운 카드가 입체적으로 들리는 것처럼 보입니다.

<main class="stage">
<div class="frame">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<figcaption class="photo__cap">우수 파트너 인증</figcaption>
</figure>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 기울기가 붙는 시간
$easing: $ease-spring;
$color: $subject-cream; // 카드 프레임 색
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.frame {
perspective: 700px; // 자식 카드가 이 값 기준으로 기운다
}
.photo {
margin: 0;
width: min(150px, 48vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
transform-style: preserve-3d;
transform: rotateX(0) rotateY(0);
transition: transform $duration $easing;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $stage-ink;
background: $color;
}
.photo:hover,
.photo:focus-visible,
.photo:active {
transform: rotateX(-9deg) rotateY(11deg) translateY(-4px);
}
.photo.is-demo {
animation: tilt-loop $dur-loop $easing infinite;
}
@keyframes tilt-loop {
0%, 15% { transform: rotateX(0) rotateY(0); }
40% { transform: rotateX(-9deg) rotateY(11deg) translateY(-4px); }
65% { transform: rotateX(6deg) rotateY(-9deg) translateY(-2px); }
100% { transform: rotateX(0) rotateY(0); }
}
@media (hover: none) {
.photo:active { transform: rotateX(-9deg) rotateY(11deg) translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.photo { transition: none; }
.photo.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.frame {
perspective: 700px;
}
.photo {
margin: 0;
width: min(150px, 48vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #fff7e6;
outline: none;
cursor: pointer;
transform-style: preserve-3d;
transform: rotateX(0) rotateY(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
background: #fff7e6;
}
.photo:hover,
.photo:focus-visible,
.photo:active {
transform: rotateX(-9deg) rotateY(11deg) translateY(-4px);
}
.photo.is-demo {
animation: tilt-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tilt-loop {
0%, 15% {
transform: rotateX(0) rotateY(0);
}
40% {
transform: rotateX(-9deg) rotateY(11deg) translateY(-4px);
}
65% {
transform: rotateX(6deg) rotateY(-9deg) translateY(-2px);
}
100% {
transform: rotateX(0) rotateY(0);
}
}
@media (hover: none) {
.photo:active {
transform: rotateX(-9deg) rotateY(11deg) translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.photo {
transition: none;
}
.photo.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./Tilt3DPhoto.module.scss";
export type Tilt3DPhotoProps = {
caption: string;
/** 기울기가 붙는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 카드 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function Tilt3DPhoto({
caption,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: Tilt3DPhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.frame}>
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
</div>
);
}05 · 3D 틸트 — 측정 누적면적 5.519% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.frame { perspective: 700px; }
.photo {
transform-style: preserve-3d;
transform: rotateX(0) rotateY(0);
transition: transform $duration $easing;
}
.photo:hover,
.photo:focus-visible,
.photo:active {
transform: rotateX(-9deg) rotateY(11deg) translateY(-4px);
}
06밝기 스윕
어떻게 움직이나
대각선으로 기운 밝은 띠를 그러데이션으로 그려 화면 밖에 두었다가, 호버 시 transform: translateX로 한 번 훑고 지나가게 합니다.

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
<span class="photo__sweep"></span>
</span>
<figcaption class="photo__cap">리미티드 에디션</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 띠가 한 번 지나가는 시간
$easing: $ease-out;
$color: $subject-ink; // 사진 프레임 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-ink 0%, $subject-blue 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $subject-cream;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $stage-orange;
opacity: .6;
}
.photo__sweep {
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 30%, rgba($subject-cream, .65) 50%, transparent 70%);
transform: translateX(-120%);
pointer-events: none;
}
.photo:hover .photo__sweep,
.photo:focus-visible .photo__sweep,
.photo:active .photo__sweep {
transform: translateX(120%);
transition: transform $duration $easing;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $subject-cream;
background: $color;
}
.photo.is-demo .photo__sweep {
animation: sweep-loop $dur-loop $easing infinite;
}
@keyframes sweep-loop {
0%, 15% { transform: translateX(-120%); }
65% { transform: translateX(120%); }
100% { transform: translateX(120%); }
}
@media (hover: none) {
.photo:active .photo__sweep { transform: translateX(120%); transition: transform $duration $easing; }
}
@media (prefers-reduced-motion: reduce) {
.photo__sweep { transition: none; }
.photo.is-demo .photo__sweep { animation: none; transform: translateX(-120%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #17141a;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #17141a 0%, #2f6df6 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #fff7e6;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #ff4d1f;
opacity: 0.6;
}
.photo__sweep {
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 30%, rgba(255, 247, 230, 0.65) 50%, transparent 70%);
transform: translateX(-120%);
pointer-events: none;
}
.photo:hover .photo__sweep,
.photo:focus-visible .photo__sweep,
.photo:active .photo__sweep {
transform: translateX(120%);
transition: transform 600ms ease-out;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
background: #17141a;
}
.photo.is-demo .photo__sweep {
animation: sweep-loop 2s ease-out infinite;
}
@keyframes sweep-loop {
0%, 15% {
transform: translateX(-120%);
}
65% {
transform: translateX(120%);
}
100% {
transform: translateX(120%);
}
}
@media (hover: none) {
.photo:active .photo__sweep {
transform: translateX(120%);
transition: transform 600ms ease-out;
}
}
@media (prefers-reduced-motion: reduce) {
.photo__sweep {
transition: none;
}
.photo.is-demo .photo__sweep {
animation: none;
transform: translateX(-120%);
}
}import type { CSSProperties } from "react";
import styles from "./BrightnessSweepPhoto.module.scss";
export type BrightnessSweepPhotoProps = {
caption: string;
/** 띠가 한 번 지나가는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BrightnessSweepPhoto({
caption,
duration = 600,
easing = "ease-out",
color = "#17141a",
}: BrightnessSweepPhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
<span className={styles.sweep} />
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}06 · 밝기 스윕 — 측정 누적면적 12.756% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.photo__sweep {
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 30%, rgba($subject-cream, .65) 50%, transparent 70%);
transform: translateX(-120%);
}
.photo:hover .photo__sweep,
.photo:focus-visible .photo__sweep,
.photo:active .photo__sweep {
transform: translateX(120%);
transition: transform $duration $easing;
}
07클립 원형 확대
어떻게 움직이나
사진 두 장을 겹쳐두고 위쪽 사진에 clip-path: circle(0% at 50% 50%)를 걸어 숨긴 뒤, 호버 시 반지름을 키워 원 모양 창이 확대되며 드러납니다.

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__base" aria-hidden="true"></span>
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<figcaption class="photo__cap">가을 페스티벌 · 미리보기</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 원형 창이 다 커지는 시간
$easing: $ease-spring;
$color: $subject-cream; // 사진 프레임 색
@include stage($stage-orange);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
}
.photo__base,
.photo__pic {
position: absolute;
inset: 0;
}
.photo__base {
background: linear-gradient(155deg, rgba($subject-ink, .9) 0%, rgba($subject-blue, .55) 100%);
}
.photo__pic {
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
clip-path: circle(0% at 50% 50%); // 반지름 0에서 시작
transition: clip-path $duration $easing;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__cap {
word-break: keep-all;
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(11px, 3vw, 13px); line-height: 1.3; font-family: inherit;
color: $subject-cream;
text-shadow: 0 1px 3px rgba($stage-ink, .6);
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
clip-path: circle(150% at 50% 50%);
}
.photo.is-demo .photo__pic {
animation: circle-loop $dur-loop $easing infinite;
}
@keyframes circle-loop {
0%, 15% { clip-path: circle(0% at 50% 50%); }
50%, 75% { clip-path: circle(150% at 50% 50%); }
100% { clip-path: circle(0% at 50% 50%); }
}
@media (hover: none) {
.photo__pic { clip-path: circle(150% at 50% 50%); }
.photo.is-demo .photo__pic { animation: none; }
}
@media (prefers-reduced-motion: reduce) {
.photo__pic { transition: none; }
.photo.is-demo .photo__pic { animation: none; clip-path: circle(150% at 50% 50%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #fff7e6;
outline: none;
cursor: pointer;
}
.photo__base,
.photo__pic {
position: absolute;
inset: 0;
}
.photo__base {
background: linear-gradient(155deg, rgba(23, 20, 26, 0.9) 0%, rgba(47, 109, 246, 0.55) 100%);
}
.photo__pic {
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
clip-path: circle(0% at 50% 50%);
transition: clip-path 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__cap {
word-break: keep-all;
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(11px, 3vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
text-shadow: 0 1px 3px rgba(23, 20, 26, 0.6);
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
clip-path: circle(150% at 50% 50%);
}
.photo.is-demo .photo__pic {
animation: circle-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes circle-loop {
0%, 15% {
clip-path: circle(0% at 50% 50%);
}
50%, 75% {
clip-path: circle(150% at 50% 50%);
}
100% {
clip-path: circle(0% at 50% 50%);
}
}
@media (hover: none) {
.photo__pic {
clip-path: circle(150% at 50% 50%);
}
.photo.is-demo .photo__pic {
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
.photo__pic {
transition: none;
}
.photo.is-demo .photo__pic {
animation: none;
clip-path: circle(150% at 50% 50%);
}
}import type { CSSProperties } from "react";
import styles from "./ClipCircleZoomPhoto.module.scss";
export type ClipCircleZoomPhotoProps = {
caption: string;
/** 원형 창이 다 커지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ClipCircleZoomPhoto({
caption,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ClipCircleZoomPhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.base} aria-hidden="true" />
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}07 · 클립 원형 확대 — 측정 누적면적 12.927% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.photo__pic {
clip-path: circle(0% at 50% 50%);
transition: clip-path $duration $easing;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
clip-path: circle(150% at 50% 50%);
}
08두 장 교차
어떻게 움직이나
같은 자리에 사진 두 장을 절대 위치로 겹쳐두고, 두 번째 사진의 opacity를 0에서 1로 트랜지션해 다른 장면으로 바꿉니다.

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__frame" aria-hidden="true">
<span class="photo__a">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<span class="photo__b">
<span class="photo__sun photo__sun--b"></span>
<span class="photo__hill photo__hill--b"></span>
</span>
</span>
<figcaption class="photo__cap">컬러 옵션</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 두 번째 사진이 번지는 시간
$easing: $ease-out;
$color: $subject-ink; // 사진 프레임 색
@include stage($stage-paper);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
}
.photo__frame {
display: block;
position: relative;
aspect-ratio: 4 / 3;
}
.photo__a,
.photo__b {
position: absolute;
inset: 0;
}
.photo__a {
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
}
.photo__b {
background: linear-gradient(155deg, $subject-blue 0%, $subject-ink 100%);
opacity: 0; // 평소엔 첫 사진만 보인다
transition: opacity $duration $easing;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__sun--b { background: $subject-cream; }
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__hill--b { background: $stage-orange; }
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $subject-cream;
background: $color;
}
.photo:hover .photo__b,
.photo:focus-visible .photo__b,
.photo:active .photo__b {
opacity: 1;
}
.photo.is-demo .photo__b {
animation: cross-loop $dur-loop $easing infinite;
}
@keyframes cross-loop {
0%, 20% { opacity: 0; }
50%, 75% { opacity: 1; }
100% { opacity: 0; }
}
@media (hover: none) {
.photo:active .photo__b { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.photo__b { transition: none; }
.photo.is-demo .photo__b { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #17141a;
outline: none;
cursor: pointer;
}
.photo__frame {
display: block;
position: relative;
aspect-ratio: 4/3;
}
.photo__a,
.photo__b {
position: absolute;
inset: 0;
}
.photo__a {
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
}
.photo__b {
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
opacity: 0;
transition: opacity 300ms ease-out;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__sun--b {
background: #fff7e6;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__hill--b {
background: #ff4d1f;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
background: #17141a;
}
.photo:hover .photo__b,
.photo:focus-visible .photo__b,
.photo:active .photo__b {
opacity: 1;
}
.photo.is-demo .photo__b {
animation: cross-loop 2s ease-out infinite;
}
@keyframes cross-loop {
0%, 20% {
opacity: 0;
}
50%, 75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (hover: none) {
.photo:active .photo__b {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.photo__b {
transition: none;
}
.photo.is-demo .photo__b {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./CrossFadePhoto.module.scss";
export type CrossFadePhotoProps = {
caption: string;
/** 두 번째 사진이 번지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CrossFadePhoto({
caption,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: CrossFadePhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.frame} aria-hidden="true">
<span className={styles.a}>
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<span className={styles.b}>
<span className={`${styles.sun} ${styles.sunB}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</span>
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}08 · 두 장 교차 — 측정 누적면적 14.624% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.photo__a,
.photo__b {
position: absolute;
inset: 0;
}
.photo__b {
opacity: 0;
transition: opacity $duration $easing;
}
.photo:hover .photo__b,
.photo:focus-visible .photo__b,
.photo:active .photo__b {
opacity: 1;
}
09흔들림
어떻게 움직이나
짧은 @keyframes로 좌우 translateX를 몇 픽셀씩 왕복시켜, 호버·포커스·클릭 시 사진이 빠르게 진동하며 시선을 붙잡습니다.

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
<span class="photo__badge">SALE</span>
</span>
<figcaption class="photo__cap">한정 특가</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 흔들림이 끝나는 시간
$easing: $ease-pop;
$color: $subject-cream; // 사진 프레임 색
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: visible; // 흔들릴 때 프레임 밖으로 살짝 나가도 된다
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
transform: translateX(0);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__badge {
position: absolute;
top: 10px;
left: 10px;
padding: 4px 10px;
border-radius: $r-pill;
font-weight: 800; font-size: clamp(10px, 2.8vw, 12px); line-height: 1; font-family: inherit;
letter-spacing: .04em;
color: $subject-cream;
background: $error;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px 0;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $color;
background: transparent;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
animation: shake $duration $easing;
}
.photo.is-demo .photo__pic {
animation: shake-loop $dur-loop linear infinite;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
@keyframes shake-loop {
0%, 55%, 100% { transform: translateX(0); }
60% { transform: translateX(-7px); }
65% { transform: translateX(6px); }
70% { transform: translateX(-6px); }
75% { transform: translateX(5px); }
80% { transform: translateX(-4px); }
85% { transform: translateX(3px); }
90% { transform: translateX(-2px); }
95% { transform: translateX(1px); }
}
@media (hover: none) {
.photo:active .photo__pic { animation: shake $duration $easing; }
}
@media (prefers-reduced-motion: reduce) {
.photo.is-demo .photo__pic { animation: none; }
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: 16px;
overflow: visible;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #fff7e6;
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
transform: translateX(0);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__badge {
position: absolute;
top: 10px;
left: 10px;
padding: 4px 10px;
border-radius: 999px;
font-weight: 800;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
color: #fff7e6;
background: #c2361a;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px 0;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
background: transparent;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
animation: shake 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.photo.is-demo .photo__pic {
animation: shake-loop 2s linear infinite;
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(5px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(3px);
}
}
@keyframes shake-loop {
0%, 55%, 100% {
transform: translateX(0);
}
60% {
transform: translateX(-7px);
}
65% {
transform: translateX(6px);
}
70% {
transform: translateX(-6px);
}
75% {
transform: translateX(5px);
}
80% {
transform: translateX(-4px);
}
85% {
transform: translateX(3px);
}
90% {
transform: translateX(-2px);
}
95% {
transform: translateX(1px);
}
}
@media (hover: none) {
.photo:active .photo__pic {
animation: shake 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
}
@media (prefers-reduced-motion: reduce) {
.photo.is-demo .photo__pic {
animation: none;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./ShakePhoto.module.scss";
export type ShakePhotoProps = {
caption: string;
badge?: string;
/** 흔들림이 끝나는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ShakePhoto({
caption,
badge = "SALE",
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
}: ShakePhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
<span className={styles.badge}>{badge}</span>
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}09 · 흔들림 — 측정 누적면적 3.646% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
animation: shake $duration $easing;
}
어디서 깨지는가 — 함정 둘
circle() 이 안 받는 키워드
07의 clip-path: circle()은 만만해 보이지만 반지름 키워드에 함정이 있습니다. radial-gradient()에는 farthest-corner가 있지만, circle()의 반지름 키워드는 closest-side와 farthest-side뿐입니다. farthest-corner를 그대로 썼더니 브라우저가 값 전체를 무효로 처리해 원이 한 프레임도 움직이지 않는 걸 실측했습니다. MDN의 basic-shape 문서가 circle()이 받는 키워드 목록을 정리해 둡니다.
어떻게 고쳤나
반지름을 150%처럼 넉넉한 퍼센트 값으로 바꾸자 원이 카드 구석까지 제대로 덮였습니다. circle()과 ellipse()는 radial-gradient()와 이름이 비슷해도 받는 키워드 집합이 다르다는 걸 이번에 확실히 배웠고, caniuse의 clip-path 지원표를 같이 열어 반지름 값이 브라우저마다 다르게 처리되지 않는지도 확인해 두었습니다.
터치 기기에서 캡션이 사라진다
또 하나는 03 캡션 슬라이드처럼 사진 위에 숨겨두는 콘텐츠입니다. 호버가 없는 터치 기기에서 @media (hover: none)을 빼먹으면 캡션은 영영 안 보이는 텍스트가 됩니다. caniuse의 hover 미디어 특성 지원표를 보면 대부분의 모바일 브라우저가 이 값을 지원하니, 폴백 없이 방치할 이유가 없습니다.
압축 파일 비밀번호
이 아홉 개 파일을 실제로 렌더해 값을 확인한 압축 파일의 비밀번호는 pbk89rn6이고, 아래 버튼으로 받을 수 있습니다. 이 카드들이 크기가 다른 칸으로 배치되는 판까지 가면 벤토 그리드 디자인 부품 9개가 그 격자를 미리 짜 둔 편입니다.
접근성
동작 줄이기에서
아홉 개 전부 prefers-reduced-motion: reduce를 처리해서, 운영체제에서 "동작 줄이기"를 켠 사람에게는 트랜지션·애니메이션이 꺼지고 마지막 상태만 남습니다. 01·02·04·06·08은 즉시 확대·컬러·오버레이·스윕·교차 상태로 붙고, 03·07처럼 콘텐츠를 가리는 효과는 아예 기본으로 펼쳐 둡니다.
키보드와 터치도 같은 결과
:hover뿐 아니라 :focus-visible과 :active에도 같은 스타일을 걸어서 키보드 사용자와 터치 사용자가 마우스 사용자와 같은 결과를 봅니다. 이 조합은 MDN의 @media prefers-reduced-motion 문서에 정리돼 있습니다.
@media (prefers-reduced-motion: reduce) {
.photo__pic { transition: none; }
.photo.is-demo .photo__pic { animation: none; }
}
이 아홉 개 말고 CSS 로 만드는 다른 전환·등장 효과가 궁금하면 CSS 카테고리 페이지를 열어 보시고, 사이트 소개는 여기에서 확인하십시오.
FAQ
html 이미지 호버 효과에 실제 사진 파일이 꼭 있어야 합니까?
아닙니다. 이 글의 데모 아홉 개는 전부 CSS 그러데이션과 도형만으로 "사진 자리"를 만들었습니다. 실제로 쓸 때는 .photo__pic의 background를 지우고 <img> 태그를 넣거나, object-fit: cover를 건 이미지로 바꾸면 코드는 그대로 동작합니다.
아홉 개를 한 페이지에 다 써도 성능에 문제없습니까?
대부분은 transform과 opacity만 바뀌어 레이아웃을 다시 계산하지 않지만, 02의 filter와 07의 clip-path는 픽셀을 다시 그리는 비용이 조금 더 듭니다. 화면에 동시에 보이는 카드가 수십 장을 넘어가면 이 두 효과만 골라 아끼는 게 낫습니다. 05 3D 틸트를 한 장이 아니라 면 여러 개가 도는 형태로 키우고 싶다면 CSS 3D 큐브 캐러셀 틸트 9종이 그 다음 단계입니다.
모바일에서도 호버와 똑같이 보입니까?
호버가 아니라 탭(:active)이 트리거가 됩니다. 03처럼 콘텐츠를 가리는 효과는 @media (hover: none)에서 기본으로 펼쳐 두었고, 나머지는 탭하는 동안만 효과가 보였다가 손을 떼면 원래대로 돌아갑니다.









