一行でコピペOK ― CSS カード ホバー アニメーション9選
css カード ホバーとは、カード型のUIコンポーネントがマウスやフォーカスを受け取った瞬間だけ、浮く・光る・傾くといった短い変化を見せる仕掛けです。今回は9種類を選び、7つはCSSのtransformとtransitionだけで、残り2つはJavaScriptを10行前後添えて実装しました。
01 card-hover-lift-shadow
02 card-hover-border-light-spin
03 card-hover-tilt-3d-parallax
04 card-hover-content-slide-up
05 card-hover-bg-zoom
07 card-hover-gradient-border-hover
08 card-hover-spotlight-follow
09 card-hover-press-feedback
自動再生 · タイルを押すとその項目へ · すべて1つのzip
並び順は人気順ではなく、カードを選ぶ人の視線がたどる順番に合わせました。まず存在を主張する2つ(01・02)、次に傾きと内容で層を見せる3つ(03〜05)、css ホバー グラデーション で表面の縁を変える1つ(07)、押せると伝える2つ(06・08)を経て、最後に実際に押された瞬間へ答える1つ(09)で終わります。コードは要点だけを載せていて、完成した9本のファイルはzipにまとめてあります。カードではなく会話画面を整えるならチャット 吹き出し css アニメーション、07のグラデーション枠を文字側にも流したいならCSS テキスト グラデーション アニメーションが続きの置き場所になります。
01浮き上がり+影
どう動くか
カードにカーソルを重ねると8pxほど上へ浮き上がり、box-shadowで影が一段濃くなります。クリックする前からグリッドの中で真っ先に目に入るようになります。
どこで使うか
商品カードの一覧やブログ記事一覧のカードに向いています。

<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">ワイヤレスイヤホン</h3>
<p class="card__meta">48,000ウォン</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 뜨는 시간
$easing: $ease-spring; // 탄력 있게 붙는 느낌
$color: $subject-cream; // 카드 배경
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); }
// 스포트라이트(커서 하이라이트) + 미세 3D 틸트 — 기존 lift 효과 위에 얹는 층
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.card {
position: relative;
width: min(150px, 48vw);
border-radius: $r-card;
background: $color;
box-shadow: $shadow-press;
overflow: hidden;
outline: none;
cursor: pointer;
transform: translateY(0) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing, box-shadow $duration $easing;
}
.card::after {
content: '';
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, .32), transparent 70%);
opacity: var(--sp);
}
.card__media {
display: block;
aspect-ratio: 16 / 9;
background: linear-gradient(155deg, $subject-blue 0%, $stage-ink 100%);
}
.card__body { position: relative; z-index: 1; padding: 8px 12px; }
.card__title { margin: 0 0 4px; 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,
.card:focus-visible,
.card:active,
.card.is-demo {
transform: translateY(-8px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
box-shadow: $shadow-raised;
}
.card.is-demo { animation: lift-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
@keyframes lift-loop {
0%, 20% { transform: translateY(0) rotateX(0deg) rotateY(0deg); box-shadow: $shadow-press; }
50% { transform: translateY(-8px) rotateX(-4deg) rotateY(5deg); box-shadow: $shadow-raised; }
75% { transform: translateY(-8px) rotateX(3deg) rotateY(-5deg); box-shadow: $shadow-raised; }
100% { transform: translateY(0) rotateX(0deg) rotateY(0deg); box-shadow: $shadow-press; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active { transform: translateY(-8px); box-shadow: $shadow-raised; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.card.is-demo { animation: none; box-shadow: $shadow-raised; --rx: 0deg; --ry: 0deg; --sp: 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(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.card {
position: relative;
width: min(150px, 48vw);
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
overflow: hidden;
outline: none;
cursor: pointer;
transform: translateY(0) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__media {
display: block;
aspect-ratio: 16/9;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__body {
position: relative;
z-index: 1;
padding: 8px 12px;
}
.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,
.card:focus-visible,
.card:active,
.card.is-demo {
transform: translateY(-8px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.card.is-demo {
animation: lift-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, spot-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes lift-loop {
0%, 20% {
transform: translateY(0) rotateX(0deg) rotateY(0deg);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
50% {
transform: translateY(-8px) rotateX(-4deg) rotateY(5deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
75% {
transform: translateY(-8px) rotateX(3deg) rotateY(-5deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
100% {
transform: translateY(0) rotateX(0deg) rotateY(0deg);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active {
transform: translateY(-8px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card.is-demo {
animation: none;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
--rx: 0deg;
--ry: 0deg;
--sp: 0;
}
}// 커서 위치로 스포트라이트(--mx/--my/--sp)와 미세 3D 틸트(--rx/--ry)를 얹는다. 기존 lift 효과는 :hover 로 그대로 유지.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5, py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-py * 6) + 'deg');
card.style.setProperty('--ry', (px * 7) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent, ReactNode } from "react";
import styles from "./LiftShadowCard.module.scss";
export type LiftShadowCardProps = {
title: string;
meta: string;
/** 뜨는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 카드 배경색 */
color?: string;
media?: ReactNode;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LiftShadowCard({
title,
meta,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
media,
}: LiftShadowCardProps) {
const ref = useRef<HTMLElement>(null);
// 커서 위치로 스포트라이트(--mx/--my/--sp)와 미세 3D 틸트(--rx/--ry)를 얹는다.
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
el.style.setProperty("--sp", "1");
el.style.setProperty("--rx", `${-py * 6}deg`);
el.style.setProperty("--ry", `${px * 7}deg`);
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.setProperty("--sp", "0");
el.style.setProperty("--rx", "0deg");
el.style.setProperty("--ry", "0deg");
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article
ref={ref}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={move}
onPointerLeave={leave}
>
<span className={styles.media} aria-hidden="true">{media}</span>
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}01 · 浮き上がり+影 — 計測 累積面積 11.669% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.card {
transform: translateY(0);
transition:
transform .2s $ease-spring,
box-shadow .2s $ease-spring;
}
.card:hover,
.card:focus-visible,
.card:active {
transform: translateY(-8px);
box-shadow: $shadow-raised;
}
02枠を光が回る
どう動くか
カードの枠の内側でconic-gradientの光が2秒に一度ぐるりと回り続け、触れる前から静かに動いているように見えます。角度は@propertyで登録した--angleを0degから360degへkeyframesで進めているので、値が飛ばずになめらかに繋がります。
どこで使うか
プレミアムな料金プランカードやAI機能紹介カードに向いています。

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="card__ring" aria-hidden="true"></span>
<div class="card__inner">
<h3 class="card__title">プレミアムプラン</h3>
<p class="card__meta">AI機能付き</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 빛이 한 바퀴 도는 시간
$easing: linear; // 회전은 일정 속도로
$color: $subject-blue; // 빛 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
// 스포트라이트(커서 하이라이트) + 미세 3D 틸트 — 기존 테두리 회전 효과 위에 얹는 층
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.card {
position: relative;
width: min(190px, 58vw);
padding: 2px;
border-radius: $r-card;
outline: none;
cursor: pointer;
overflow: hidden; // 빛이 카드 테두리 밖으로 안 번지게, 얇은 띠만 보이게
isolation: isolate;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $dur-base $ease-spring;
}
.card::after {
content: '';
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, .32), transparent 70%);
opacity: var(--sp);
}
.card__ring {
position: absolute;
inset: -50%;
background: conic-gradient(from var(--angle), transparent 0deg, $color 40deg, transparent 100deg);
opacity: 0;
transition: opacity $dur-quick $ease-out;
z-index: -1;
}
.card__inner {
position: relative;
z-index: 1;
border-radius: $r-card - 2px;
background: $stage-ink;
padding: 16px 14px;
}
.card__title { margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $subject-cream; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($subject-cream, .7); }
.card:hover .card__ring,
.card:focus-visible .card__ring,
.card:active .card__ring,
.card.is-demo .card__ring {
opacity: 1;
animation: spin $duration $easing infinite;
}
.card.is-demo { animation: tilt-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
@keyframes spin { to { --angle: 360deg; } }
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active .card__ring { opacity: 1; animation: spin $duration $easing infinite; }
}
@media (prefers-reduced-motion: reduce) {
.card__ring { transition: none; animation: none !important; opacity: .55; }
.card { transition: none; }
.card.is-demo { animation: none; --rx: 0deg; --ry: 0deg; --sp: 0; }
}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);
}
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.card {
position: relative;
width: min(190px, 58vw);
padding: 2px;
border-radius: 16px;
outline: none;
cursor: pointer;
overflow: hidden;
isolation: isolate;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__ring {
position: absolute;
inset: -50%;
background: conic-gradient(from var(--angle), transparent 0deg, #2f6df6 40deg, transparent 100deg);
opacity: 0;
transition: opacity 220ms ease-out;
z-index: -1;
}
.card__inner {
position: relative;
z-index: 1;
border-radius: 14px;
background: #17141a;
padding: 16px 14px;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 247, 230, 0.7);
}
.card:hover .card__ring,
.card:focus-visible .card__ring,
.card:active .card__ring,
.card.is-demo .card__ring {
opacity: 1;
animation: spin 2s linear infinite;
}
.card.is-demo {
animation: tilt-loop 2s linear infinite, spot-loop 2s linear infinite;
}
@keyframes spin {
to {
--angle: 360deg;
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active .card__ring {
opacity: 1;
animation: spin 2s linear infinite;
}
}
@media (prefers-reduced-motion: reduce) {
.card__ring {
transition: none;
animation: none !important;
opacity: 0.55;
}
.card {
transition: none;
}
.card.is-demo {
animation: none;
--rx: 0deg;
--ry: 0deg;
--sp: 0;
}
}// 커서 위치로 스포트라이트(--mx/--my/--sp)와 미세 3D 틸트(--rx/--ry)를 얹는다. 기존 테두리 회전 효과는 :hover 로 그대로 유지.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5, py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-py * 6) + 'deg');
card.style.setProperty('--ry', (px * 7) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BorderLightSpinCard.module.scss";
export type BorderLightSpinCardProps = {
title: string;
meta: string;
/** 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 빛 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BorderLightSpinCard({
title,
meta,
duration = 2000,
easing = "linear",
color = "#2f6df6",
}: BorderLightSpinCardProps) {
const ref = useRef<HTMLElement>(null);
// 커서 위치로 스포트라이트(--mx/--my/--sp)와 미세 3D 틸트(--rx/--ry)를 얹는다.
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
el.style.setProperty("--sp", "1");
el.style.setProperty("--rx", `${-py * 6}deg`);
el.style.setProperty("--ry", `${px * 7}deg`);
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.setProperty("--sp", "0");
el.style.setProperty("--rx", "0deg");
el.style.setProperty("--ry", "0deg");
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article
ref={ref}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={move}
onPointerLeave={leave}
>
<span className={styles.ring} aria-hidden="true" />
<div className={styles.inner}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}02 · 枠を光が回る — 計測 累積面積 1.778% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.card__ring {
background: conic-gradient(
from var(--angle),
transparent 0deg, $color 40deg, transparent 100deg
);
opacity: 0;
}
.card:hover .card__ring,
.card.is-demo .card__ring {
opacity: 1;
animation: spin 2s linear infinite;
}
@keyframes spin { to { --angle: 360deg; } }
033Dチルト視差
どう動くか
カーソルの位置に応じてrotateXとrotateYでカードが傾き、背景レイヤーだけが逆方向へわずかにずれて層が分かれて見えます。座標の計算はJavaScript数行だけが担い、CSS側はその値を変数として受け取るだけです。
どこで使うか
機能紹介カードやポートフォリオのプロジェクトカードに向いています。

<main class="stage">
<div class="frame">
<article class="card is-demo" tabindex="0">
<span class="card__layer card__layer--back" aria-hidden="true"></span>
<div class="card__layer card__layer--front">
<h3 class="card__title">ポートフォリオ</h3>
<p class="card__meta">詳しく見る</p>
</div>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 기울기가 붙는 시간
$easing: $ease-spring; // 탄력 있게
$color: $subject-cream; // 카드 배경
@include stage($stage-orange);
.stage { padding: clamp(6px, 2.4vw, 16px); }
// 스포트라이트(커서 하이라이트) — 기존 틸트 효과는 그대로, 이 층만 얹는다
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
.frame { perspective: 700px; } // 자식 카드가 이 값 기준으로 기운다
.card {
position: relative;
width: min(190px, 58vw);
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
outline: none;
cursor: pointer;
overflow: hidden;
transform-style: preserve-3d;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card__layer--back {
position: absolute;
inset: -12%;
background: linear-gradient(155deg, $subject-blue-deep 0%, $stage-ink 100%);
transform: translateZ(-20px) translateX(var(--pz, 0px));
transition: transform $duration $easing;
}
.card__layer--front {
position: relative;
padding: 36px 14px 14px;
transform: translateZ(20px);
}
.card__layer--front::after {
content: '';
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, .32), transparent 70%);
opacity: var(--sp);
}
.card__title { position: relative; z-index: 1; margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $subject-cream; }
.card__meta { position: relative; z-index: 1; margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($subject-cream, .72); }
.card:focus-visible,
.card:active {
transform: rotateX(-8deg) rotateY(12deg);
}
.card:focus-visible .card__layer--back,
.card:active .card__layer--back {
transform: translateZ(-20px) translateX(10px);
}
.card.is-demo { animation: tilt-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
.card.is-demo .card__layer--back { animation: tilt-loop-bg $dur-loop $easing infinite; }
@keyframes tilt-loop {
0%, 15% { transform: rotateX(0) rotateY(0); }
40% { transform: rotateX(-8deg) rotateY(12deg); }
65% { transform: rotateX(6deg) rotateY(-10deg); }
100% { transform: rotateX(0) rotateY(0); }
}
@keyframes tilt-loop-bg {
0%, 15% { transform: translateZ(-20px) translateX(0); }
40% { transform: translateZ(-20px) translateX(10px); }
65% { transform: translateZ(-20px) translateX(-8px); }
100% { transform: translateZ(-20px) translateX(0); }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active { transform: rotateX(-8deg) rotateY(12deg); }
.card:active .card__layer--back { transform: translateZ(-20px) translateX(10px); }
}
@media (prefers-reduced-motion: reduce) {
.card, .card__layer--back { transition: none; }
.card.is-demo, .card.is-demo .card__layer--back { animation: none; }
.card.is-demo { --sp: 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);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.frame {
perspective: 700px;
}
.card {
position: relative;
width: min(190px, 58vw);
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
overflow: hidden;
transform-style: preserve-3d;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card__layer--back {
position: absolute;
inset: -12%;
background: linear-gradient(155deg, #0a4fe8 0%, #17141a 100%);
transform: translateZ(-20px) translateX(var(--pz, 0px));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card__layer--front {
position: relative;
padding: 36px 14px 14px;
transform: translateZ(20px);
}
.card__layer--front::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__title {
position: relative;
z-index: 1;
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
}
.card__meta {
position: relative;
z-index: 1;
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 247, 230, 0.72);
}
.card:focus-visible,
.card:active {
transform: rotateX(-8deg) rotateY(12deg);
}
.card:focus-visible .card__layer--back,
.card:active .card__layer--back {
transform: translateZ(-20px) translateX(10px);
}
.card.is-demo {
animation: tilt-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, spot-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card.is-demo .card__layer--back {
animation: tilt-loop-bg 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tilt-loop {
0%, 15% {
transform: rotateX(0) rotateY(0);
}
40% {
transform: rotateX(-8deg) rotateY(12deg);
}
65% {
transform: rotateX(6deg) rotateY(-10deg);
}
100% {
transform: rotateX(0) rotateY(0);
}
}
@keyframes tilt-loop-bg {
0%, 15% {
transform: translateZ(-20px) translateX(0);
}
40% {
transform: translateZ(-20px) translateX(10px);
}
65% {
transform: translateZ(-20px) translateX(-8px);
}
100% {
transform: translateZ(-20px) translateX(0);
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active {
transform: rotateX(-8deg) rotateY(12deg);
}
.card:active .card__layer--back {
transform: translateZ(-20px) translateX(10px);
}
}
@media (prefers-reduced-motion: reduce) {
.card, .card__layer--back {
transition: none;
}
.card.is-demo, .card.is-demo .card__layer--back {
animation: none;
}
.card.is-demo {
--sp: 0;
}
}// 카드 위 커서 위치로 기울기를 만든다. 배경 레이어가 반대로 움직여 층이 갈린다(시차).
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect(), px = (e.clientX - r.left) / r.width - .5, py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--rx', (-py * 14) + 'deg');
card.style.setProperty('--ry', (px * 16) + 'deg');
card.style.setProperty('--pz', (px * -12) + 'px');
// 스포트라이트(커서 하이라이트) — 틸트는 그대로, 이 층만 얹는다
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
});
card.addEventListener('pointerleave', function () { card.style.cssText = ''; });import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./TiltParallaxCard.module.scss";
export type TiltParallaxCardProps = {
title: string;
meta: string;
/** 기울기가 붙는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TiltParallaxCard({
title,
meta,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: TiltParallaxCardProps) {
const ref = useRef<HTMLElement>(null);
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
el.style.setProperty("--rx", `${-py * 14}deg`);
el.style.setProperty("--ry", `${px * 16}deg`);
el.style.setProperty("--pz", `${px * -12}px`);
// 스포트라이트(커서 하이라이트) — 틸트는 그대로, 이 층만 얹는다
el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
el.style.setProperty("--sp", "1");
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (el) {
el.style.removeProperty("--rx"); el.style.removeProperty("--ry"); el.style.removeProperty("--pz");
el.style.removeProperty("--mx"); el.style.removeProperty("--my"); el.style.removeProperty("--sp");
}
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.frame}>
<article
ref={ref}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={move}
onPointerLeave={leave}
>
<span className={styles.back} aria-hidden="true" />
<div className={styles.front}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
</div>
);
}03 · 3Dチルト視差 — 計測 累積面積 11.272% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
card.addEventListener('pointermove', (e) => {
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - .5;
const py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--rx', `${-py * 16}deg`);
card.style.setProperty('--ry', `${px * 20}deg`);
card.style.setProperty('--pz', `${px * -10}px`);
});
04内容がスライドアップ
どう動くか
画像の下に隠れている説明パネルが、ホバーすると画像の上へtranslateYでスライドアップして現れます。カードの高さ自体は変わらないまま、情報だけが一枚追加されます。
どこで使うか
画像中心の商品カードやギャラリーのサムネイルカードに向いています。

<main class="stage">
<div class="frame">
<article class="card is-demo" tabindex="0">
<span class="card__media" aria-hidden="true"></span>
<div class="card__panel">
<h3 class="card__title">秋のコレクション</h3>
<p class="card__meta">今すぐ見る</p>
</div>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 패널이 올라오는 시간
$easing: $ease-spring;
$color: $subject-cream; // 패널 배경
@include stage($stage-paper);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.frame { perspective: 700px; } // 커서 틸트가 이 값 기준으로 기운다
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card::after {
content: '';
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255,255,255,.32), transparent 70%);
opacity: var(--sp);
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, $subject-blue 0%, $stage-ink 100%);
}
.card__panel {
position: absolute;
z-index: 1; // 스포트라이트(::after) 위로 올라오게
left: 0; right: 0; bottom: 0;
padding: 10px 12px;
background: $color;
border-radius: $r-card $r-card 0 0;
transform: translateY(70%);
transition: transform $duration $easing;
}
.card__title { margin: 0 0 4px; 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);
opacity: 0;
transition: opacity $duration $easing;
}
.card:hover .card__panel,
.card:focus-visible .card__panel,
.card:active .card__panel,
.card.is-demo .card__panel {
transform: translateY(0);
}
.card:hover .card__meta,
.card:focus-visible .card__meta,
.card:active .card__meta,
.card.is-demo .card__meta {
opacity: 1;
}
.card.is-demo .card__panel { animation: slide-loop $dur-loop $easing infinite; }
.card.is-demo .card__meta { animation: fade-loop $dur-loop $easing infinite; }
.card.is-demo { animation: tilt-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
@keyframes slide-loop {
0%, 20% { transform: translateY(70%); }
50%, 75% { transform: translateY(0); }
100% { transform: translateY(70%); }
}
@keyframes fade-loop {
0%, 20% { opacity: 0; }
50%, 75% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active .card__panel { transform: translateY(0); }
.card:active .card__meta { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card__panel, .card__meta { transition: none; }
.card.is-demo .card__panel, .card.is-demo .card__meta { animation: none; }
.card__panel { transform: translateY(0); }
.card__meta { opacity: 1; }
.card { transition: none; --rx: 0deg; --ry: 0deg; --sp: 0; }
.card.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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.frame {
perspective: 700px;
}
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__panel {
position: absolute;
z-index: 1;
left: 0;
right: 0;
bottom: 0;
padding: 10px 12px;
background: #fff7e6;
border-radius: 16px 16px 0 0;
transform: translateY(70%);
transition: transform 300ms 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);
opacity: 0;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card:hover .card__panel,
.card:focus-visible .card__panel,
.card:active .card__panel,
.card.is-demo .card__panel {
transform: translateY(0);
}
.card:hover .card__meta,
.card:focus-visible .card__meta,
.card:active .card__meta,
.card.is-demo .card__meta {
opacity: 1;
}
.card.is-demo .card__panel {
animation: slide-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card.is-demo .card__meta {
animation: fade-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card.is-demo {
animation: tilt-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, spot-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes slide-loop {
0%, 20% {
transform: translateY(70%);
}
50%, 75% {
transform: translateY(0);
}
100% {
transform: translateY(70%);
}
}
@keyframes fade-loop {
0%, 20% {
opacity: 0;
}
50%, 75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active .card__panel {
transform: translateY(0);
}
.card:active .card__meta {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.card__panel, .card__meta {
transition: none;
}
.card.is-demo .card__panel, .card.is-demo .card__meta {
animation: none;
}
.card__panel {
transform: translateY(0);
}
.card__meta {
opacity: 1;
}
.card {
transition: none;
--rx: 0deg;
--ry: 0deg;
--sp: 0;
}
.card.is-demo {
animation: none;
}
}// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5;
var py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-py * 8) + 'deg');
card.style.setProperty('--ry', (px * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent, ReactNode } from "react";
import styles from "./ContentSlideUpCard.module.scss";
export type ContentSlideUpCardProps = {
title: string;
meta: string;
/** 패널이 올라오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
media?: ReactNode;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ContentSlideUpCard({
title,
meta,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
media,
}: ContentSlideUpCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cardRef = useRef<HTMLElement>(null);
// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
const handlePointerMove = useCallback((e: PointerEvent<HTMLElement>) => {
const card = cardRef.current;
if (!card) return;
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
card.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
card.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
card.style.setProperty("--sp", "1");
card.style.setProperty("--rx", `${-py * 8}deg`);
card.style.setProperty("--ry", `${px * 9}deg`);
}, []);
const handlePointerLeave = useCallback(() => {
const card = cardRef.current;
if (!card) return;
card.style.setProperty("--sp", "0");
card.style.setProperty("--rx", "0deg");
card.style.setProperty("--ry", "0deg");
}, []);
return (
<div className={styles.frame}>
<article
ref={cardRef}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
<span className={styles.media} aria-hidden="true">{media}</span>
<div className={styles.panel}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
</div>
);
}04 · 内容がスライドアップ — 計測 累積面積 11.14% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.card__panel {
transform: translateY(70%);
transition: transform .35s $ease-spring;
}
.card:hover .card__panel,
.card.is-demo .card__panel {
transform: translateY(0);
}
05背景画像が拡大
css ホバー 拡大 の作り方
カードを覆う背景がホバーで1.12倍に拡大し、overflow: hiddenが角丸の外へのはみ出しを切り取ります。滑らかなグラデーションだけでは拡大が伝わりにくいため、輪郭のはっきりした太陽と丘の形を重ねています。
どこで使うか
旅行先の紹介カードやブログのカバーカードに向いています。

<main class="stage">
<div class="frame">
<article class="card is-demo" tabindex="0">
<span class="card__bg" aria-hidden="true">
<span class="card__sun"></span>
<span class="card__hill"></span>
</span>
<div class="card__overlay">
<h3 class="card__title">済州の海</h3>
<p class="card__meta">旅行レビュー</p>
</div>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 배경이 다 커지는 시간
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.frame { perspective: 700px; } // 커서 틸트가 이 값 기준으로 기운다
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden; // 확대된 배경이 둥근 모서리 밖으로 안 나가게
box-shadow: $shadow-raised;
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card::after {
content: '';
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255,255,255,.32), transparent 70%);
opacity: var(--sp);
}
.card__bg {
position: absolute;
inset: 0;
overflow: hidden;
background: linear-gradient(155deg, $subject-blue 0%, $color 100%);
transform: scale(1);
transition: transform $duration $easing;
}
.card__sun {
position: absolute;
top: 14%;
right: 16%;
width: 26%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.card__hill {
position: absolute;
left: -10%;
right: -10%;
bottom: -12%;
height: 46%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $stage-ink;
opacity: .85;
}
.card__overlay {
position: absolute;
z-index: 1; // 스포트라이트(::after) 위로 올라오게
left: 0; right: 0; bottom: 0;
padding: 8px 12px;
background: linear-gradient(to top, rgba($stage-ink, .78), transparent);
}
.card__title { margin: 0 0 2px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .8); }
.card:hover .card__bg,
.card:focus-visible .card__bg,
.card:active .card__bg,
.card.is-demo .card__bg {
transform: scale(1.12);
}
.card.is-demo .card__bg { animation: zoom-loop $dur-loop $ease-spring infinite; }
.card.is-demo { animation: tilt-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
@keyframes zoom-loop {
0%, 20% { transform: scale(1); }
50%, 75% { transform: scale(1.12); }
100% { transform: scale(1); }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active .card__bg { transform: scale(1.12); }
}
@media (prefers-reduced-motion: reduce) {
.card__bg { transition: none; }
.card.is-demo .card__bg { animation: none; }
.card { transition: none; --rx: 0deg; --ry: 0deg; --sp: 0; }
.card.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);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.frame {
perspective: 700px;
}
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 600ms ease-out;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__bg {
position: absolute;
inset: 0;
overflow: hidden;
background: linear-gradient(155deg, #2f6df6 0%, #fff7e6 100%);
transform: scale(1);
transition: transform 600ms ease-out;
}
.card__sun {
position: absolute;
top: 14%;
right: 16%;
width: 26%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.card__hill {
position: absolute;
left: -10%;
right: -10%;
bottom: -12%;
height: 46%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.card__overlay {
position: absolute;
z-index: 1;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px;
background: linear-gradient(to top, rgba(23, 20, 26, 0.78), transparent);
}
.card__title {
margin: 0 0 2px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.8);
}
.card:hover .card__bg,
.card:focus-visible .card__bg,
.card:active .card__bg,
.card.is-demo .card__bg {
transform: scale(1.12);
}
.card.is-demo .card__bg {
animation: zoom-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card.is-demo {
animation: tilt-loop 2s ease-out infinite, spot-loop 2s ease-out infinite;
}
@keyframes zoom-loop {
0%, 20% {
transform: scale(1);
}
50%, 75% {
transform: scale(1.12);
}
100% {
transform: scale(1);
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active .card__bg {
transform: scale(1.12);
}
}
@media (prefers-reduced-motion: reduce) {
.card__bg {
transition: none;
}
.card.is-demo .card__bg {
animation: none;
}
.card {
transition: none;
--rx: 0deg;
--ry: 0deg;
--sp: 0;
}
.card.is-demo {
animation: none;
}
}// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5;
var py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-py * 8) + 'deg');
card.style.setProperty('--ry', (px * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent, ReactNode } from "react";
import styles from "./BackgroundZoomCard.module.scss";
export type BackgroundZoomCardProps = {
title: string;
meta: string;
/** 배경이 다 커지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
media?: ReactNode;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BackgroundZoomCard({
title,
meta,
duration = 600,
easing = "ease-out",
color = "#fff7e6",
media,
}: BackgroundZoomCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cardRef = useRef<HTMLElement>(null);
// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
const handlePointerMove = useCallback((e: PointerEvent<HTMLElement>) => {
const card = cardRef.current;
if (!card) return;
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
card.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
card.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
card.style.setProperty("--sp", "1");
card.style.setProperty("--rx", `${-py * 8}deg`);
card.style.setProperty("--ry", `${px * 9}deg`);
}, []);
const handlePointerLeave = useCallback(() => {
const card = cardRef.current;
if (!card) return;
card.style.setProperty("--sp", "0");
card.style.setProperty("--rx", "0deg");
card.style.setProperty("--ry", "0deg");
}, []);
return (
<div className={styles.frame}>
<article
ref={cardRef}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
<span className={styles.bg} aria-hidden="true">{media}</span>
<div className={styles.overlay}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
</div>
);
}05 · 背景画像が拡大 — 計測 累積面積 7.676% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.card { overflow: hidden; }
.card__bg {
transform: scale(1);
transition: transform .5s $ease-out;
}
.card:hover .card__bg,
.card.is-demo .card__bg {
transform: scale(1.12);
}
06アイコンが弾む
どう動くか
カード右上の丸いアイコンバッジが、ホバーすると2回小さく弾んでクリックできることを伝えます。keyframesで上下にtranslateYさせるだけで、cubic-bezierの効いた動きがボールの弾む感触を作ります。
どこで使うか
ブログカードの「続きを読む」矢印やリンクカードに向いています。
<main class="stage">
<div class="frame">
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">最新情報</h3>
<p class="card__meta">続きを読む</p>
</div>
<span class="card__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</span>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 한 번 튀어오르는 시간
$easing: $ease-pop; // 통통 튀는 느낌
$color: $subject-blue; // 아이콘 배지 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.frame { perspective: 700px; } // 커서 틸트가 이 값 기준으로 기운다
.card {
position: relative;
width: min(190px, 58vw);
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
padding: 12px 38px 12px 12px;
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card::after {
content: '';
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255,255,255,.32), transparent 70%);
opacity: var(--sp);
}
.card__body { position: relative; z-index: 1; } // 스포트라이트(::after) 위로 올라오게
.card__title { margin: 0 0 4px; 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__icon {
position: absolute;
z-index: 1; // 스포트라이트(::after) 위로 올라오게
top: 12px; right: 12px;
display: grid;
place-items: center;
width: 24px; height: 24px;
border-radius: 50%;
background: $color;
color: $stage-paper;
transform: translateY(0);
}
.card:hover .card__icon,
.card:focus-visible .card__icon,
.card:active .card__icon {
animation: bounce $duration $easing 2;
}
.card.is-demo .card__icon {
animation: bounce-loop $dur-loop $easing infinite;
}
.card.is-demo { animation: tilt-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes bounce-loop {
0%, 55%, 100% { transform: translateY(0); }
20% { transform: translateY(-9px); }
32% { transform: translateY(0); }
42% { transform: translateY(-4px); }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active .card__icon { animation: bounce $duration $easing 2; }
}
@media (prefers-reduced-motion: reduce) {
.card__icon { animation: none !important; }
.card { transition: none; --rx: 0deg; --ry: 0deg; --sp: 0; }
.card.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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.frame {
perspective: 700px;
}
.card {
position: relative;
width: min(190px, 58vw);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
padding: 12px 38px 12px 12px;
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__body {
position: relative;
z-index: 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__icon {
position: absolute;
z-index: 1;
top: 12px;
right: 12px;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: #2f6df6;
color: #ffffff;
transform: translateY(0);
}
.card:hover .card__icon,
.card:focus-visible .card__icon,
.card:active .card__icon {
animation: bounce 220ms cubic-bezier(0.2, 1.4, 0.4, 1) 2;
}
.card.is-demo .card__icon {
animation: bounce-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.card.is-demo {
animation: tilt-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite, spot-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@keyframes bounce-loop {
0%, 55%, 100% {
transform: translateY(0);
}
20% {
transform: translateY(-9px);
}
32% {
transform: translateY(0);
}
42% {
transform: translateY(-4px);
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active .card__icon {
animation: bounce 220ms cubic-bezier(0.2, 1.4, 0.4, 1) 2;
}
}
@media (prefers-reduced-motion: reduce) {
.card__icon {
animation: none !important;
}
.card {
transition: none;
--rx: 0deg;
--ry: 0deg;
--sp: 0;
}
.card.is-demo {
animation: none;
}
}// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5;
var py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-py * 8) + 'deg');
card.style.setProperty('--ry', (px * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./IconBounceCard.module.scss";
export type IconBounceCardProps = {
title: string;
meta: string;
/** 한 번 튀어오르는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 아이콘 배지 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function IconBounceCard({
title,
meta,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: IconBounceCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cardRef = useRef<HTMLElement>(null);
// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
const handlePointerMove = useCallback((e: PointerEvent<HTMLElement>) => {
const card = cardRef.current;
if (!card) return;
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
card.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
card.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
card.style.setProperty("--sp", "1");
card.style.setProperty("--rx", `${-py * 8}deg`);
card.style.setProperty("--ry", `${px * 9}deg`);
}, []);
const handlePointerLeave = useCallback(() => {
const card = cardRef.current;
if (!card) return;
card.style.setProperty("--sp", "0");
card.style.setProperty("--rx", "0deg");
card.style.setProperty("--ry", "0deg");
}, []);
return (
<div className={styles.frame}>
<article
ref={cardRef}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</span>
</article>
</div>
);
}06 · アイコンが弾む — 計測 累積面積 2.056% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.card__icon { transform: translateY(0); }
.card:hover .card__icon,
.card:focus-visible .card__icon,
.card:active .card__icon {
animation: bounce .3s $ease-pop 2;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
07グラデーション枠
どう動くか
普段は薄いグレーの枠のカードが、ホバーすると2色のグラデーション枠に変わって縁取ります。mask-compositeで内側だけを切り抜いた層のopacityを0から1に変えるだけなので、レイアウトはまったく動きません。
どこで使うか
料金比較カードやおすすめを強調したいカードに向いています。

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="card__border" aria-hidden="true"></span>
<div class="card__inner">
<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: $subject-blue; // 그라데이션 시작 색
@include stage($stage-orange);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.card {
position: relative;
width: min(190px, 58vw);
border-radius: $r-card;
padding: 2px;
outline: none;
cursor: pointer;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, .32), transparent 70%);
opacity: var(--sp);
}
.card__border {
position: absolute;
inset: 0;
border-radius: $r-card;
padding: 2px;
background: linear-gradient(135deg, $color, $subject-cream);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity $duration $easing;
}
.card__inner {
position: relative;
border: 2px solid rgba($stage-ink, .12);
border-radius: $r-card - 2px;
background: $stage-paper;
padding: 14px 12px;
}
.card__title { margin: 0 0 4px; 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 .card__border,
.card:focus-visible .card__border,
.card:active .card__border,
.card.is-demo .card__border {
opacity: 1;
}
.card.is-demo .card__border { animation: glow-loop $dur-loop $easing infinite; }
.card.is-demo { animation: spot-loop $dur-loop $easing infinite, tilt-loop $dur-loop $easing infinite; }
@keyframes glow-loop {
0%, 20% { opacity: 0; }
50%, 75% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@media (hover: none) {
.card:active .card__border { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card__border { transition: none; }
.card.is-demo .card__border { animation: none; }
.card { transition: none; }
.card.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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.card {
position: relative;
width: min(190px, 58vw);
border-radius: 16px;
padding: 2px;
outline: none;
cursor: pointer;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 300ms ease-out;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__border {
position: absolute;
inset: 0;
border-radius: 16px;
padding: 2px;
background: linear-gradient(135deg, #2f6df6, #fff7e6);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 300ms ease-out;
}
.card__inner {
position: relative;
border: 2px solid rgba(23, 20, 26, 0.12);
border-radius: 14px;
background: #ffffff;
padding: 14px 12px;
}
.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 .card__border,
.card:focus-visible .card__border,
.card:active .card__border,
.card.is-demo .card__border {
opacity: 1;
}
.card.is-demo .card__border {
animation: glow-loop 2s ease-out infinite;
}
.card.is-demo {
animation: spot-loop 2s ease-out infinite, tilt-loop 2s ease-out infinite;
}
@keyframes glow-loop {
0%, 20% {
opacity: 0;
}
50%, 75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@media (hover: none) {
.card:active .card__border {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.card__border {
transition: none;
}
.card.is-demo .card__border {
animation: none;
}
.card {
transition: none;
}
.card.is-demo {
animation: none;
}
}// 커서 위치로 스포트라이트(반사광)와 미세 3D 틸트를 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
card.style.setProperty('--mx', (px * 100) + '%');
card.style.setProperty('--my', (py * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-(py - .5) * 8) + 'deg');
card.style.setProperty('--ry', ((px - .5) * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./GradientBorderCard.module.scss";
export type GradientBorderCardProps = {
title: string;
meta: string;
/** 테두리가 나타나는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 그라데이션 시작 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GradientBorderCard({
title,
meta,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: GradientBorderCardProps) {
const ref = useRef<HTMLElement>(null);
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
el.style.setProperty("--mx", `${px * 100}%`);
el.style.setProperty("--my", `${py * 100}%`);
el.style.setProperty("--sp", "1");
el.style.setProperty("--rx", `${-(py - 0.5) * 8}deg`);
el.style.setProperty("--ry", `${(px - 0.5) * 9}deg`);
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.setProperty("--sp", "0");
el.style.setProperty("--rx", "0deg");
el.style.setProperty("--ry", "0deg");
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article ref={ref} className={styles.card} style={vars} tabIndex={0} onPointerMove={move} onPointerLeave={leave}>
<span className={styles.border} aria-hidden="true" />
<div className={styles.inner}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}07 · グラデーション枠 — 計測 累積面積 2.281% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.card__border {
background: linear-gradient(135deg, $color, $subject-cream);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity .35s $ease-out;
}
.card:hover .card__border,
.card.is-demo .card__border {
opacity: 1;
}
08カーソルスポットライト
どう動くか
カーソル座標をJavaScript数行で読み取り--mxと--myというCSS変数に渡すと、radial-gradientの光がカーソルに追従してカード表面を照らします。--spという数値も@propertyで登録してあるので、光の強さがふっと現れて自然に消えます。
どこで使うか
ダークテーマのカードやインタラクティブな機能カードに向いています。

<main class="stage">
<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-quick; // 빛이 나타나는 시간
$easing: $ease-out;
$color: $subject-blue; // 빛 색
@include stage($stage-paper);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.card {
position: relative;
width: min(190px, 58vw);
border-radius: $r-card;
background: $stage-ink;
box-shadow: $shadow-raised;
padding: 16px 14px;
outline: none;
cursor: pointer;
overflow: hidden;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(140px circle at var(--mx) var(--my), rgba($color, .5), transparent 70%);
opacity: var(--sp);
transition: opacity $duration $easing;
pointer-events: none;
}
.card__title { position: relative; margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.card__meta { position: relative; margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .65); }
.card.is-demo { animation: spot-loop $dur-loop $easing infinite, tilt-loop $dur-loop $easing infinite; }
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
.card:focus-visible::before,
.card:active::before {
--mx: 50%;
--my: 50%;
opacity: 1;
}
@media (hover: none) {
.card:active::before { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card::before { transition: none; }
.card.is-demo { animation: none; }
.card { transition: 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);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.card {
position: relative;
width: min(190px, 58vw);
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 16px 14px;
outline: none;
cursor: pointer;
overflow: hidden;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 220ms ease-out;
}
.card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(140px circle at var(--mx) var(--my), rgba(47, 109, 246, 0.5), transparent 70%);
opacity: var(--sp);
transition: opacity 220ms ease-out;
pointer-events: none;
}
.card__title {
position: relative;
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.card__meta {
position: relative;
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.65);
}
.card.is-demo {
animation: spot-loop 2s ease-out infinite, tilt-loop 2s ease-out infinite;
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
.card:focus-visible::before,
.card:active::before {
--mx: 50%;
--my: 50%;
opacity: 1;
}
@media (hover: none) {
.card:active::before {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.card::before {
transition: none;
}
.card.is-demo {
animation: none;
}
.card {
transition: none;
}
}// 카드 위 커서 좌표를 CSS 변수로 넘겨 radial-gradient 가 그 지점에서 뜨게 하고, 미세한 3D 틸트도 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
card.style.setProperty('--mx', (px * 100) + '%');
card.style.setProperty('--my', (py * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-(py - .5) * 8) + 'deg');
card.style.setProperty('--ry', ((px - .5) * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpotlightCard.module.scss";
export type SpotlightCardProps = {
title: string;
meta: string;
/** 빛이 나타나는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 빛 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SpotlightCard({
title,
meta,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: SpotlightCardProps) {
const ref = useRef<HTMLElement>(null);
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
el.style.setProperty("--mx", `${px * 100}%`);
el.style.setProperty("--my", `${py * 100}%`);
el.style.setProperty("--sp", "1");
el.style.setProperty("--rx", `${-(py - 0.5) * 8}deg`);
el.style.setProperty("--ry", `${(px - 0.5) * 9}deg`);
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.setProperty("--sp", "0");
el.style.setProperty("--rx", "0deg");
el.style.setProperty("--ry", "0deg");
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article ref={ref} className={styles.card} style={vars} tabIndex={0} onPointerMove={move} onPointerLeave={leave}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</article>
);
}08 · カーソルスポットライト — 計測 累積面積 9.937% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
card.addEventListener('pointermove', (e) => {
const r = card.getBoundingClientRect();
const mx = (e.clientX - r.left) / r.width * 100;
const my = (e.clientY - r.top) / r.height * 100;
card.style.setProperty('--mx', `${mx}%`);
card.style.setProperty('--my', `${my}%`);
card.style.setProperty('--sp', 1);
});
09押した反応
どう動くか
クリック可能なカードを実際に押すとscaleが0.97まで縮み、影も一段浅くなって指先で触れた感触を伝えます。ホバーだけでは反応せず、押している間だけ動きます。
どこで使うか
選択できるカード型のラジオボタンやタッチ操作のモバイルカードに向いています。

<main class="stage">
<article class="card is-demo" tabindex="0" role="button" aria-pressed="false">
<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-instant; // 눌렸다가 돌아오는 시간
$easing: $ease-out;
$color: $subject-cream; // 카드 배경
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.card {
position: relative;
width: min(190px, 58vw);
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
padding: 14px 12px;
outline: none;
cursor: pointer;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, .32), transparent 70%);
opacity: var(--sp);
}
.card__title { margin: 0 0 4px; 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:active,
.card.is-demo {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(.97);
box-shadow: $shadow-press;
}
.card.is-demo { animation: press-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite, tilt-loop $dur-loop $easing infinite; }
@keyframes press-loop {
0%, 30% { transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1); box-shadow: $shadow-raised; }
45%, 55% { transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(.97); box-shadow: $shadow-press; }
70%, 100% { transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1); box-shadow: $shadow-raised; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@media (hover: none) {
.card:active { transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(.97); box-shadow: $shadow-press; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.card.is-demo { animation: none; box-shadow: $shadow-press; }
}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);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.card {
position: relative;
width: min(190px, 58vw);
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 14px 12px;
outline: none;
cursor: pointer;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1);
transition: transform 140ms ease-out, box-shadow 140ms ease-out;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.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:active,
.card.is-demo {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(0.97);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.card.is-demo {
animation: press-loop 2s ease-out infinite, spot-loop 2s ease-out infinite, tilt-loop 2s ease-out infinite;
}
@keyframes press-loop {
0%, 30% {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
45%, 55% {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(0.97);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
70%, 100% {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@media (hover: none) {
.card:active {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(0.97);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card.is-demo {
animation: none;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}// 커서 위치로 스포트라이트(반사광)와 미세 3D 틸트를 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
card.style.setProperty('--mx', (px * 100) + '%');
card.style.setProperty('--my', (py * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-(py - .5) * 8) + 'deg');
card.style.setProperty('--ry', ((px - .5) * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./PressFeedbackCard.module.scss";
export type PressFeedbackCardProps = {
title: string;
meta: string;
/** 눌렸다가 돌아오는 시간 (기본 140ms) */
duration?: number;
easing?: string;
color?: string;
onSelect?: () => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PressFeedbackCard({
title,
meta,
duration = 140,
easing = "ease-out",
color = "#fff7e6",
onSelect,
}: PressFeedbackCardProps) {
const ref = useRef<HTMLElement>(null);
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
el.style.setProperty("--mx", `${px * 100}%`);
el.style.setProperty("--my", `${py * 100}%`);
el.style.setProperty("--sp", "1");
el.style.setProperty("--rx", `${-(py - 0.5) * 8}deg`);
el.style.setProperty("--ry", `${(px - 0.5) * 9}deg`);
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.setProperty("--sp", "0");
el.style.setProperty("--rx", "0deg");
el.style.setProperty("--ry", "0deg");
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article
ref={ref}
className={styles.card}
style={vars}
tabIndex={0}
role="button"
aria-pressed="false"
onClick={onSelect}
onPointerMove={move}
onPointerLeave={leave}
>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</article>
);
}09 · 押した反応 — 計測 累積面積 2.287% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.card {
transform: scale(1);
transition:
transform .1s $ease-out,
box-shadow .1s $ease-out;
}
.card:active {
transform: scale(.97);
box-shadow: $shadow-press;
}
どこで崩れるか — 落とし穴
@property がないと補間できない
9つの中で02と08はどちらも@propertyでカスタムプロパティの型を登録しています。最初はこれを省いて--angleや--mx・--myをただのCSS変数として書いたところ、02の光は回転させたつもりが一コマも動かず一瞬で消え、08のスポットライトも位置がなめらかに追従せず飛び飛びに切り替わるだけでした。MDNの@propertyの解説にあるとおり、ブラウザは<angle>や<percentage>として型を教えられない限りカスタムプロパティを補間できず、アニメーションの途中の値を作れません。
Safari だけ枠が消えない
2つ目は07のグラデーション枠を組んでいるときに踏んだ落とし穴です。標準のmask-composite: excludeだけを書いてSafariで確かめると枠がいつまでも透明にならず、正方形のグラデーションがカード全体を覆ったままでした。caniuseのmask-composite対応表にあるとおりSafariは長らく接頭辞付きの-webkit-mask-compositeしか読めず、しかもキーワードはexcludeではなくxorだったため、両方を並べて書いてからSafariでも枠が正しく切り抜かれるようになりました。そもそも枠をマスクで抜かずに済ませる方向もあり、ネオブルータリズム UI キットの枠線は不透明な線を直接引くだけなので、この2つの語彙のずれに巻き込まれません。修正済みの9本のファイルを開く合言葉は45c9tna8で、下のカードから取り出せます。
なめらかすぎて測れない
3つ目は05の背景拡大を作っているときに見つけた発見です。角度だけが違うなめらかなlinear-gradientだけを背景に敷いてscale(1.12)を掛けたところ、レンダリングして測定すると24フレームすべてで隣り合うピクセルの色の差が閾値の12を超えず、累積面積は0.00%のまま「動いていないカード」と判定されました。MDNのlinear-gradient()の解説にあるとおり色がなめらかに混ざる帯は隣り合うピクセルの差が小さくなりがちで、境界のはっきりした太陽と丘の形を重ねてから同じ倍率で測り直すと、強度41.3・累積面積0.68%まで上がり正常に通過しました。
アクセシビリティ
動きを減らす設定では
9つともprefers-reduced-motion: reduceでは動きだけを止め、最後の見た目は残します。01は浮かばず影だけが濃いまま、02は光が回らず薄く固定、03は傾かず:focus-visibleで既定の角度だけを見せ、04はパネルが最初から開いたまま、05は拡大せず静止、06はバッジが跳ねず、07は枠が常にうっすら見えたまま、08は光が灯った状態で止まり、09は影だけが浅くなります。
キーボードとタッチ
:hoverだけでなく:focus-visibleと:activeにも同じ最終状態を与えているので、キーボード操作やタッチ操作でもマウス操作と同じ結果が届きます。カード以外のCSSアニメーションはカテゴリー一覧に、このサイトの成り立ちは紹介ページにまとめています。
FAQ
08のスポットライトはタッチ画面でもカーソルに追従しますか?
いいえ、pointermoveはタッチでは座標を継続して渡さないため、08は--spが既定値の0のまま止まります。代わりに:focus-visibleと:activeでカード中央に固定のスポットライトを灯し、指で押しても反応だけは残るようにしています。
css ホバー アニメーション を1つの画面に9つ並べても大丈夫ですか?
並べても構いませんが、02と08のように回り続けたり追従し続けたりする演出は一画面に1つか2つにとどめるのが無難です。残りの7つはホバー・フォーカス・プレスといったユーザーの操作に紐づいているので、何枚並べても画面がうるさくなりすぎることはありません。カードの中に切り替えスイッチを置くなら、09の押した反応とCSS トグルスイッチのdurationをそろえておくと動きがぶつかりません。
Reactの03と08はカーソル座標をどう扱っていますか?
どちらのコンポーネントもuseRefでカード要素をつかみ、useCallbackで包んだpointermoveハンドラーの中でstyle.setPropertyを使ってCSS変数へ直接書き込みます。stateを経由しないので、座標が変わるたびにコンポーネント全体が再レンダリングされることはありません。









