CSS 画像 ホバー エフェクト 9選 — 一行でコピペOK
css 画像 ホバー エフェクトは、写真の上にカーソルを乗せた瞬間だけ拡大・変色・出現が起きる、CSSのtransformとtransitionだけで組める小さな仕掛けです。この記事では9種類を選び、それぞれ6〜25行のコードとして実装しました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
並び順は効果の派手さではなく、写真に何を仕掛けるかという観点で揃えています。単一のCSSプロパティだけを動かす2つ(01・02)から始め、隠れた要素を見せる2つ(03・04)、奥行きと帯を作る2つ(05・06)、切り抜きと css ホバー 画像 切り替え の2つ(07・08)を経て、最後は視線を引く揺れ(09)で締めくくりました。デモの「写真」はすべてCSSのlinear-gradientで描いた色面で、画像ファイルは一枚も使っていません。9つとも:hoverと同じ見た目を:focus-visibleと:activeにも与え、タッチ操作向けには@media (hover: none)でホバーの代わりを用意しています。 写真の背後の面そのものが動くべき場面はCSS 背景 アニメーション 9選で、写真の隣の数字が伸びる役はCSS 数字 カウントアップ アニメーション9選が担います。
01ズームイン
css 画像 ホバー 拡大 の付け方
写真を包む要素にoverflow:hiddenをかけ、内側の.photo__picだけをtransform:scaleで拡大することで、枠は動かさずに写真だけが迫って見えます。
どこで使うか
商品カードのサムネイルやポートフォリオのグリッドに向いています。

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<figcaption class="photo__cap">ハンドメイドマグ</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 확대가 끝나는 시간
$easing: $ease-spring; // 탄력 있게 붙는 느낌
$color: $subject-cream; // 사진 프레임 색
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden; // 확대된 사진이 프레임 밖으로 안 나가게
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
transform: scale(1);
transition: transform $duration $easing;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $stage-ink;
background: $color;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
transform: scale(1.14);
}
// 데모 · 문서용: 가운데 2초 주기로 자동 반복
.photo.is-demo .photo__pic {
animation: zoom-loop $dur-loop $easing infinite;
}
@keyframes zoom-loop {
0%, 20% { transform: scale(1); }
50%, 75% { transform: scale(1.14); }
100% { transform: scale(1); }
}
// 터치 기기: hover 가 없으니 탭(:active)이 곧 트리거
@media (hover: none) {
.photo:active .photo__pic { transform: scale(1.14); }
}
@media (prefers-reduced-motion: reduce) {
.photo__pic { transition: none; }
.photo.is-demo .photo__pic { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #fff7e6;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
transform: scale(1);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
background: #fff7e6;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
transform: scale(1.14);
}
.photo.is-demo .photo__pic {
animation: zoom-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes zoom-loop {
0%, 20% {
transform: scale(1);
}
50%, 75% {
transform: scale(1.14);
}
100% {
transform: scale(1);
}
}
@media (hover: none) {
.photo:active .photo__pic {
transform: scale(1.14);
}
}
@media (prefers-reduced-motion: reduce) {
.photo__pic {
transition: none;
}
.photo.is-demo .photo__pic {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./ZoomInPhoto.module.scss";
export type ZoomInPhotoProps = {
caption: string;
/** 확대가 끝나는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ZoomInPhoto({
caption,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ZoomInPhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}01 · ズームイン — 計測 累積面積 3.017% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.photo {
overflow: hidden; // 拡大した写真が枠の外に出ないように
}
.photo__pic {
transform: scale(1);
transition: transform .6s $ease-spring;
}
.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); }
}
02モノクロ→カラー
どう動くか
filter:grayscale(1)で彩度を抜いた写真が、ホバーで0へ遷移して元の色を取り戻します。
どこで使うか
チーム紹介のプロフィール写真やギャラリーのフィルターカードに使うと効果的です。

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<figcaption class="photo__cap">春の庭</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 채도가 돌아오는 시간
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$color: $subject-ink; // 사진 프레임 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
filter: grayscale(1);
transition: filter $duration $easing;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-orange;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $subject-cream;
background: $color;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
filter: grayscale(0);
}
.photo.is-demo .photo__pic {
animation: color-loop $dur-loop $easing infinite;
}
@keyframes color-loop {
0%, 20% { filter: grayscale(1); }
50%, 75% { filter: grayscale(0); }
100% { filter: grayscale(1); }
}
@media (hover: none) {
.photo:active .photo__pic { filter: grayscale(0); }
}
@media (prefers-reduced-motion: reduce) {
.photo__pic { transition: none; }
.photo.is-demo .photo__pic { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #17141a;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
filter: grayscale(1);
transition: filter 300ms ease-out;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ff4d1f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
background: #17141a;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
filter: grayscale(0);
}
.photo.is-demo .photo__pic {
animation: color-loop 2s ease-out infinite;
}
@keyframes color-loop {
0%, 20% {
filter: grayscale(1);
}
50%, 75% {
filter: grayscale(0);
}
100% {
filter: grayscale(1);
}
}
@media (hover: none) {
.photo:active .photo__pic {
filter: grayscale(0);
}
}
@media (prefers-reduced-motion: reduce) {
.photo__pic {
transition: none;
}
.photo.is-demo .photo__pic {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./GrayscaleToColorPhoto.module.scss";
export type GrayscaleToColorPhotoProps = {
caption: string;
/** 채도가 돌아오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GrayscaleToColorPhoto({
caption,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: GrayscaleToColorPhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}02 · モノクロ→カラー — 計測 累積面積 12.272% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.photo__pic {
filter: grayscale(1);
transition: filter .3s ease-out;
}
.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); }
}
03キャプションスライド
どう動くか
写真の下に隠しておいたキャプションが、ホバーでtranslateY(0)まで持ち上がり、タイトルと説明を見せます。
どこで使うか
ブログのカード一覧やマガジン風グリッドに向いています。

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<figcaption class="photo__cap">
<strong class="photo__cap-title">カフェ抽出ガイド</strong>
<span class="photo__cap-desc">5分で読める</span>
</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 캡션이 올라오는 시간
$easing: $ease-out;
$color: $subject-cream; // 캡션 배경 색
@include stage($stage-orange);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden; // 캡션이 밖으로 안 나가게
box-shadow: $shadow-raised;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-blue 0%, $subject-ink 100%);
}
.photo__sun {
position: absolute;
top: 14%;
right: 16%;
width: 18%;
aspect-ratio: 1;
border-radius: 50%;
background: $subject-cream;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 46%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $stage-orange;
opacity: .55;
}
.photo__cap {
word-break: keep-all;
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: grid;
gap: 2px;
padding: 12px 14px;
background: $color;
transform: translateY(100%); // 아래로 숨겨 둔다
transition: transform $duration $easing;
}
.photo__cap-title {
word-break: keep-all;
font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $stage-ink;
}
.photo__cap-desc {
word-break: keep-all;
font-weight: 500; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.3; font-family: inherit;
color: $stage-ink;
}
.photo:hover .photo__cap,
.photo:focus-visible .photo__cap,
.photo:active .photo__cap {
word-break: keep-all;
transform: translateY(0);
}
.photo.is-demo .photo__cap {
word-break: keep-all;
animation: slide-loop $dur-loop $easing infinite;
}
@keyframes slide-loop {
0%, 20% { transform: translateY(100%); }
50%, 75% { transform: translateY(0); }
100% { transform: translateY(100%); }
}
// 터치: 캡션은 콘텐츠라 hover 가 없는 기기에선 기본으로 보이게 한다
@media (hover: none) {
.photo__cap {
word-break: keep-all; transform: translateY(0); }
.photo.is-demo .photo__cap {
word-break: keep-all; animation: none; }
}
@media (prefers-reduced-motion: reduce) {
.photo__cap {
word-break: keep-all; transition: none; }
.photo.is-demo .photo__cap {
word-break: keep-all; animation: none; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.photo__sun {
position: absolute;
top: 14%;
right: 16%;
width: 18%;
aspect-ratio: 1;
border-radius: 50%;
background: #fff7e6;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 46%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #ff4d1f;
opacity: 0.55;
}
.photo__cap {
word-break: keep-all;
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: grid;
gap: 2px;
padding: 12px 14px;
background: #fff7e6;
transform: translateY(100%);
transition: transform 300ms ease-out;
}
.photo__cap-title {
word-break: keep-all;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.photo__cap-desc {
word-break: keep-all;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.photo:hover .photo__cap,
.photo:focus-visible .photo__cap,
.photo:active .photo__cap {
word-break: keep-all;
transform: translateY(0);
}
.photo.is-demo .photo__cap {
word-break: keep-all;
animation: slide-loop 2s ease-out infinite;
}
@keyframes slide-loop {
0%, 20% {
transform: translateY(100%);
}
50%, 75% {
transform: translateY(0);
}
100% {
transform: translateY(100%);
}
}
@media (hover: none) {
.photo__cap {
word-break: keep-all;
transform: translateY(0);
}
.photo.is-demo .photo__cap {
word-break: keep-all;
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
.photo__cap {
word-break: keep-all;
transition: none;
}
.photo.is-demo .photo__cap {
word-break: keep-all;
animation: none;
transform: translateY(0);
}
}import type { CSSProperties } from "react";
import styles from "./CaptionSlidePhoto.module.scss";
export type CaptionSlidePhotoProps = {
title: string;
desc: string;
/** 캡션이 올라오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 캡션 배경 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CaptionSlidePhoto({
title,
desc,
duration = 300,
easing = "ease-out",
color = "#fff7e6",
}: CaptionSlidePhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<figcaption className={styles.cap}>
<strong className={styles.title}>{title}</strong>
<span className={styles.desc}>{desc}</span>
</figcaption>
</figure>
);
}03 · キャプションスライド — 計測 累積面積 6.603% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.photo__cap {
position: absolute;
left: 0; right: 0; bottom: 0;
transform: translateY(100%); // 下に隠しておく
transition: transform .3s ease-out;
}
.photo:hover .photo__cap,
.photo:focus-visible .photo__cap,
.photo:active .photo__cap {
transform: translateY(0);
}
// タッチ機器: キャプションは内容なのではじめから見せておく
@media (hover: none) {
.photo__cap { transform: translateY(0); }
}
04オーバーレイフェード
どう動くか
写真の上に重ねた半透明レイヤーがopacity 0から1へ遷移して覆い、カーソルを外すと再び透明に戻ります。
どこで使うか
ヒーローの背景写真やカテゴリのカバー画像に使われます。

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<span class="photo__overlay">
<span class="photo__overlay-text">詳しく見る</span>
</span>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 오버레이가 덮이는 시간
$easing: $ease-out;
$color: $subject-blue; // 오버레이 색
@include stage($stage-paper);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $subject-ink;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba($color, .82);
opacity: 0; // 평소엔 투명
transition: opacity $duration $easing;
}
.photo__overlay-text {
word-break: keep-all;
padding: 8px 18px;
border-radius: $r-pill;
font-weight: 700; font-size: clamp(11px, 3vw, 13px); line-height: 1; font-family: inherit;
color: $subject-cream;
border: 1px solid $subject-cream;
}
.photo:hover .photo__overlay,
.photo:focus-visible .photo__overlay,
.photo:active .photo__overlay {
opacity: 1;
}
.photo.is-demo .photo__overlay {
animation: fade-loop $dur-loop $easing infinite;
}
@keyframes fade-loop {
0%, 20% { opacity: 0; }
50%, 75% { opacity: 1; }
100% { opacity: 0; }
}
@media (hover: none) {
.photo:active .photo__overlay { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.photo__overlay { transition: none; }
.photo.is-demo .photo__overlay { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #17141a;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(47, 109, 246, 0.82);
opacity: 0;
transition: opacity 300ms ease-out;
}
.photo__overlay-text {
word-break: keep-all;
padding: 8px 18px;
border-radius: 999px;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
border: 1px solid #fff7e6;
}
.photo:hover .photo__overlay,
.photo:focus-visible .photo__overlay,
.photo:active .photo__overlay {
opacity: 1;
}
.photo.is-demo .photo__overlay {
animation: fade-loop 2s ease-out infinite;
}
@keyframes fade-loop {
0%, 20% {
opacity: 0;
}
50%, 75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (hover: none) {
.photo:active .photo__overlay {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.photo__overlay {
transition: none;
}
.photo.is-demo .photo__overlay {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./OverlayFadePhoto.module.scss";
export type OverlayFadePhotoProps = {
overlayText: string;
/** 오버레이가 덮이는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 오버레이 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function OverlayFadePhoto({
overlayText,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: OverlayFadePhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<span className={styles.overlay}>
<span className={styles.overlayText}>{overlayText}</span>
</span>
</figure>
);
}04 · オーバーレイフェード — 計測 累積面積 13.208% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.photo__overlay {
position: absolute;
inset: 0;
opacity: 0; // 平常時は透明
transition: opacity .3s ease-out;
}
.photo:hover .photo__overlay,
.photo:focus-visible .photo__overlay,
.photo:active .photo__overlay {
opacity: 1;
}
@media (hover: none) {
.photo:active .photo__overlay { opacity: 1; }
}
053Dチルト
どう動くか
perspectiveを与えた親要素の中で、写真がrotateXとrotateYでわずかに傾き、カードが持ち上がったように見えます。
どこで使うか
商品ショーケースのカードや認定バッジの画像に向いています。

<main class="stage">
<div class="frame">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<figcaption class="photo__cap">優秀パートナー認定</figcaption>
</figure>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 기울기가 붙는 시간
$easing: $ease-spring;
$color: $subject-cream; // 카드 프레임 색
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.frame {
perspective: 700px; // 자식 카드가 이 값 기준으로 기운다
}
.photo {
margin: 0;
width: min(150px, 48vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
transform-style: preserve-3d;
transform: rotateX(0) rotateY(0);
transition: transform $duration $easing;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $stage-ink;
background: $color;
}
.photo:hover,
.photo:focus-visible,
.photo:active {
transform: rotateX(-9deg) rotateY(11deg) translateY(-4px);
}
.photo.is-demo {
animation: tilt-loop $dur-loop $easing infinite;
}
@keyframes tilt-loop {
0%, 15% { transform: rotateX(0) rotateY(0); }
40% { transform: rotateX(-9deg) rotateY(11deg) translateY(-4px); }
65% { transform: rotateX(6deg) rotateY(-9deg) translateY(-2px); }
100% { transform: rotateX(0) rotateY(0); }
}
@media (hover: none) {
.photo:active { transform: rotateX(-9deg) rotateY(11deg) translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.photo { transition: none; }
.photo.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.frame {
perspective: 700px;
}
.photo {
margin: 0;
width: min(150px, 48vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #fff7e6;
outline: none;
cursor: pointer;
transform-style: preserve-3d;
transform: rotateX(0) rotateY(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
background: #fff7e6;
}
.photo:hover,
.photo:focus-visible,
.photo:active {
transform: rotateX(-9deg) rotateY(11deg) translateY(-4px);
}
.photo.is-demo {
animation: tilt-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tilt-loop {
0%, 15% {
transform: rotateX(0) rotateY(0);
}
40% {
transform: rotateX(-9deg) rotateY(11deg) translateY(-4px);
}
65% {
transform: rotateX(6deg) rotateY(-9deg) translateY(-2px);
}
100% {
transform: rotateX(0) rotateY(0);
}
}
@media (hover: none) {
.photo:active {
transform: rotateX(-9deg) rotateY(11deg) translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.photo {
transition: none;
}
.photo.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./Tilt3DPhoto.module.scss";
export type Tilt3DPhotoProps = {
caption: string;
/** 기울기가 붙는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 카드 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function Tilt3DPhoto({
caption,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: Tilt3DPhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.frame}>
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
</div>
);
}05 · 3Dチルト — 計測 累積面積 5.519% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.frame {
perspective: 700px; // 子のカードはこの値を基準に傾く
}
.photo {
transform-style: preserve-3d;
transform: rotateX(0) rotateY(0);
transition: transform .3s $ease-spring;
}
.photo:hover,
.photo:focus-visible,
.photo:active {
transform: rotateX(-9deg) rotateY(11deg) translateY(-4px);
}
06明るさスイープ
どう動くか
斜めに傾いた明るい帯をlinear-gradientで描き、ホバーのたびにtranslateXで写真の表面を一度だけ通過させます。
どこで使うか
プレミアムな商品カードやバッジを強調する画像に向いています。

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
<span class="photo__sweep"></span>
</span>
<figcaption class="photo__cap">限定エディション</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 띠가 한 번 지나가는 시간
$easing: $ease-out;
$color: $subject-ink; // 사진 프레임 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
background: linear-gradient(155deg, $subject-ink 0%, $subject-blue 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $subject-cream;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $stage-orange;
opacity: .6;
}
.photo__sweep {
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 30%, rgba($subject-cream, .65) 50%, transparent 70%);
transform: translateX(-120%);
pointer-events: none;
}
.photo:hover .photo__sweep,
.photo:focus-visible .photo__sweep,
.photo:active .photo__sweep {
transform: translateX(120%);
transition: transform $duration $easing;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $subject-cream;
background: $color;
}
.photo.is-demo .photo__sweep {
animation: sweep-loop $dur-loop $easing infinite;
}
@keyframes sweep-loop {
0%, 15% { transform: translateX(-120%); }
65% { transform: translateX(120%); }
100% { transform: translateX(120%); }
}
@media (hover: none) {
.photo:active .photo__sweep { transform: translateX(120%); transition: transform $duration $easing; }
}
@media (prefers-reduced-motion: reduce) {
.photo__sweep { transition: none; }
.photo.is-demo .photo__sweep { animation: none; transform: translateX(-120%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #17141a;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
background: linear-gradient(155deg, #17141a 0%, #2f6df6 100%);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #fff7e6;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #ff4d1f;
opacity: 0.6;
}
.photo__sweep {
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 30%, rgba(255, 247, 230, 0.65) 50%, transparent 70%);
transform: translateX(-120%);
pointer-events: none;
}
.photo:hover .photo__sweep,
.photo:focus-visible .photo__sweep,
.photo:active .photo__sweep {
transform: translateX(120%);
transition: transform 600ms ease-out;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
background: #17141a;
}
.photo.is-demo .photo__sweep {
animation: sweep-loop 2s ease-out infinite;
}
@keyframes sweep-loop {
0%, 15% {
transform: translateX(-120%);
}
65% {
transform: translateX(120%);
}
100% {
transform: translateX(120%);
}
}
@media (hover: none) {
.photo:active .photo__sweep {
transform: translateX(120%);
transition: transform 600ms ease-out;
}
}
@media (prefers-reduced-motion: reduce) {
.photo__sweep {
transition: none;
}
.photo.is-demo .photo__sweep {
animation: none;
transform: translateX(-120%);
}
}import type { CSSProperties } from "react";
import styles from "./BrightnessSweepPhoto.module.scss";
export type BrightnessSweepPhotoProps = {
caption: string;
/** 띠가 한 번 지나가는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BrightnessSweepPhoto({
caption,
duration = 600,
easing = "ease-out",
color = "#17141a",
}: BrightnessSweepPhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
<span className={styles.sweep} />
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}06 · 明るさスイープ — 計測 累積面積 12.756% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.photo__sweep {
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 30%, rgba($subject-cream, .65) 50%, transparent 70%);
transform: translateX(-120%);
}
.photo:hover .photo__sweep,
.photo:focus-visible .photo__sweep,
.photo:active .photo__sweep {
transform: translateX(120%);
transition: transform .6s ease-out;
}
07クリップパス円形ズーム
どう動くか
clip-path:circle()の半径をホバーで0%から150%まで広げ、円形の窓が拡大して写真全体を見せます。
どこで使うか
イベントのティザー画像やスポットライト演出のカードに向いています。

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__base" aria-hidden="true"></span>
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<figcaption class="photo__cap">秋のフェスティバル · プレビュー</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 원형 창이 다 커지는 시간
$easing: $ease-spring;
$color: $subject-cream; // 사진 프레임 색
@include stage($stage-orange);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
}
.photo__base,
.photo__pic {
position: absolute;
inset: 0;
}
.photo__base {
background: linear-gradient(155deg, rgba($subject-ink, .9) 0%, rgba($subject-blue, .55) 100%);
}
.photo__pic {
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
clip-path: circle(0% at 50% 50%); // 반지름 0에서 시작
transition: clip-path $duration $easing;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__cap {
word-break: keep-all;
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(11px, 3vw, 13px); line-height: 1.3; font-family: inherit;
color: $subject-cream;
text-shadow: 0 1px 3px rgba($stage-ink, .6);
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
clip-path: circle(150% at 50% 50%);
}
.photo.is-demo .photo__pic {
animation: circle-loop $dur-loop $easing infinite;
}
@keyframes circle-loop {
0%, 15% { clip-path: circle(0% at 50% 50%); }
50%, 75% { clip-path: circle(150% at 50% 50%); }
100% { clip-path: circle(0% at 50% 50%); }
}
@media (hover: none) {
.photo__pic { clip-path: circle(150% at 50% 50%); }
.photo.is-demo .photo__pic { animation: none; }
}
@media (prefers-reduced-motion: reduce) {
.photo__pic { transition: none; }
.photo.is-demo .photo__pic { animation: none; clip-path: circle(150% at 50% 50%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
position: relative;
width: min(160px, 50vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #fff7e6;
outline: none;
cursor: pointer;
}
.photo__base,
.photo__pic {
position: absolute;
inset: 0;
}
.photo__base {
background: linear-gradient(155deg, rgba(23, 20, 26, 0.9) 0%, rgba(47, 109, 246, 0.55) 100%);
}
.photo__pic {
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
clip-path: circle(0% at 50% 50%);
transition: clip-path 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__cap {
word-break: keep-all;
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(11px, 3vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
text-shadow: 0 1px 3px rgba(23, 20, 26, 0.6);
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
clip-path: circle(150% at 50% 50%);
}
.photo.is-demo .photo__pic {
animation: circle-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes circle-loop {
0%, 15% {
clip-path: circle(0% at 50% 50%);
}
50%, 75% {
clip-path: circle(150% at 50% 50%);
}
100% {
clip-path: circle(0% at 50% 50%);
}
}
@media (hover: none) {
.photo__pic {
clip-path: circle(150% at 50% 50%);
}
.photo.is-demo .photo__pic {
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
.photo__pic {
transition: none;
}
.photo.is-demo .photo__pic {
animation: none;
clip-path: circle(150% at 50% 50%);
}
}import type { CSSProperties } from "react";
import styles from "./ClipCircleZoomPhoto.module.scss";
export type ClipCircleZoomPhotoProps = {
caption: string;
/** 원형 창이 다 커지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ClipCircleZoomPhoto({
caption,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ClipCircleZoomPhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.base} aria-hidden="true" />
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}07 · クリップパス円形ズーム — 計測 累積面積 12.927% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.photo__pic {
clip-path: circle(0% at 50% 50%); // 半径0から始める
transition: clip-path .6s $ease-spring;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
clip-path: circle(150% at 50% 50%);
}
@media (hover: none) {
.photo__pic { clip-path: circle(150% at 50% 50%); }
}
082枚クロスフェード
どう動くか
同じ位置に重ねておいた2枚目の写真がopacityでホバー時に浮かび上がり、別の場面に切り替わります。
どこで使うか
ビフォーアフターの商品写真やカラーオプションのプレビューに使われます。

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__frame" aria-hidden="true">
<span class="photo__a">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
</span>
<span class="photo__b">
<span class="photo__sun photo__sun--b"></span>
<span class="photo__hill photo__hill--b"></span>
</span>
</span>
<figcaption class="photo__cap">カラーオプション</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 두 번째 사진이 번지는 시간
$easing: $ease-out;
$color: $subject-ink; // 사진 프레임 색
@include stage($stage-paper);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
outline: none;
cursor: pointer;
}
.photo__frame {
display: block;
position: relative;
aspect-ratio: 4 / 3;
}
.photo__a,
.photo__b {
position: absolute;
inset: 0;
}
.photo__a {
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
}
.photo__b {
background: linear-gradient(155deg, $subject-blue 0%, $subject-ink 100%);
opacity: 0; // 평소엔 첫 사진만 보인다
transition: opacity $duration $easing;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__sun--b { background: $subject-cream; }
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__hill--b { background: $stage-orange; }
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $subject-cream;
background: $color;
}
.photo:hover .photo__b,
.photo:focus-visible .photo__b,
.photo:active .photo__b {
opacity: 1;
}
.photo.is-demo .photo__b {
animation: cross-loop $dur-loop $easing infinite;
}
@keyframes cross-loop {
0%, 20% { opacity: 0; }
50%, 75% { opacity: 1; }
100% { opacity: 0; }
}
@media (hover: none) {
.photo:active .photo__b { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.photo__b { transition: none; }
.photo.is-demo .photo__b { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #17141a;
outline: none;
cursor: pointer;
}
.photo__frame {
display: block;
position: relative;
aspect-ratio: 4/3;
}
.photo__a,
.photo__b {
position: absolute;
inset: 0;
}
.photo__a {
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
}
.photo__b {
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
opacity: 0;
transition: opacity 300ms ease-out;
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__sun--b {
background: #fff7e6;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__hill--b {
background: #ff4d1f;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
background: #17141a;
}
.photo:hover .photo__b,
.photo:focus-visible .photo__b,
.photo:active .photo__b {
opacity: 1;
}
.photo.is-demo .photo__b {
animation: cross-loop 2s ease-out infinite;
}
@keyframes cross-loop {
0%, 20% {
opacity: 0;
}
50%, 75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (hover: none) {
.photo:active .photo__b {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.photo__b {
transition: none;
}
.photo.is-demo .photo__b {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./CrossFadePhoto.module.scss";
export type CrossFadePhotoProps = {
caption: string;
/** 두 번째 사진이 번지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CrossFadePhoto({
caption,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: CrossFadePhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.frame} aria-hidden="true">
<span className={styles.a}>
<span className={styles.sun} />
<span className={styles.hill} />
</span>
<span className={styles.b}>
<span className={`${styles.sun} ${styles.sunB}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</span>
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}08 · 2枚クロスフェード — 計測 累積面積 14.624% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.photo__a,
.photo__b {
position: absolute;
inset: 0;
}
.photo__b {
opacity: 0; // 平常時は1枚目だけが見える
transition: opacity .3s ease-out;
}
.photo:hover .photo__b,
.photo:focus-visible .photo__b,
.photo:active .photo__b {
opacity: 1;
}
09揺れ
どう動くか
短いkeyframesが写真を左右に数ピクセルすばやく振動させ、ホバーのたびに視線を引きます。
どこで使うか
限定セールのバッジが付いた商品画像や、注意を引きたいバナー写真に向いています。

<main class="stage">
<figure class="photo is-demo" tabindex="0">
<span class="photo__pic" aria-hidden="true">
<span class="photo__sun"></span>
<span class="photo__hill"></span>
<span class="photo__badge">SALE</span>
</span>
<figcaption class="photo__cap">期間限定セール</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 흔들림이 끝나는 시간
$easing: $ease-pop;
$color: $subject-cream; // 사진 프레임 색
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); } // 320px 대응: 데모 카드가 작아 여백도 줄인다
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: $r-card;
overflow: visible; // 흔들릴 때 프레임 밖으로 살짝 나가도 된다
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
border: 3px solid $color;
background: linear-gradient(155deg, $subject-cream 0%, $subject-blue 100%);
transform: translateX(0);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $subject-ink;
opacity: .85;
}
.photo__badge {
position: absolute;
top: 10px;
left: 10px;
padding: 4px 10px;
border-radius: $r-pill;
font-weight: 800; font-size: clamp(10px, 2.8vw, 12px); line-height: 1; font-family: inherit;
letter-spacing: .04em;
color: $subject-cream;
background: $error;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px 0;
font-weight: 600; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit;
color: $color;
background: transparent;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
animation: shake $duration $easing;
}
.photo.is-demo .photo__pic {
animation: shake-loop $dur-loop linear infinite;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
@keyframes shake-loop {
0%, 55%, 100% { transform: translateX(0); }
60% { transform: translateX(-7px); }
65% { transform: translateX(6px); }
70% { transform: translateX(-6px); }
75% { transform: translateX(5px); }
80% { transform: translateX(-4px); }
85% { transform: translateX(3px); }
90% { transform: translateX(-2px); }
95% { transform: translateX(1px); }
}
@media (hover: none) {
.photo:active .photo__pic { animation: shake $duration $easing; }
}
@media (prefers-reduced-motion: reduce) {
.photo.is-demo .photo__pic { animation: none; }
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.photo {
margin: 0;
width: min(160px, 50vw);
border-radius: 16px;
overflow: visible;
outline: none;
cursor: pointer;
}
.photo__pic {
display: block;
position: relative;
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 3px solid #fff7e6;
background: linear-gradient(155deg, #fff7e6 0%, #2f6df6 100%);
transform: translateX(0);
}
.photo__sun {
position: absolute;
top: 16%;
right: 18%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.photo__hill {
position: absolute;
left: -8%;
right: -8%;
bottom: -10%;
height: 48%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.photo__badge {
position: absolute;
top: 10px;
left: 10px;
padding: 4px 10px;
border-radius: 999px;
font-weight: 800;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
color: #fff7e6;
background: #c2361a;
}
.photo__cap {
word-break: keep-all;
margin: 0;
padding: 10px 14px 0;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
background: transparent;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
animation: shake 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.photo.is-demo .photo__pic {
animation: shake-loop 2s linear infinite;
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(5px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(3px);
}
}
@keyframes shake-loop {
0%, 55%, 100% {
transform: translateX(0);
}
60% {
transform: translateX(-7px);
}
65% {
transform: translateX(6px);
}
70% {
transform: translateX(-6px);
}
75% {
transform: translateX(5px);
}
80% {
transform: translateX(-4px);
}
85% {
transform: translateX(3px);
}
90% {
transform: translateX(-2px);
}
95% {
transform: translateX(1px);
}
}
@media (hover: none) {
.photo:active .photo__pic {
animation: shake 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
}
@media (prefers-reduced-motion: reduce) {
.photo.is-demo .photo__pic {
animation: none;
}
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./ShakePhoto.module.scss";
export type ShakePhotoProps = {
caption: string;
badge?: string;
/** 흔들림이 끝나는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 프레임 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ShakePhoto({
caption,
badge = "SALE",
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
}: ShakePhotoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure className={styles.photo} style={vars} tabIndex={0}>
<span className={styles.pic} aria-hidden="true">
<span className={styles.sun} />
<span className={styles.hill} />
<span className={styles.badge}>{badge}</span>
</span>
<figcaption className={styles.cap}>{caption}</figcaption>
</figure>
);
}09 · 揺れ — 計測 累積面積 3.646% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic {
animation: shake .22s $ease-pop;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
どこで崩れるか — 落とし穴
9つとも同じ.photo:hoverの型を使っていますが、実際に組んでみると3つの場所でつまずきました。
circle() が受け取れない値
1つ目は07を組み立てているときに踏んだ落とし穴です。clip-path:circle()の半径にfarthest-cornerを指定できると思い込んで書いたところ、MDNのcircle()の対応表にあるとおりfarthest-cornerはradial-gradientの終端形状だけに使える値で、clip-pathのcircle()が受け取れるのは数値か closest-side・farthest-side だけでした。無効な値を書いた行はまるごと無視され、写真は最初から丸ごと見えたまま固定され、円形に広がる演出そのものが起きなくなりました。
overflow:hidden で点滅する
2つ目は09の揺れを作ったときのことです。他の8つに合わせて.photoにoverflow:hiddenを付けたまま試すと、写真がtranslateXで動くたびに角丸の内側で切り取られ、震えているというより点滅しているように見えました。09だけoverflow:visibleに戻し、代わりに内側の.photo__picに角丸と枠線を移してから、枠ごと自然に揺れるようになりました。修正済みの9ファイルを開くにはpbk89rn6という文字列が必要です。下のカードからzipを取ってください。 これらのカードを大きさの違う区画で並べる段階まで進むと、ベントグリッド 今どきのLPパーツ9選がその格子を先に組んであります。
perspective をどこに書くか
3つ目は05のチルトを組んでいるときに踏んだものです。perspectiveを傾ける対象の.photo自身に書いてしまうと奥行きがまったく付かず、rotateXとrotateYの数値を大きくしてもただの平面が回るだけでした。perspectiveを親の.frameに移し、.photo側はtransform-style: preserve-3dとrotateだけを持つ形に分けてから、カードが立体的に持ち上がって見えるようになりました。
アクセシビリティ
動きを減らす設定では
prefers-reduced-motion: reduceでは、01から08まではtransition: noneで上書きされるだけなので、ホバーやフォーカスの見た目そのものは消えず、拡大や色の変化が時間をかけずに一気に切り替わるようになります。
09 だけ完全に止まる
唯一09の揺れだけはanimation: noneで仕組みごと止め、写真は最初から最後まで静止したままで何も起きません。
@media (prefers-reduced-motion: reduce) {
.photo__pic,
.photo__cap,
.photo__overlay,
.photo__sweep,
.photo__b,
.photo { transition: none; }
.photo:hover .photo__pic,
.photo:focus-visible .photo__pic,
.photo:active .photo__pic { animation: none; }
}
画像ホバー以外のCSSアニメーションはこちらのカテゴリー一覧に、このサイトの成り立ちは紹介ページにまとめています。
FAQ
css 画像 ホバー アニメーション を:hoverだけでなく:focus-visibleと:activeにも当てているのはなぜですか?
Tabキーでフォーカスした場合や、スマホでタップした瞬間にも同じ見た目を返すためです。:hoverだけに絞ると、マウスを使わない人には演出がまったく起きず、伝わる情報に差が生まれてしまいます。09の揺れのような短い演出も、:active経由でタップ直後に同じ動きを確認できます。 05の3Dチルトを一枚ではなく複数の面が回る形へ育てたい場合は、CSS 3D アニメーション9選がその次の段階です。
グリッドでは動いているのに自分のページに置くと止まって見えるのはなぜですか?
冒頭のグリッドは記事を読むためのiframeの中で、is-demoというクラスが付いた自動ループ専用のkeyframesが動いているためです。実際に配置するコードにはis-demoのクラスも自動ループ用のkeyframesも含まれていないので、カーソルを乗せる・フォーカスする・タップするまでは静止したままで正常です。
zipのReact版でも同じホバーの動きになりますか?
はい、zipのreact/フォルダの各コンポーネントは、div要素やsvg要素にこれまでと同じクラス名を付けているだけで、拡大の割合や色の変わり方はCSSのtransitionと@keyframesが担っています。08だけは2枚目の写真を重ねる構造をJSXで組んでいますが、opacityを切り替えるのはCSS側の:hoverセレクタのままです。









