9 CSS Image Hover Effects — dán 1 dòng là chạy
CSS image hover effects là các hiệu ứng chỉ chạy bằng CSS khi con trỏ dừng trên một ảnh, không cần JavaScript.
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
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Phóng to (zoom in)
- 02 Xám sang màu
- 03 Caption trượt lên
- 04 Overlay mờ dần
- 05 Nghiêng 3D
- 06 Quét sáng
- 07 Phóng to hình tròn clip-path
- 08 Cross-fade hai ảnh
- 09 Rung lắc
Chín hiệu ứng dưới đây xếp từ đơn giản nhất đến phức tạp nhất. Từ 01 đến 04 chỉ đổi một thuộc tính duy nhất — scale, filter, vị trí hoặc opacity — ngay trên :hover. 05 và 06 thêm chiều thứ hai: một góc nghiêng phối cảnh và một dải sáng di chuyển. 07 đến 09 là nhóm dạng ý tưởng: mở khung tròn bằng clip-path, đổi cảnh bằng hai ảnh chồng nhau, và một cú rung giật mình. Ở lưới phía trên, bạn xem cả chín hiệu ứng tự lặp lại bên trong một khung iframe cố định không cuộn được, vì khung đó không nhận được sự kiện con trỏ thật nên mỗi ô tự chuyển sang trạng thái hover của chính nó theo chu kỳ hai giây. Mọi tấm "ảnh" trong demo đều là một dải gradient CSS hai tông màu có hình mặt trời và quả đồi được vẽ chồng lên, không phải file ảnh thật, nên khi bạn thay bằng thẻ img hay ảnh nền thật thì toàn bộ rule vẫn chạy y nguyên. Cả chín hiệu ứng đều áp lại đúng trạng thái :hover cho cả :focus-visible và :active, và mỗi file còn có một fallback @media (hover: none) để khi thiết bị cảm ứng chạm vào cũng cho ra cùng kết quả.
01Phóng to (zoom in)
Bạn thấy gì
Overflow bị cắt trên wrapper trong khi chỉ ảnh bên trong phóng to qua transform ở :hover, nên khung giữ nguyên kích thước còn tấm ảnh trông như đang tiến gần lại.

<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">Cốc thủ công</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 · Phóng to (zoom in) — Diện tích đo được 3.017% · khung hình chuyển động 11/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Hợp với thumbnail sản phẩm và ô portfolio, nơi viền khung và bóng đổ cần đứng yên trong khi chỉ ảnh chuyển động.
.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); }
}
02Xám sang màu
Chuyện gì diễn ra trên màn hình
Filter grayscale(1) rút hết màu ảnh lúc đứng yên, rồi chuyển về grayscale(0) khi hover, nên màu gốc dần hiện lại đúng theo thời lượng transition đã khai báo.

<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">Khu vườn mùa xuân</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 · Xám sang màu — Diện tích đo được 12.272% · khung hình chuyển động 10/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Ảnh hồ sơ giới thiệu team hay card lọc trong gallery dùng cách này để đánh dấu ô nào đang được chú ý mà không cần thêm markup.
.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 trượt lên
Chuyển động thế nào
Thanh caption nằm ẩn hoàn toàn dưới đáy ảnh qua translateY(100%), rồi trượt về translateY(0) khi hover để lộ tiêu đề và mô tả chưa từng hiện ra trước đó.

<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">Hướng dẫn pha cà phê</strong>
<span class="photo__cap-desc">Đọc trong 5 phút</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 trượt lên — Diện tích đo được 6.603% · khung hình chuyển động 13/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Danh sách card blog hay lưới kiểu tạp chí dùng cách này để caption không chiếm chỗ ảnh lúc bình thường.
.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 mờ dần
Bạn thấy gì
Một lớp màu phủ kín ảnh đứng ở opacity 0 cho đến khi hover đẩy lên 1, rồi mờ dần trở lại ngay khi con trỏ rời đi.

<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">Xem chi tiết</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 mờ dần — Diện tích đo được 13.208% · khung hình chuyển động 11/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Ảnh nền hero và ảnh cover danh mục dùng đúng mẹo này để đặt một nhãn dễ đọc lên trên ảnh rối mắt, chỉ khi có người thật sự đang nhìn vào ô đó.
.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;
}
05Nghiêng 3D
Chuyện gì diễn ra trên màn hình
Bên trong parent mang thuộc tính perspective, chính tấm ảnh nghiêng theo rotateX và rotateY trong khi nhấc nhẹ theo trục Y, khiến card đọc như một vật thể thật chứ không phải hình chữ nhật phẳng.

<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">Đối tác được chứng nhận</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 · Nghiêng 3D — Diện tích đo được 5.519% · khung hình chuyển động 18/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Card showcase sản phẩm hay ảnh chứng chỉ, huy hiệu là nơi hưởng lợi nhiều nhất, vì một khung vật lý trông đúng khi có chiều sâu ba chiều.
.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);
}
06Quét sáng
Chuyển động thế nào
Một dải linear-gradient nghiêng chéo nằm ngoài khung lúc đứng yên rồi dịch chuyển hết chiều rộng ảnh khi hover, mô phỏng một tia sáng quét qua bề mặt bóng đúng một lượt.

<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">Phiên bản giới hạn</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 · Quét sáng — Diện tích đo được 12.756% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Card sản phẩm cao cấp và ảnh nhấn mạnh huy hiệu dùng cú quét này như một điểm nhấn một lần chứ không phải trạng thái bật tắt.
.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;
}
07Phóng to hình tròn clip-path
Bạn thấy gì
clip-path:circle() bắt đầu ở bán kính 0% và lớn dần tới 150% khi hover, để một ô cửa tròn mở rộng từ tâm ra cho tới khi phủ hết tấm ảnh nằm dưới một lớp nền tối hơn.

<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">Lễ hội mùa thu · Xem trước</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 · Phóng to hình tròn clip-path — Diện tích đo được 12.927% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Ảnh teaser sự kiện và card hiệu ứng spotlight dùng vòng tròn giãn rồi co này để giấu phần lớn bức ảnh cho tới khi có người thật sự dừng lại nhìn.
.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%);
}
08Cross-fade hai ảnh
Chuyện gì diễn ra trên màn hình
Hai tấm ảnh xếp chồng đúng cùng một vị trí bằng position:absolute, và tấm thứ hai chuyển từ opacity 0 sang 1 khi hover, thay hẳn cảnh đang hiện mà không tấm nào phải di chuyển.

<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">Tùy chọn màu</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 · Cross-fade hai ảnh — Diện tích đo được 14.624% · khung hình chuyển động 11/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Ảnh sản phẩm before/after và bản xem trước tùy chọn màu cần cú đổi này giữ nguyên vị trí thay vì trượt, vì một phép so sánh có chuyển động khó nhìn hơn khi chỉ liếc mắt qua.
.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;
}
09Rung lắc
Chuyển động thế nào
Một đoạn @keyframes ngắn đẩy ảnh sang trái rồi sang phải vài pixel theo từng bước không đều rồi dừng lại ở translateX(0) trước khi animation kết thúc.

<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">Ưu đãi có hạn</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 · Rung lắc — Diện tích đo được 3.646% · khung hình chuyển động 10/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Nó hợp với ảnh sản phẩm gắn badge sale giới hạn hay bất kỳ ảnh banner nào cần cắt ngang ánh mắt đang lướt qua thay vì chỉ đứng yên cho đẹp.
.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); }
}
Chỗ dễ hỏng ở đâu? — cái bẫy
Từ khoá farthest-corner dùng nhầm chỗ
Trong chín hiệu ứng này có một chỗ mượn nhầm từ vựng của một góc khác trong CSS, khiến nó không hoạt động được. Từ khoá farthest-corner đo khoảng cách tới góc xa nhất của khung, vốn thuộc cú pháp kích cỡ của radial-gradient(), nên rất dễ mang nguyên nó vào bên trong clip-path:circle() ở mục 07 khi bán kính 150% tràn hơi quá trên một card rất dẹt hoặc rất cao. Nhưng đối số bán kính của circle() chỉ nhận length, percentage, closest-side hoặc farthest-side — đúng bốn giá trị đó theo trang tham chiếu circle() của MDN, không giá trị nào chứa từ "corner" cả — nên khai báo clip-path: circle(farthest-corner at 50% 50%) đơn giản là không hợp lệ, và trình duyệt bỏ hẳn cả dòng đó thay vì làm tròn về giá trị gần đúng nhất. Vòng tròn đứng nguyên ở chỗ rule hợp lệ gần nhất để lại, mà lúc mới tải trang thì đó thường là 0%, tức là hiệu ứng không bao giờ mở ra được. Muốn khớp một tỉ lệ khung lệch thì hãy tăng thẳng con số phần trăm thay vì mượn từ khoá của gradient mà clip-path chưa bao giờ nhận, còn bộ đầy đủ với mọi fallback đã canh sẵn nằm trong file nén, mở bằng mật khẩu pbk89rn6 gõ đúng như nó hiện trên dòng này, không thêm khoảng trắng nào.
Khả năng tiếp cận
Thời lượng transition khi giảm chuyển động
Cả chín hiệu ứng đều tôn trọng prefers-reduced-motion, nhưng không theo cùng một cách. Từ mục 01 đến 04, 06, 07 và 08 chỉ đưa thời lượng transition về none, nên trạng thái đã hover vẫn hiện ngay lập tức — ảnh đã phóng to, màu đã trở lại, caption đã lộ ra — chỉ là không còn quãng chuyển động dẫn tới đó. Mục 05 cũng làm y vậy với góc nghiêng, bật thẳng sang vị trí đã xoay thay vì trượt dần vào đó. Mục 09 là trường hợp khác hẳn: vì cú rung dựng hoàn toàn bằng animation dạng keyframes chứ không phải transition, reduced motion bỏ luôn animation đó ở cả hover, focus lẫn active, nên dưới thiết lập này tấm ảnh hoàn toàn không rung chứ không phải rung tức thì rồi dừng. Trang prefers-reduced-motion của MDN ghi rõ cách hệ điều hành bật media query này và mức hỗ trợ trình duyệt đi kèm.
Xem thêm các hiệu ứng CSS khác trong chuyên mục CSS, và trang giới thiệu nói rõ site này thực chất bán gì.
FAQ
Chỉ cần một hiệu ứng thôi, không lấy hết chín cái có được không?
Được — mỗi hiệu ứng đóng gói gọn trong khối .photo riêng, không chia sẻ trạng thái với mục khác, nên copy đúng phần SCSS của một mục cùng khung HTML đi kèm là đủ để chạy độc lập. Mục 01 (zoom) và mục 02 (grayscale) là hai bản đơn giản nhất nếu trang chỉ cần một chuyển động duy nhất.
Dùng chung với Tailwind hay một dự án React có ổn không?
Ổn. Demo biên dịch từ SCSS ra CSS thuần nên không phụ thuộc công cụ build nào cả, và một rule như transform: scale(1.14) map thẳng sang utility kiểu hover:scale-110 của Tailwind cho phần lớn chín mục. Riêng mục 09 dựng bằng @keyframes thay vì transition nên vẫn cần khai animation tùy chỉnh ngoài bộ utility mặc định, còn thư mục react/ trong zip có sẵn một component riêng cho mỗi hiệu ứng.
Zip có kèm css image hover effects generator để tự chỉnh không?
Không có generator — zip đưa thẳng chín file SCSS, bản CSS đã biên dịch và bản React tương ứng, chứ không phải một công cụ sinh biến thể theo yêu cầu. Muốn đổi mức scale hay tốc độ transition thì sửa trực tiếp các biến $duration, $easing, $color khai ở đầu mỗi file, thay vì chờ một form sinh lại rule.









