【コピペOK】CSS カード ホバー 効果9選
css カード ホバーとは、商品カードやブログ記事カードにマウスを乗せたりキーボードでフォーカスを移したりした瞬間だけ、画像が拡大したり隠れていた説明が現れたりする短い動きのことです。商品・記事カードにそのまま貼り付けられる9種類を、実際にブラウザで描画して動きを測定してからまとめました。
01 card-hover-image-zoom-caption
02 card-hover-tilt-3d-zones
03 card-hover-flip-reveal
04 card-hover-corner-frame-expand
05 card-hover-badge-pop-elastic
06 card-hover-cta-underline-sweep
07 card-hover-anchored-tooltip-panel
08 card-hover-glassmorphism-blur
09 card-hover-select-ring-draw
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 画像ズーム+キャプションスライド
- 02 ゾーン別3Dチルト(CSSのみ)
- 03 裏面フリップ公開
- 04 コーナーブラケットフレーム
- 05 バッジの弾む登場
- 06 CTA下線スイープ(親ホバー)
- 07 アンカー配置のミニパネル
- 08 グラスモーフィズムブラー
- 09 選択リングの描画
文字側の並びが気になる方は、同じfeedback柱の前後にあたるcss テキスト アニメーションとデコード演出もご覧ください。9個の css カード アニメーション は人気の高い順ではなく、カードを一枚じっくり見るときに視線と操作が実際にたどる順に並べました。まず画像が主役として大きくなり(01)、カーソルを置いた位置に応じてカード自体が傾き(02)、気になって見ていると裏面がめくれます(03)。続けてブラケット・バッジ・下線という3つの小さな合図で狙いを絞ったことを伝えたあと(04〜06)、少し止まると詳細パネルが横に現れ(07)、ガラス質の表面が光を受けたように濃くなり(08)、最後にリングが一周描かれて選択が確定します(09)。
| 番号 | 名前 | 何が動くか | JS |
|---|---|---|---|
| 01 | 画像ズーム+キャプションスライド | 画像scale・キャプションtranslateY |
なし |
| 02 | ゾーン別3Dチルト | カードrotateX・rotateY |
なし |
| 03 | 裏面フリップ公開 | カードrotateY(180deg) |
なし |
| 04 | コーナーブラケットフレーム | ブラケットopacity・位置 |
なし |
| 05 | バッジの弾む登場 | バッジscaleのオーバーシュート |
なし |
| 06 | CTA下線スイープ | 下線scaleX |
なし |
| 07 | アンカー配置のミニパネル | パネルopacity・位置 |
なし |
| 08 | グラスモーフィズムブラー | backdrop-filter・影 |
なし |
| 09 | 選択リングの描画 | SVGstroke-dashoffset |
なし |
01画像ズーム+キャプションスライド
画像が1.08倍まで広がる
カードにカーソルを乗せると内側の画像がゆっくり1.08倍まで拡大し、同時に下に隠れていたキャプションが上へスライドして重なります。

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__frame">
<span class="card__media" aria-hidden="true"></span>
<div class="card__caption">
<h3 class="card__title">バリ・ウブド3泊4日</h3>
<p class="card__desc">田んぼの間のヴィラ・ナイトツアー</p>
</div>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 확대·캡션이 움직이는 시간
$easing: $ease-out;
$color: $subject-blue; // 미디어 그라디언트
@include stage($stage-ink);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
box-shadow: $shadow-press;
outline: none;
cursor: pointer;
}
.card__frame { position: relative; aspect-ratio: 4 / 3; overflow: hidden; }
.card__media {
position: absolute; inset: 0;
background: linear-gradient(155deg, $color 0%, $stage-ink 100%);
transform: scale(1);
transition: transform $duration $easing;
}
.card__caption {
position: absolute; left: 0; right: 0; bottom: 0;
padding: $sp-2 $sp-3;
background: linear-gradient(0deg, rgba($stage-ink, .85), rgba($stage-ink, 0));
transform: translateY(60%);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.card__desc { margin: 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .85); }
.card:hover .card__media,
.card:focus-visible .card__media,
.card:active .card__media {
transform: scale(1.08);
}
.card:hover .card__caption,
.card:focus-visible .card__caption,
.card:active .card__caption {
transform: translateY(0);
opacity: 1;
}
.card.is-demo .card__media { animation: zoom-loop $dur-loop $easing infinite; }
.card.is-demo .card__caption { animation: caption-loop $dur-loop $easing infinite; }
@keyframes zoom-loop {
0%, 15% { transform: scale(1); }
45%, 75% { transform: scale(1.08); }
100% { transform: scale(1); }
}
@keyframes caption-loop {
0%, 15% { transform: translateY(60%); opacity: 0; }
45%, 75% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(60%); opacity: 0; }
}
@media (hover: none) {
.card:active .card__media { transform: scale(1.08); }
.card:active .card__caption { transform: translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card__media, .card__caption { transition: none; }
.card.is-demo .card__media, .card.is-demo .card__caption { animation: none; }
.card__caption { transform: translateY(0); opacity: 1; }
}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(8px, 2.4vw, 16px);
}
.card {
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
}
.card__frame {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
transform: scale(1);
transition: transform 300ms ease-out;
}
.card__caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px;
background: linear-gradient(0deg, rgba(23, 20, 26, 0.85), rgba(23, 20, 26, 0));
transform: translateY(60%);
opacity: 0;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.card__desc {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.85);
}
.card:hover .card__media,
.card:focus-visible .card__media,
.card:active .card__media {
transform: scale(1.08);
}
.card:hover .card__caption,
.card:focus-visible .card__caption,
.card:active .card__caption {
transform: translateY(0);
opacity: 1;
}
.card.is-demo .card__media {
animation: zoom-loop 2s ease-out infinite;
}
.card.is-demo .card__caption {
animation: caption-loop 2s ease-out infinite;
}
@keyframes zoom-loop {
0%, 15% {
transform: scale(1);
}
45%, 75% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}
@keyframes caption-loop {
0%, 15% {
transform: translateY(60%);
opacity: 0;
}
45%, 75% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(60%);
opacity: 0;
}
}
@media (hover: none) {
.card:active .card__media {
transform: scale(1.08);
}
.card:active .card__caption {
transform: translateY(0);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.card__media, .card__caption {
transition: none;
}
.card.is-demo .card__media, .card.is-demo .card__caption {
animation: none;
}
.card__caption {
transform: translateY(0);
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./ZoomCaptionCard.module.scss";
export type ZoomCaptionCardProps = {
title: string;
desc: string;
/** 확대·캡션이 움직이는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 미디어 그라디언트 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ZoomCaptionCard({
title,
desc,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: ZoomCaptionCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<div className={styles.frame}>
<span className={styles.media} aria-hidden="true" />
<div className={styles.caption}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.desc}>{desc}</p>
</div>
</div>
</article>
);
}01 · 画像ズーム+キャプションスライド — 計測 累積面積 4.11% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
画像が主役の場面に
旅行先や商品を紹介するカード、ブログのサムネイルカードに向いています。
.card__media {
transform: scale(1);
transition: transform $duration $easing;
}
.card__caption {
transform: translateY(60%);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.card:hover .card__media,
.card:focus-visible .card__media { transform: scale(1.08); }
.card:hover .card__caption,
.card:focus-visible .card__caption { transform: translateY(0); opacity: 1; }
02ゾーン別3Dチルト(CSSのみ)
ゾーンに応じて傾く
カードを4分割した見えないゾーンのうち、カーソルが乗ったゾーンに応じて:has()セレクタが異なる傾きの値を選び、座標を読み取るJSなしでもその方向へ傾きます。

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__zones" aria-hidden="true">
<span class="zone zone--tl"></span>
<span class="zone zone--tr"></span>
<span class="zone zone--bl"></span>
<span class="zone zone--br"></span>
</div>
<div class="card__inner">
<h3 class="card__title">新着リリース</h3>
<p class="card__meta">Q3ロードマップを見る</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 기울어지는 시간
$easing: $ease-spring;
$color: $subject-cream; // 안쪽 패널 배경
@include stage($stage-yellow);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
perspective: 600px;
outline: none;
cursor: pointer;
}
.card__zones {
position: absolute; inset: 0;
z-index: 2;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
border-radius: $r-card;
overflow: hidden;
}
.zone { display: block; }
.card__inner {
height: 100%;
padding: $sp-3;
box-sizing: border-box;
border-radius: $r-card;
background: $color;
box-shadow: $shadow-press;
display: flex;
flex-direction: column;
justify-content: center;
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .65); }
// 구역 4개 중 어디에 커서가 있는지에 따라 :has() 가 다른 기울기를 고른다 — JS 좌표 추적이 없다
.card:has(.zone--tl:hover) .card__inner,
.card:focus-visible .card__inner { transform: rotateX(8deg) rotateY(-10deg); }
.card:has(.zone--tr:hover) .card__inner { transform: rotateX(8deg) rotateY(10deg); }
.card:has(.zone--bl:hover) .card__inner { transform: rotateX(-8deg) rotateY(-10deg); }
.card:has(.zone--br:hover) .card__inner { transform: rotateX(-8deg) rotateY(10deg); }
.card.is-demo .card__inner { animation: tilt-loop $dur-loop $easing infinite; }
@keyframes tilt-loop {
0%, 10% { transform: rotateX(0) rotateY(0); }
25% { transform: rotateX(8deg) rotateY(-10deg); }
50% { transform: rotateX(8deg) rotateY(10deg); }
75% { transform: rotateX(-8deg) rotateY(-10deg); }
90%, 100% { transform: rotateX(0) rotateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.card__inner { transition: none; }
.card.is-demo .card__inner { animation: none; transform: 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(8px, 2.4vw, 16px);
}
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
perspective: 600px;
outline: none;
cursor: pointer;
}
.card__zones {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
border-radius: 16px;
overflow: hidden;
}
.zone {
display: block;
}
.card__inner {
height: 100%;
padding: 12px;
box-sizing: border-box;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
display: flex;
flex-direction: column;
justify-content: center;
transform-style: preserve-3d;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.65);
}
.card:has(.zone--tl:hover) .card__inner,
.card:focus-visible .card__inner {
transform: rotateX(8deg) rotateY(-10deg);
}
.card:has(.zone--tr:hover) .card__inner {
transform: rotateX(8deg) rotateY(10deg);
}
.card:has(.zone--bl:hover) .card__inner {
transform: rotateX(-8deg) rotateY(-10deg);
}
.card:has(.zone--br:hover) .card__inner {
transform: rotateX(-8deg) rotateY(10deg);
}
.card.is-demo .card__inner {
animation: tilt-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tilt-loop {
0%, 10% {
transform: rotateX(0) rotateY(0);
}
25% {
transform: rotateX(8deg) rotateY(-10deg);
}
50% {
transform: rotateX(8deg) rotateY(10deg);
}
75% {
transform: rotateX(-8deg) rotateY(-10deg);
}
90%, 100% {
transform: rotateX(0) rotateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card__inner {
transition: none;
}
.card.is-demo .card__inner {
animation: none;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./TiltZonesCard.module.scss";
export type TiltZonesCardProps = {
title: string;
meta: string;
/** 기울어지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 안쪽 패널 배경 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 구역 4개 중 마우스가 올라간 구역에 따라 :has() 가 기울기를 고른다 — JS 좌표 추적이 없다 */
export default function TiltZonesCard({
title,
meta,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: TiltZonesCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<div className={styles.zones} aria-hidden="true">
<span className={`${styles.zone} ${styles.zoneTl}`} />
<span className={`${styles.zone} ${styles.zoneTr}`} />
<span className={`${styles.zone} ${styles.zoneBl}`} />
<span className={`${styles.zone} ${styles.zoneBr}`} />
</div>
<div className={styles.inner}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}02 · ゾーン別3Dチルト(CSSのみ) — 計測 累積面積 2.811% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
JSなしでチルトを出す
JSに頼らずチルトを実装したい機能紹介カードやポートフォリオカードに向いています。
.card__inner {
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.card:has(.zone--tl:hover) .card__inner { transform: rotateX(8deg) rotateY(-10deg); }
.card:has(.zone--tr:hover) .card__inner { transform: rotateX(8deg) rotateY(10deg); }
.card:has(.zone--bl:hover) .card__inner { transform: rotateX(-8deg) rotateY(-10deg); }
.card:has(.zone--br:hover) .card__inner { transform: rotateX(-8deg) rotateY(10deg); }
.card:focus-visible .card__inner { transform: rotateX(8deg) rotateY(-10deg); }
03裏面フリップ公開
カード ホバー アニメーション で裏面を見せる
カードにカーソルを乗せるとY軸を軸に180度回転して裏面が現れ、表面には収まらなかった詳細説明やボタンが姿を見せます。

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__flip">
<div class="card__face card__face--front">
<h3 class="card__title">プロプラン</h3>
<p class="card__meta">月12,000ウォン</p>
</div>
<div class="card__face card__face--back">
<p class="card__back-text">無制限エクスポート<br>メンバー5人まで</p>
</div>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 뒤집히는 시간
$easing: $ease-spring;
$color: $subject-blue; // 뒷면 배경
@include stage($stage-orange);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
perspective: 800px;
outline: none;
cursor: pointer;
border-radius: $r-card;
}
.card__flip {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform $duration $easing;
border-radius: $r-card;
}
.card__face {
position: absolute; inset: 0;
backface-visibility: hidden;
border-radius: $r-card;
padding: $sp-3;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
}
.card__face--front { background: $stage-paper; box-shadow: $shadow-press; }
.card__face--back { background: $color; transform: rotateY(180deg); color: $stage-paper; }
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .65); }
.card__back-text { margin: 0; font-weight: 600; font-size: clamp(11px, 3vw, 12px); line-height: 1.5; font-family: inherit; }
.card:hover .card__flip,
.card:focus-visible .card__flip,
.card:active .card__flip {
transform: rotateY(180deg);
}
.card.is-demo .card__flip { animation: flip-loop $dur-loop $easing infinite; }
@keyframes flip-loop {
0%, 20% { transform: rotateY(0); }
50%, 75% { transform: rotateY(180deg); }
100% { transform: rotateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.card__flip { transition: none; }
.card.is-demo .card__flip { animation: none; transform: none; }
}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(8px, 2.4vw, 16px);
}
.card {
width: min(150px, 48vw);
aspect-ratio: 4/3;
perspective: 800px;
outline: none;
cursor: pointer;
border-radius: 16px;
}
.card__flip {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
border-radius: 16px;
}
.card__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
border-radius: 16px;
padding: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
}
.card__face--front {
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.card__face--back {
background: #2f6df6;
transform: rotateY(180deg);
color: #ffffff;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.65);
}
.card__back-text {
margin: 0;
font-weight: 600;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.5;
font-family: inherit;
}
.card:hover .card__flip,
.card:focus-visible .card__flip,
.card:active .card__flip {
transform: rotateY(180deg);
}
.card.is-demo .card__flip {
animation: flip-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes flip-loop {
0%, 20% {
transform: rotateY(0);
}
50%, 75% {
transform: rotateY(180deg);
}
100% {
transform: rotateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card__flip {
transition: none;
}
.card.is-demo .card__flip {
animation: none;
transform: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./FlipRevealCard.module.scss";
export type FlipRevealCardProps = {
title: string;
meta: string;
back: ReactNode;
/** 뒤집히는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 뒷면 배경 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FlipRevealCard({
title,
meta,
back,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FlipRevealCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<div className={styles.flip}>
<div className={`${styles.face} ${styles.faceFront}`}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
<div className={`${styles.face} ${styles.faceBack}`}>{back}</div>
</div>
</article>
);
}03 · 裏面フリップ公開 — 計測 累積面積 13.908% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
情報が二層あるカードに
料金カードの細かい条件やチームメンバー紹介のように、情報が二層あるカードに向いています。
.card__flip {
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.card__face--back {
backface-visibility: hidden;
transform: rotateY(180deg);
}
.card:hover .card__flip,
.card:focus-visible .card__flip {
transform: rotateY(180deg);
}
04コーナーブラケットフレーム
四隅のブラケットが伸びる
カードの四隅にたたまれた短いブラケットが普段は内側に小さく隠れていて、ホバーすると縁のほうへ伸びてカメラのフォーカス枠のようにカードを囲みます。

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="corner corner--tl" aria-hidden="true"></span>
<span class="corner corner--tr" aria-hidden="true"></span>
<span class="corner corner--bl" aria-hidden="true"></span>
<span class="corner corner--br" aria-hidden="true"></span>
<span class="card__media" aria-hidden="true"></span>
<div class="card__body">
<h3 class="card__title">秋のコレクション</h3>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 브래킷이 자라는 시간
$easing: $ease-out;
$color: $subject-blue; // 브래킷 색
@include stage($stage-paper);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
background: $stage-ink;
outline: none;
cursor: pointer;
}
.card__media {
position: absolute; inset: 0;
background: linear-gradient(155deg, $color 0%, $stage-ink 100%);
}
.card__body {
position: absolute; left: 0; right: 0; bottom: 0;
padding: $sp-2 $sp-3;
}
.card__title { margin: 0; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.corner {
position: absolute;
width: $sp-4;
height: $sp-4;
border: 2px solid $color;
opacity: 0;
z-index: 2;
transition: transform $duration $easing, opacity $duration $easing;
}
.corner--tl { top: $sp-2; left: $sp-2; border-right: none; border-bottom: none; transform: translate($sp-2, $sp-2); }
.corner--tr { top: $sp-2; right: $sp-2; border-left: none; border-bottom: none; transform: translate(-$sp-2, $sp-2); }
.corner--bl { bottom: $sp-2; left: $sp-2; border-right: none; border-top: none; transform: translate($sp-2, -$sp-2); }
.corner--br { bottom: $sp-2; right: $sp-2; border-left: none; border-top: none; transform: translate(-$sp-2, -$sp-2); }
.card:hover .corner,
.card:focus-visible .corner,
.card:active .corner {
opacity: 1;
transform: translate(0, 0);
}
.card.is-demo .corner { animation: frame-loop $dur-loop $easing infinite; }
@keyframes frame-loop {
0%, 15% { opacity: 0; }
45%, 75% { opacity: 1; transform: translate(0, 0); }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.corner { transition: none; }
.card.is-demo .corner { animation: none; opacity: 1; transform: translate(0, 0); }
}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(8px, 2.4vw, 16px);
}
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
background: #17141a;
outline: none;
cursor: pointer;
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__body {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px;
}
.card__title {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.corner {
position: absolute;
width: 16px;
height: 16px;
border: 2px solid #2f6df6;
opacity: 0;
z-index: 2;
transition: transform 220ms ease-out, opacity 220ms ease-out;
}
.corner--tl {
top: 8px;
left: 8px;
border-right: none;
border-bottom: none;
transform: translate(8px, 8px);
}
.corner--tr {
top: 8px;
right: 8px;
border-left: none;
border-bottom: none;
transform: translate(-8px, 8px);
}
.corner--bl {
bottom: 8px;
left: 8px;
border-right: none;
border-top: none;
transform: translate(8px, -8px);
}
.corner--br {
bottom: 8px;
right: 8px;
border-left: none;
border-top: none;
transform: translate(-8px, -8px);
}
.card:hover .corner,
.card:focus-visible .corner,
.card:active .corner {
opacity: 1;
transform: translate(0, 0);
}
.card.is-demo .corner {
animation: frame-loop 2s ease-out infinite;
}
@keyframes frame-loop {
0%, 15% {
opacity: 0;
}
45%, 75% {
opacity: 1;
transform: translate(0, 0);
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.corner {
transition: none;
}
.card.is-demo .corner {
animation: none;
opacity: 1;
transform: translate(0, 0);
}
}import type { CSSProperties } from "react";
import styles from "./CornerFrameCard.module.scss";
export type CornerFrameCardProps = {
title: string;
/** 브래킷이 자라는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 브래킷 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CornerFrameCard({
title,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: CornerFrameCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<span className={`${styles.corner} ${styles.tl}`} aria-hidden="true" />
<span className={`${styles.corner} ${styles.tr}`} aria-hidden="true" />
<span className={`${styles.corner} ${styles.bl}`} aria-hidden="true" />
<span className={`${styles.corner} ${styles.br}`} aria-hidden="true" />
<span className={styles.media} aria-hidden="true" />
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
</div>
</article>
);
}04 · コーナーブラケットフレーム — 計測 累積面積 0.773% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
1枚を選ばせたいとき
何枚も並ぶギャラリーグリッドで1枚を選ばせたいときに向いています。
.corner {
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.corner--tl { transform: translate($sp-2, $sp-2); }
.card:hover .corner,
.card:focus-visible .corner {
opacity: 1;
transform: translate(0, 0);
}
05バッジの弾む登場
弾んでから収まる
普段は隠れている「NEW」や「SALE」のバッジが、ホバーすると弾むイージングで一度大きくなってからわずかに縮んで収まります。

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="badge" aria-hidden="true">NEW</span>
<span class="card__media" aria-hidden="true"></span>
<div class="card__body">
<h3 class="card__title">ワイヤレスキーボード</h3>
<p class="card__meta">89,000ウォン</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 뱃지가 튀는 시간
$easing: $ease-pop;
$color: $subject-blue; // 뱃지 배경
@include stage($stage-ink);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
box-shadow: $shadow-press;
outline: none;
cursor: pointer;
}
.card__media {
position: absolute; inset: 0;
background: linear-gradient(155deg, $color 0%, $stage-ink 100%);
}
.card__body {
position: absolute; left: 0; right: 0; bottom: 0;
padding: $sp-2 $sp-3;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .85); }
.badge {
position: absolute;
top: $sp-2; right: $sp-2;
z-index: 2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $color;
color: $stage-ink;
font-weight: 700; font-size: 11px; line-height: 1; font-family: inherit;
transform: scale(0);
transition: transform $duration $easing;
}
.card:hover .badge,
.card:focus-visible .badge,
.card:active .badge {
transform: scale(1);
}
.card.is-demo .badge { animation: badge-loop $dur-loop $easing infinite; }
@keyframes badge-loop {
0%, 15% { transform: scale(0); }
35% { transform: scale(1.25); }
50% { transform: scale(.92); }
65%, 80% { transform: scale(1); }
100% { transform: scale(0); }
}
@media (prefers-reduced-motion: reduce) {
.badge { transition: none; }
.card.is-demo .badge { animation: none; transform: scale(1); }
}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(8px, 2.4vw, 16px);
}
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__body {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.85);
}
.badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 4px 8px;
border-radius: 999px;
background: #2f6df6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1;
font-family: inherit;
transform: scale(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.card:hover .badge,
.card:focus-visible .badge,
.card:active .badge {
transform: scale(1);
}
.card.is-demo .badge {
animation: badge-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes badge-loop {
0%, 15% {
transform: scale(0);
}
35% {
transform: scale(1.25);
}
50% {
transform: scale(0.92);
}
65%, 80% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@media (prefers-reduced-motion: reduce) {
.badge {
transition: none;
}
.card.is-demo .badge {
animation: none;
transform: scale(1);
}
}import type { CSSProperties } from "react";
import styles from "./BadgePopCard.module.scss";
export type BadgePopCardProps = {
title: string;
meta: string;
badge: string;
/** 뱃지가 튀는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 뱃지 배경 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BadgePopCard({
title,
meta,
badge,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: BadgePopCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<span className={styles.badge} aria-hidden="true">{badge}</span>
<span className={styles.media} aria-hidden="true" />
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}05 · バッジの弾む登場 — 計測 累積面積 0.917% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
もう一押し目を引く
セール商品や新着アイテムのように、もう一押し目を引きたいカードで使います。
.badge {
transform: scale(0);
transition: transform $duration $ease-pop;
}
.card:hover .badge,
.card:focus-visible .badge {
transform: scale(1);
}
06CTA下線スイープ(親ホバー)
どこに乗せても下線が引かれる
「詳しく見る」の文字そのものではなくカードのどこにカーソルを乗せても、親の.card:hoverが子の下線を選んで左から右へ描きます。

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">ブランドストーリー</h3>
<p class="card__meta">私たちが作る理由</p>
<span class="cta">
<span class="cta__label">詳しく見る</span>
<span class="cta__underline" aria-hidden="true"></span>
</span>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 밑줄이 그려지는 시간
$easing: $ease-out;
$color: $subject-blue; // CTA 색
@include stage($stage-yellow);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
width: min(160px, 50vw);
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
padding: $sp-3;
box-sizing: border-box;
outline: none;
cursor: pointer;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .6); }
.cta {
position: relative;
display: inline-block;
margin-top: $sp-2;
font-weight: 700; font-size: 12px; line-height: 1.3; font-family: inherit;
color: $color;
}
.cta__underline {
position: absolute;
left: 0; right: 0; bottom: -2px;
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: left; // 규칙8: 좌변서 그어짐
transition: transform $duration $easing;
}
// 밑줄 자체가 아니라 카드 어디에 올려도 부모 :hover 가 자식 밑줄을 켠다
.card:hover .cta__underline,
.card:focus-visible .cta__underline,
.card:active .cta__underline {
transform: scaleX(1);
}
.card.is-demo .cta__underline { animation: underline-loop $dur-loop $easing infinite; }
@keyframes underline-loop {
0%, 15% { transform: scaleX(0); }
45%, 75% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
.cta__underline { transition: none; }
.card.is-demo .cta__underline { animation: none; transform: scaleX(1); }
}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(8px, 2.4vw, 16px);
}
.card {
width: min(160px, 50vw);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
padding: 12px;
box-sizing: border-box;
outline: none;
cursor: pointer;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.cta {
position: relative;
display: inline-block;
margin-top: 8px;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
font-family: inherit;
color: #2f6df6;
}
.cta__underline {
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 2px;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left;
transition: transform 300ms ease-out;
}
.card:hover .cta__underline,
.card:focus-visible .cta__underline,
.card:active .cta__underline {
transform: scaleX(1);
}
.card.is-demo .cta__underline {
animation: underline-loop 2s ease-out infinite;
}
@keyframes underline-loop {
0%, 15% {
transform: scaleX(0);
}
45%, 75% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.cta__underline {
transition: none;
}
.card.is-demo .cta__underline {
animation: none;
transform: scaleX(1);
}
}import type { CSSProperties } from "react";
import styles from "./CtaUnderlineCard.module.scss";
export type CtaUnderlineCardProps = {
title: string;
meta: string;
cta: string;
/** 밑줄이 그려지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** CTA 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 밑줄 자체가 아니라 카드 어디에 올려도 부모 :hover 가 자식 밑줄을 켠다 */
export default function CtaUnderlineCard({
title,
meta,
cta,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: CtaUnderlineCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
<span className={styles.cta}>
<span>{cta}</span>
<span className={styles.underline} aria-hidden="true" />
</span>
</article>
);
}06 · CTA下線スイープ(親ホバー) — 計測 累積面積 0.082% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
カード全体が合図になる
ブログカードの続きを読むリンクやランディングページのCTAに合います。
.cta__underline {
transform: scaleX(0);
transform-origin: left;
transition: transform $duration $easing;
}
.card:hover .cta__underline,
.card:focus-visible .cta__underline {
transform: scaleX(1);
}
07アンカー配置のミニパネル
パネルが自動で吸い付く
カードにanchor-nameを付け、CSSアンカーポジショニングでその下に小さな情報パネルをつなぐと、ホバー時にパネルがカードへ自動で吸い付いて現れます。

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="card__media" aria-hidden="true"></span>
<div class="card__body">
<h3 class="card__title">ワイヤレスイヤホンPro</h3>
<p class="card__meta">128,000ウォン</p>
</div>
</article>
<div class="panel" aria-hidden="true">
<p class="panel__row">在庫12個</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 패널이 뜨는 시간
$easing: $ease-out;
$color: $subject-blue; // 미디어 그라디언트
@include stage($stage-orange);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
anchor-name: --c7;
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
box-shadow: $shadow-press;
outline: none;
cursor: pointer;
}
.card__media {
position: absolute; inset: 0;
background: linear-gradient(155deg, $color 0%, $stage-ink 100%);
}
.card__body {
position: absolute; left: 0; right: 0; bottom: 0;
padding: $sp-2 $sp-3;
background: linear-gradient(0deg, rgba($stage-ink, .8), rgba($stage-ink, 0));
}
.card__title { margin: 0; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .85); }
// CSS 앵커 포지셔닝 — 카드가 어디 있든 anchor-name 만 맞으면 패널이 그 아래에 자동으로 붙는다
.panel {
position: absolute;
position-anchor: --c7;
position-area: block-end;
position-try-fallbacks: flip-block;
margin-top: $sp-1;
width: min(150px, 48vw);
box-sizing: border-box;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: $stage-ink;
color: $stage-paper;
font-weight: 500; font-size: 11px; line-height: 1.4; font-family: inherit;
white-space: nowrap;
text-align: center;
opacity: 0;
transform: translateY(-6px);
transition: opacity $duration $easing, transform $duration $easing;
pointer-events: none;
z-index: 5;
}
.panel__row { margin: 0; }
.card:hover ~ .panel,
.card:focus-visible ~ .panel {
opacity: 1;
transform: translateY(0);
}
.card.is-demo ~ .panel { animation: panel-loop $dur-loop $easing infinite; }
@keyframes panel-loop {
0%, 20% { opacity: 0; transform: translateY(-6px); }
50%, 75% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.panel { transition: none; }
.card.is-demo ~ .panel { animation: none; opacity: 1; 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(8px, 2.4vw, 16px);
}
.card {
anchor-name: --c7;
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__body {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px;
background: linear-gradient(0deg, rgba(23, 20, 26, 0.8), rgba(23, 20, 26, 0));
}
.card__title {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.85);
}
.panel {
position: absolute;
position-anchor: --c7;
position-area: block-end;
position-try-fallbacks: flip-block;
margin-top: 4px;
width: min(150px, 48vw);
box-sizing: border-box;
padding: 4px 8px;
border-radius: 8px;
background: #17141a;
color: #ffffff;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
white-space: nowrap;
text-align: center;
opacity: 0;
transform: translateY(-6px);
transition: opacity 300ms ease-out, transform 300ms ease-out;
pointer-events: none;
z-index: 5;
}
.panel__row {
margin: 0;
}
.card:hover ~ .panel,
.card:focus-visible ~ .panel {
opacity: 1;
transform: translateY(0);
}
.card.is-demo ~ .panel {
animation: panel-loop 2s ease-out infinite;
}
@keyframes panel-loop {
0%, 20% {
opacity: 0;
transform: translateY(-6px);
}
50%, 75% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-6px);
}
}
@media (prefers-reduced-motion: reduce) {
.panel {
transition: none;
}
.card.is-demo ~ .panel {
animation: none;
opacity: 1;
transform: translateY(0);
}
}import type { CSSProperties } from "react";
import styles from "./AnchoredPanelCard.module.scss";
export type AnchoredPanelCardProps = {
title: string;
meta: string;
panel: string;
/** 패널이 뜨는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 미디어 그라디언트 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** CSS 앵커 포지셔닝 — anchor-name 만 맞으면 패널이 카드 아래에 자동으로 붙는다 (JS 없음) */
export default function AnchoredPanelCard({
title,
meta,
panel,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: AnchoredPanelCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.wrap} style={vars}>
<article className={styles.card} tabIndex={0}>
<span className={styles.media} aria-hidden="true" />
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
<div className={styles.panel} aria-hidden="true">
<p className={styles.panelRow}>{panel}</p>
</div>
</div>
);
}07 · アンカー配置のミニパネル — 計測 累積面積 2.903% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
在庫やスペックを先に
在庫やスペックを先に見せたい商品一覧に向いています。
.card { anchor-name: --c7; }
.panel {
position: absolute;
position-anchor: --c7;
position-area: block-end;
position-try-fallbacks: flip-block;
opacity: 0;
transform: translateY(-6px);
transition: opacity $duration $easing, transform $duration $easing;
}
.card:hover ~ .panel,
.card:focus-visible ~ .panel {
opacity: 1;
transform: translateY(0);
}
08グラスモーフィズムブラー
ホバーでガラスが明るくなる
半透明のカード背景にbackdrop-filter: blur()がかかっていて、ホバーするとぼかしと彩度がさらに深まり、光を受けたガラス面のように表情が明るくなります。

<main class="stage stage--glass">
<span class="bg-blob" aria-hidden="true"></span>
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">プレミアムウィジェット</h3>
<p class="card__meta">リアルタイム統計</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 블러·채도가 짙어지는 시간
$easing: $ease-out;
$color: $error; // 배경 블롭 강조색
@include stage($stage-paper);
.stage { padding: clamp(8px, 2.4vw, 16px); position: relative; overflow: hidden; }
.bg-blob {
position: absolute;
inset: -20%;
background:
radial-gradient(circle at 30% 30%, $subject-blue, transparent 60%),
radial-gradient(circle at 70% 70%, $color, transparent 60%);
}
.card {
position: relative;
z-index: 1;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
padding: $sp-3;
box-sizing: border-box;
background: rgba(255, 255, 255, .14);
border: 1px solid rgba(255, 255, 255, .3);
backdrop-filter: blur(6px) saturate(120%);
-webkit-backdrop-filter: blur(6px) saturate(120%);
box-shadow: $shadow-press;
outline: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
transform: translateY(0);
transition: backdrop-filter $duration $easing, background $duration $easing, box-shadow $duration $easing, transform $duration $easing;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .85); }
.card:hover,
.card:focus-visible,
.card:active {
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, .26);
box-shadow: $shadow-raised;
transform: translateY(-4px);
}
.card.is-demo { animation: glass-loop $dur-loop $easing infinite; }
@keyframes glass-loop {
0%, 15% {
backdrop-filter: blur(6px) saturate(120%);
background: rgba(255, 255, 255, .14);
box-shadow: $shadow-press;
transform: translateY(0);
}
45%, 75% {
backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, .26);
box-shadow: $shadow-raised;
transform: translateY(-4px);
}
100% {
backdrop-filter: blur(6px) saturate(120%);
background: rgba(255, 255, 255, .14);
box-shadow: $shadow-press;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.card.is-demo { animation: none; backdrop-filter: blur(14px) saturate(160%); background: rgba(255, 255, 255, .26); transform: 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(8px, 2.4vw, 16px);
position: relative;
overflow: hidden;
}
.bg-blob {
position: absolute;
inset: -20%;
background: radial-gradient(circle at 30% 30%, #2f6df6, transparent 60%), radial-gradient(circle at 70% 70%, #c2361a, transparent 60%);
}
.card {
position: relative;
z-index: 1;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
padding: 12px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(6px) saturate(120%);
-webkit-backdrop-filter: blur(6px) saturate(120%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
transform: translateY(0);
transition: backdrop-filter 300ms ease-out, background 300ms ease-out, box-shadow 300ms ease-out, transform 300ms ease-out;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.85);
}
.card:hover,
.card:focus-visible,
.card:active {
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, 0.26);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(-4px);
}
.card.is-demo {
animation: glass-loop 2s ease-out infinite;
}
@keyframes glass-loop {
0%, 15% {
backdrop-filter: blur(6px) saturate(120%);
background: rgba(255, 255, 255, 0.14);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(0);
}
45%, 75% {
backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, 0.26);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(-4px);
}
100% {
backdrop-filter: blur(6px) saturate(120%);
background: rgba(255, 255, 255, 0.14);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card.is-demo {
animation: none;
backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, 0.26);
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./GlassCard.module.scss";
export type GlassCardProps = {
title: string;
meta: string;
/** 블러·채도가 짙어지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 배경 블롭 강조색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GlassCard({
title,
meta,
duration = 300,
easing = "ease-out",
color = "#c2361a",
}: GlassCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<span className={styles.blob} aria-hidden="true" />
<article className={styles.card} tabIndex={0}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</article>
</div>
);
}08 · グラスモーフィズムブラー — 計測 累積面積 12.276% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ダーク背景の上で
ダーク背景の上のプレミアムカードやダッシュボードウィジェットに向いています。
.card {
background: rgba(255, 255, 255, .14);
backdrop-filter: blur(6px) saturate(120%);
transition: backdrop-filter $duration $easing, transform $duration $easing;
}
.card:hover,
.card:focus-visible {
backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, .26);
transform: translateY(-4px);
}
09選択リングの描画
リングが一周描かれる
選べるカードにホバーしたり選択したりすると、縁を囲んだSVGのリングがstroke-dashoffsetで一周ぶん描かれて「選択済み」だと知らせます。

<main class="stage">
<article class="card is-demo" tabindex="0">
<svg class="ring" viewBox="0 0 160 100" aria-hidden="true">
<rect class="ring__rect" x="2" y="2" width="156" height="96" rx="14" ry="14"></rect>
</svg>
<div class="card__body">
<h3 class="card__title">ベーシックプラン</h3>
<p class="card__meta">月4,900ウォン</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 링이 그려지는 시간
$easing: $ease-spring;
$color: $subject-blue; // 링 색
@include stage($stage-ink);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
position: relative;
width: min(160px, 50vw);
aspect-ratio: 8 / 5;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
padding: $sp-3;
box-sizing: border-box;
outline: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
}
.ring {
position: absolute; inset: 0;
width: 100%; height: 100%;
pointer-events: none;
}
.ring__rect {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 500;
stroke-dashoffset: 500;
transition: stroke-dashoffset $duration $easing;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .6); }
.card:hover .ring__rect,
.card:focus-visible .ring__rect,
.card:active .ring__rect {
stroke-dashoffset: 0;
}
.card.is-demo .ring__rect { animation: ring-loop $dur-loop $easing infinite; }
@keyframes ring-loop {
0%, 10% { stroke-dashoffset: 500; }
55%, 80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 500; }
}
@media (prefers-reduced-motion: reduce) {
.ring__rect { transition: none; }
.card.is-demo .ring__rect { animation: none; stroke-dashoffset: 0; }
}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(8px, 2.4vw, 16px);
}
.card {
position: relative;
width: min(160px, 50vw);
aspect-ratio: 8/5;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
padding: 12px;
box-sizing: border-box;
outline: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
}
.ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.ring__rect {
fill: none;
stroke: #2f6df6;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 500;
stroke-dashoffset: 500;
transition: stroke-dashoffset 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.card:hover .ring__rect,
.card:focus-visible .ring__rect,
.card:active .ring__rect {
stroke-dashoffset: 0;
}
.card.is-demo .ring__rect {
animation: ring-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ring-loop {
0%, 10% {
stroke-dashoffset: 500;
}
55%, 80% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 500;
}
}
@media (prefers-reduced-motion: reduce) {
.ring__rect {
transition: none;
}
.card.is-demo .ring__rect {
animation: none;
stroke-dashoffset: 0;
}
}import type { CSSProperties } from "react";
import styles from "./SelectRingCard.module.scss";
export type SelectRingCardProps = {
title: string;
meta: string;
/** 링이 그려지는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 링 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SelectRingCard({
title,
meta,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SelectRingCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<svg className={styles.ring} viewBox="0 0 160 100" aria-hidden="true">
<rect className={styles.ringRect} x={2} y={2} width={156} height={96} rx={14} ry={14} />
</svg>
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}09 · 選択リングの描画 — 計測 累積面積 1.335% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
選択を締めくくる動き
プラン選択やラジオ形式のカード選択UIを締めくくる動きです。
.ring__rect {
fill: none;
stroke-dasharray: 500;
stroke-dashoffset: 500;
transition: stroke-dashoffset $duration $easing;
}
.card:hover .ring__rect,
.card:focus-visible .ring__rect {
stroke-dashoffset: 0;
}
どこで壊れるか — 落とし穴ひとつ
強度0.0が出た理由
08番のグラスモーフィズムを最初にレンダリングしたとき、測定値が明らかにおかしいものでした。カード全体のぼかしが目には見えて変わっているのに、強度平均は0.0、動いたフレームは23コマ中0コマと出たのです。この計測はブラウザで実際に再生するのではなく、Web Animations APIでcurrentTimeをコマごとに強制的に動かしてスクリーンショットを撮る仕組みで、ヘッドレスChromeはbackdrop-filterだけが変わる合成レイヤーをこの方式では毎コマ塗り直してくれないようでした。
transformをひとつ足した
同じkeyframesの中にtransform: translateY(-4px)をひとつ足し、ぼかしと一緒にカードがわずかに浮き上がるようにしただけで、同じ条件で強度平均53.9・動いたフレーム12コマという正常な値になりました。圧縮ファイルの合言葉はtvyct8zbで、この検証記録が残っていなければ08番は「動いていないカード」のまま残っていたはずです。ぼかしや彩度のように合成レイヤーだけにかかる値は、transformやopacityを添えてはじめて計測でも人の目でもはっきり動いて見えます。質感を厚く重ねるスキューモーフィズムデザインの質感パーツも、影と光沢だけを動かすと08と同じように計測から消えてしまいます。
アクセシビリティ
遷移を止めても最終状態は残る
OSで「動きを減らす」設定を有効にしている方には、9個すべてでprefers-reduced-motion: reduceのときにトランジションと自動アニメーションを止めます。01・03・04・05・06・08・09はホバーやフォーカスが当たった瞬間、アニメーションなしでそのまま最終の見た目(画像が広がりきった・裏返った・ブラケットが点いた・バッジが座った・下線が引かれた・ぼかしが濃いまま固定・リングが一周描かれた状態)に切り替わるので、情報が欠けることはありません。02は傾きが遷移なしでそのまま適用され、07はパネルが遷移なしで現れたり消えたりします。
三つの状態を一つの規則に
マウスを乗せたときだけでなく、キーボードでフォーカスが移ったときや指で押したときも同じ最終状態になるよう、:hoverと:focus-visible(一部は:activeも)をひとつの規則にまとめています。
関連リンク
カードのホバー表現をもっと見たい方はカードホバーアニメーション9選、ガラス質の質感だけをじっくり見たい方はグラスモーフィズム効果9選もあわせてどうぞ。
FAQ
02のゾーン別チルトは:has()に対応していないブラウザではどうなりますか?
:has()に対応していないブラウザではそのセレクタを含む規則自体がマッチしないため、カードは平らなまま止まります。レイアウトが崩れたりエラーが出たりしない安全な劣化で、最新のChrome・Safari・Firefoxはすでに対応しています。
07のアンカー配置パネルはすべてのブラウザで同じように表示されますか?
Chrome・Edgeでは表示されますが、この記事を書いている時点のFirefox・SafariはCSSアンカーポジショニングに対応しておらず、パネル自体が現れません。対応していないブラウザでもカード本体(画像・タイトル・価格)は普通に表示されるので、肝心な情報が欠けることはありません。
css カードデザイン の選択リングをReact版で使うときstateはどう管理しますか?
SelectRingCardコンポーネントは専用のstateを持たず、CSSの:hover・:focus-visible・:activeだけでリングを描きます。実際のフォーム選択のようにクリックしたあとも選択状態を保ちたい場合は、親コンポーネント側でaria-pressedと専用クラスをもうひとつ重ねて管理してください。カーソルの座標そのものを読み取って反応させる手法はマウス カーソル エフェクト 9選で別に扱っています。









