9 CSS Image Hover Effects — One Line, Copy-Paste
These nine css image hover effects each run on a single wrapped image and swap in a zoom, a color shift, or a sliding caption the instant a cursor lands, using
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Zoom in
- 02 Grayscale to color
- 03 Caption slide up
- 04 Overlay fade
- 05 3D tilt
- 06 Brightness sweep
- 07 Clip-path circle zoom
- 08 Two-image cross-fade
- 09 Shake
The nine below move from the plainest single-property change to the most layered one. 01 through 04 touch just one thing — scale, filter, position, or opacity — on hover alone. 05 and 06 add a second dimension to the same css image transition effects: a tilted perspective and a moving gradient band. 07 through 09 are the idea-form entries: a clip-path reveal, a two-image swap, and a startle shake. Watch the grid above loop through all nine hover states on its own: each thumbnail sits inside a fixed, non-scrollable iframe with nothing to click, so it plays that state automatically rather than waiting on a pointer that iframe can never receive. Every "photo" in the demos below is a two-tone CSS gradient landscape with a sun and a hill layered on top, not an image file. Dropping in a real <img> or a photographic background keeps every rule working unchanged. Every one of the nine also mirrors its :hover state onto :focus-visible and :active, and each carries an @media (hover: none) fallback that fires the same end state on a tap. As a result, keyboard focus and touch screens land on the identical effect a mouse would trigger. When the surface behind the photo should move on its own, that's the CSS background animations, and when a figure beside the photo has to climb, the CSS number counter animations take that job.
01Zoom in
How the css image hover effect scale is set
Overflow is clipped on the wrapper while only the inner image scales up on :hover, so the frame stays a fixed size and just the photo appears to zoom closer.
Where it fits
It suits product thumbnails and portfolio tiles where the card edge and shadow need to hold still while the picture does the moving.

<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">Handmade Mug</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 · Zoom in — Measured area 3.017% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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);
}
@media (hover: none) {
.photo:active .photo__pic { transform: scale(1.14); }
}
02Grayscale to color
How it works
A grayscale(1) filter strips the photo's color at rest, then transitions to grayscale(0) on hover so the original hue fades back in over the wrapper's declared duration.
Where it fits
Team photo grids and gallery filter cards use it to mark which tile is currently in focus without any extra markup.

<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">Spring Garden</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 · Grayscale to color — Measured area 12.272% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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);
}
@media (hover: none) {
.photo:active .photo__pic { filter: grayscale(0); }
}
03Caption slide up
How it works
A caption block sits translated fully out of view below the photo and slides to translateY(0) on hover, revealing a title and description that were never visible until then.
Where it fits
Blog card lists and magazine-style grids use it so the caption never crowds the image at rest.

<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">Cafe Brewing Guide</strong>
<span class="photo__cap-desc">5-minute read</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 · Caption slide up — Measured area 6.603% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
.photo__cap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
transform: translateY(100%);
transition: transform $duration $easing;
}
.photo:hover .photo__cap,
.photo:focus-visible .photo__cap,
.photo:active .photo__cap {
transform: translateY(0);
}
04Overlay fade
How it works
A tinted layer positioned over the whole photo sits at opacity 0 until hover pushes it to 1, then fades back out the moment the cursor leaves.
Where it fits
Hero background photos and category cover images use the same trick to drop a readable label over busy artwork only when someone is actually looking at that tile.

<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">View details</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 · Overlay fade — Measured area 13.208% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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 tilt
How it works
Inside a parent carrying perspective, the photo itself tilts on rotateX and rotateY while lifting slightly on the Y axis, so the card reads as a physical object rather than a flat rectangle.
Where it fits
Product showcase cards and certificate or badge images benefit most, since a framed object looks right in three dimensions where a flat photo usually doesn't.

<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">Certified Partner</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 tilt — Measured area 5.519% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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);
}
06Brightness sweep
How it works
A diagonal linear-gradient band sits off-screen at rest and translates across the full width of the photo on hover, mimicking a beam of light passing once over a glossy surface.
Where it fits
Premium product cards and badge-highlight images use the sweep as a one-shot flourish rather than a toggled state.

<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">Limited Edition</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 · Brightness sweep — Measured area 12.756% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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;
}
07Clip-path circle zoom
How it works
clip-path:circle() starts at a 0% radius and grows to 150% on hover, so a circular window expands out from the center until it covers the whole photo sitting underneath a dimmer base layer.
Where it fits
Event teaser images and spotlight-reveal cards use the growing circle to hide most of the picture until someone actually commits to looking.

<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">Autumn Festival · Preview</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 · Clip-path circle zoom — Measured area 12.927% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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%);
}
08Two-image cross-fade
How it works
Two photos are stacked in the exact same spot with position:absolute, and the second transitions from opacity 0 to 1 on hover, replacing the scene shown without either image ever moving.
Where it fits
Before/after product shots and color-option previews rely on the swap landing in place rather than sliding, since a moving comparison is harder to read at a glance.

<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">Color options</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 · Two-image cross-fade — Measured area 14.624% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
.photo__b {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity $duration $easing;
}
.photo:hover .photo__b,
.photo:focus-visible .photo__b,
.photo:active .photo__b {
opacity: 1;
}
09Shake
How it works
A short @keyframes animation nudges the photo left and right by a handful of pixels in quick, uneven steps that settle back to translateX(0) before the animation ends.
Where it fits
It fits product images carrying a limited-sale badge or any banner photo that needs to interrupt a scrolling glance rather than just sit there looking good.

<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">Limited-time deal</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 · Shake — Measured area 3.646% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
animation: shake $duration $easing;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
Where it breaks — the traps
One of these nine borrows a keyword from a different corner of CSS and it doesn't survive the trip. radial-gradient()'s farthest-corner sizing keyword measures from a gradient's center to the box's furthest corner.
Borrowing a gradient keyword
It's tempting to reach for that same word inside item 07's clip-path: circle() once 150% overshoots badly on a very wide or very tall card. But circle()'s shape-radius argument only accepts a length, a percentage, closest-side, or farthest-side — MDN's basic-shape circle() reference lists exactly those four and nothing that reads "corner."
Why the whole rule drops
So clip-path: circle(farthest-corner at 50% 50%) is simply invalid, and a browser drops the whole declaration instead of rounding it to something close. The circle stays wherever the last valid rule left it, which on first paint means the reveal never opens past 0% at all. Bump the percentage number itself for an odd aspect ratio instead of chasing a gradient keyword that clip-path never adopted.
What's in the archive
The full set with every fallback already sorted out sits in the archive, unlocked with pbk89rn6 typed exactly as it reads on this line, no spaces added anywhere. Laying these cards out at deliberately uneven sizes is the grid the bento grid design sections already work out.
Accessibility
Not identical under reduced motion
All nine respect prefers-reduced-motion, but not identically. Items 01 through 04, 06, 07, and 08 simply drop their transition duration to none. The hovered end state still appears instantly — a scaled image, full color, a visible caption — just without the animated approach getting there. Item 05's tilt does the same, snapping straight to its rotated position instead of easing into it.
The one exception
Item 09 is the odd one out: because the shake is built entirely from a keyframes animation rather than a transition, reduced motion removes that animation on hover, focus, and active alike. So under that setting, the photo simply doesn't shake at all rather than shaking instantly. MDN's prefers-reduced-motion page documents the operating-system settings that flip this media query on.
Browse everything else in this line of demos from the CSS category hub, or read what this site does and doesn't sell on the about page.
FAQ
Can I use just one of these as a simple css image hover effect, not the whole set?
Yes — every effect above is scoped to its own .photo block with no shared parent state, so copying a single item's SCSS along with its matching HTML wrapper is enough on its own. Nothing here depends on the other eight being present. Item 01's zoom and item 02's grayscale swap are the simplest single-property versions if one effect is all a page needs. Growing item 05's tilt from one face into several turning around an axis is the step the CSS 3D cube carousel tilt set takes.
Will these work as tailwind css image hover effects, or only plain CSS?
The demos compile from SCSS down to plain CSS classes, so nothing about them is tied to a build tool. A rule like transform: scale(1.14) maps directly onto a Tailwind utility such as hover:scale-110 for most of the nine. The ones built from @keyframes instead of a transition, mainly item 09's shake, still need a small custom animation defined outside Tailwind's default utility set.
Is there a css image hover effects generator inside the zip instead of raw code?
No generator — the zip hands over the nine SCSS and compiled CSS files directly, plus a React version of each, rather than a tool that spits out new variations on demand. Anyone wanting a different scale factor or transition speed edits the SCSS variables at the top of each file, $duration, $easing, and $color, instead of regenerating the rule from a form.









