9 CSS Card Hover Effects — One Line, Copy-Paste
These nine css card hover effects each act on a whole card component at once, not a single button inside it, using nothing heavier than transform, opacity, and
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Lift with shadow
- 02 Spinning border light
- 03 3D tilt parallax
- 04 Content slides up
- 05 Background image zoom
- 06 Icon bounce
- 07 Gradient border reveal
- 08 Cursor spotlight
- 09 Press feedback
The order below follows how a card actually gets used: 01 and 02 give a card presence before any pointer arrives, and 03 through 05 add depth or reveal extra content once one lands. 06 through 08 nudge toward the next click, and 09 confirms that click actually registered. Watch the grid above instead of trying to click it — each thumbnail plays inside a fixed iframe that a real pointer can never reach. So every card loops through its hover state automatically, through the same is-demo class the standalone demos below rely on. Every effect also mirrors its :hover state onto :focus-visible and a matching @media (hover: none) block, so keyboard and touch visitors land on the same finished look a mouse would trigger. Two of the nine — 03 and 08 — additionally read the pointer's live position through a short JavaScript listener; the rest are simple card hover effects css handles alone once a card gets hovered, focused, or pressed. For a card that holds a conversation rather than a product, the CSS chat bubble animations set picks up where 04's slide-up stops, and item 07's gradient ring has a type-side counterpart in the CSS text shine animation demos.
01Lift with shadow
How it works
The whole card lifts 8px on hover, focus, and press alike while its box-shadow deepens from a pressed default into a raised one, sharing a single transition so neither property finishes ahead of the other.
Where it fits
It suits a grid of product or article cards, where the raised card needs to stand out from its neighbors before anyone commits to a click.

<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">Wireless Earbuds</h3>
<p class="card__meta">48,000 KRW</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 · Lift with shadow — Measured area 11.669% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
.card {
transform: translateY(0);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.card:hover,
.card:focus-visible,
.card:active {
transform: translateY(-8px);
box-shadow: $shadow-raised;
}
02Spinning border light
How it works
A conic-gradient sliver of light starts spinning along the card's outline the moment hover — or the idle grid loop — fades its wrapper's opacity in, rather than turning underneath a hidden layer the whole time.
Where it fits
The rotation itself comes from animating a registered @property angle rather than nudging a background position, so the light sweeps smoothly instead of jumping between fixed stops.

<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">Premium Plan</h3>
<p class="card__meta">Includes AI features</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 · Spinning border light — Measured area 1.778% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
@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:focus-visible .card__ring {
opacity: 1;
animation: spin $duration linear infinite;
}
@keyframes spin { to { --angle: 360deg; } }
033D tilt parallax
How the css 3d card hover effect reads the pointer
Eight lines of JavaScript turn the pointer's position inside the card into three CSS custom properties, and the card's rotateX/rotateY read those variables live. A background layer sits behind it on a fixed translateZ offset and shifts the opposite way through its own translateX value.
Where it fits
Tabbing to the card or pressing it without a mouse falls back to one fixed tilt angle instead of tracking anything, so the sense of depth still shows up without a pointer in the room.

<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">Portfolio Project</h3>
<p class="card__meta">View details</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 tilt parallax — Measured area 11.272% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
card.addEventListener('pointermove', function (e) {
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('--rx', (-py * 14) + 'deg');
card.style.setProperty('--ry', (px * 16) + 'deg');
card.style.setProperty('--pz', (px * -12) + 'px');
});
04Content slides up
How it works
A caption panel sits pushed down to 70% of its own height under the card's media. Hover, focus, or the idle loop slides it fully into view while its meta line fades in over the same span.
Where it fits
The card's own height never changes, since the panel is positioned absolutely rather than pushing the layout around it.

<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">Autumn Collection</h3>
<p class="card__meta">View now</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 · Content slides up — Measured area 11.14% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
.card__panel {
transform: translateY(70%);
transition: transform $duration $easing;
}
.card:hover .card__panel,
.card:focus-visible .card__panel {
transform: translateY(0);
}
05Background zoom
How it works
The full card background scales from 1 to 1.12 on hover or focus, and overflow: hidden on both the card and the background layer keeps the zoomed edge from ever crossing the card's rounded corners.
Where it fits
A sun shape and a hill shape sit on top of the gradient specifically so the zoom has hard edges to move against — the reason matters, and it's covered in the trap below.

<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">Jeju Sea</h3>
<p class="card__meta">Travel review</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 · Background image zoom — Measured area 7.676% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
.card { overflow: hidden; }
.card__bg {
transform: scale(1);
transition: transform $duration $easing;
}
.card:hover .card__bg,
.card:focus-visible .card__bg {
transform: scale(1.12);
}
06Icon bounce
How it works
An arrow badge in the card's corner plays a two-cycle translateY bounce the instant hover, focus, or a press begins. It uses a springy cubic-bezier easing instead of a linear one, so each bounce feels elastic rather than mechanical.
Where it fits
The grid preview runs a separate, endlessly looping version of the same keyframes so it has motion to show without a pointer ever landing on it.
<main class="stage">
<div class="frame">
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">Latest news</h3>
<p class="card__meta">Read more</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 · Icon bounce — Measured area 2.056% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
.card__icon { transform: translateY(0); }
.card:hover .card__icon,
.card:focus-visible .card__icon,
.card:active .card__icon {
animation: bounce $duration $easing 2;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
07Gradient border reveal
How it works
A plain 2px gray border sits on the card at rest. A second, absolutely positioned layer carrying a two-color linear-gradient fades in over it on hover, through two parallel mask declarations that punch out everything except a thin ring.
Where it fits
Nothing about the card's box size or padding shifts, since that gradient layer only ever toggles opacity.

<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">Recommended plan</h3>
<p class="card__meta">Most popular choice</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 · Gradient border reveal — Measured area 2.281% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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 $duration $easing;
}
.card:hover .card__border,
.card:focus-visible .card__border {
opacity: 1;
}
08Cursor spotlight
How it works
Ten lines of JavaScript write the pointer's position into two CSS custom properties on every pointermove, and a radial-gradient circle reads those variables live to trail the cursor across the card's dark surface. Because --mx, --my, and --sp are registered with @property, the browser interpolates the move itself instead of the light jumping between spots.
Where it fits
Keyboard focus centers that same spotlight in the card's middle rather than chasing a pointer that was never there.

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">Dark mode card</h3>
<p class="card__meta">Interactive card</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 · Cursor spotlight — Measured area 9.937% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
card.addEventListener('pointermove', function (e) {
var r = card.getBoundingClientRect();
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);
});
09Press feedback
How it works
The card only reacts to :active, never :hover, shrinking to scale(.97) and flattening its shadow back to the pressed variant for as long as a finger or mouse button stays down.
Where it fits
Releasing reverses both properties on the same short transition, so the card visibly springs back the instant contact ends.

<main class="stage">
<article class="card is-demo" tabindex="0" role="button" aria-pressed="false">
<div class="card__body">
<h3 class="card__title">Select card</h3>
<p class="card__meta">Tap to select</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 · Press feedback — Measured area 2.287% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
.card {
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.card:active {
transform: scale(.97);
box-shadow: $shadow-press;
}
Where it breaks — the trap
Item 07 is the one demo here built from two parallel declarations doing the same job for two different rendering engines. Keeping only one of them breaks the reveal without any warning in the console.
Two properties, two vocabularies
The masked ring depends on -webkit-mask-composite: xor for WebKit's older compositing keywords and mask-composite: exclude for the standard CSS Masking property that replaced it. Those two properties don't share a vocabulary at all. MDN's mask-composite reference lists only add, subtract, intersect, and exclude as valid values for the unprefixed property. xor is not one of them, even though it reads like the natural modern spelling of the WebKit keyword.
Why the ring disappears
Write mask-composite: xor out of habit, and a browser reading the unprefixed property treats the whole declaration as invalid, drops it outright, and never rounds it to the nearest match. So the ring stays invisible in any engine that only understands the standard property. The unprefixed property itself is younger than the prefixed one it replaces — Chrome and Edge 120+, Safari 15.4+, Firefox 53+ per caniuse. So a project still supporting older Chrome builds needs the -webkit- line kept in place beside it, not swapped out for it. A border that never has to be masked at all sidesteps the whole pairing: the neobrutalism UI kit sections draw their frames as plain opaque strokes, which is why nothing in that set depends on two vocabularies agreeing.
What's in the archive
Item 07's own folder in the archive already carries both lines correctly paired, opened with 45c9tna8 keyed in exactly as it's spelled on this line, no spaces added, no letters capitalized.
Accessibility
Two shapes under reduced motion
Accessibility here mostly means the same finished look staying reachable without a pointer, but the nine split into two shapes under reduced motion. 01, 05, 07, and 09 simply drop their transition, so hover, focus, or a press still lands on the exact same lifted, zoomed, bordered, or pressed state — just without the animated trip getting there. 04 goes one step further and pins its caption panel open at all times, so the extra description never depends on hover firing at all. 02's spinning ring and 06's bounce disappear differently. 02 freezes at a fixed, dim ring rather than either hiding completely or keeping its full spin. 06's two-cycle bounce is removed outright, since its own keyframes already return the icon to its starting position by the end.
The two the query never reaches
03's tilt and 08's spotlight are the two reading a live pointer position through JavaScript rather than a CSS transition. Reduced motion still strips their eased approach, but the values a mouse keeps writing into --rx, --ry, --mx, and --my keep updating in real time regardless. So a keyboard user gets one fixed fallback angle, while a mouse user's own movement is the one thing the media query never reaches. The setting itself is explained on MDN's prefers-reduced-motion page.
The rest of this card-hover set lives on the css hub, and what actually ships inside the download versus what only gets described here is covered on the about page.
FAQ
Can two of these css card hover effects run on the same card at once?
Yes — each effect only touches its own property or its own layer. 01 changes transform and box-shadow on the whole card, and 07 fades in a separately masked border layer, so neither competes with the other. The one pairing worth avoiding is two effects writing to the same custom property or the same transform on the same element, such as 03's tilt and 09's press. Whichever declaration comes second in the stylesheet simply overrides the first.
Do all nine of these css card hover effects need JavaScript to work?
No — only 03's tilt and 08's spotlight read a live pointer position, and both do it in eight to ten lines that write straight into CSS custom properties rather than touching layout. The other seven run entirely on :hover, :focus-visible, and :active, with no script involved at any point. Even 03 and 08 ship a CSS-only fallback: tabbing to the card without a mouse still lands on one fixed tilt angle or a centered spotlight. That fallback is written directly into the stylesheet rather than into the pointer listener. Controls placed inside a card follow the same no-script rule — the CSS toggle switches in that set flip on a checkbox state alone, so they sit inside 09's press feedback without a second listener.
Does a css product card hover effect pay more for box-shadow than for transform?
A little, yes — only 01 and 09 in this set touch box-shadow on hover or press. Unlike transform, changing a shadow forces the browser to repaint the area the shadow covers instead of just recompositing a layer. Scoped to one hovered card at a time, that repaint stays small enough not to show up as dropped frames. That's part of why the other seven effects lean on transform and opacity instead, once the motion needs to loop continuously rather than fire once per interaction.









