CSS 카드 애니메이션 호버 효과 9종 — 한 줄 복붙으로
CSS 카드 애니메이션은 카드 컴포넌트가 마우스나 포커스에 반응해 뜨거나 빛나거나 기울어지는 짧은 시각 변화를 말합니다. 카드 호버 효과 아홉 가지를 한 번에 모았고, 커서 좌표가 필요한 두 개는 JS를 10줄 안팎만 씁니다.
01 card-hover-lift-shadow
02 card-hover-border-light-spin
03 card-hover-tilt-3d-parallax
04 card-hover-content-slide-up
05 card-hover-bg-zoom
07 card-hover-gradient-border-hover
08 card-hover-spotlight-follow
09 card-hover-press-feedback
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 인기 순위가 아니라 카드 리스트 ui 에서 카드를 고르는 사람의 눈에 걸리는 순서로 놓았습니다. 먼저 존재감을 드러내고(01~02), 기울고 내용을 더 보여주고(03~05), 눌러보라고 몸짓하고(06~08), 마지막으로 실제로 눌렀을 때 답합니다(09). 아래 코드는 핵심만 옮겼고 완결본은 zip에 있습니다. 카드 대신 대화 화면을 꾸민다면 채팅 말풍선 CSS 애니메이션이, 07 그라데이션 테두리를 글자에까지 흘리고 싶다면 CSS 글자 그라데이션 반짝이는 텍스트가 이어지는 자리입니다.
01들림+그림자
마우스 호버 효과로 무엇이 달라지나
카드에 마우스를 올리면 8px 위로 떠오르며 그림자가 깊어져, 눌리기 전인데도 그리드 안에서 먼저 도드라집니다.
어디에 쓰나
상품 카드나 글 목록처럼 여러 장이 나란히 놓이는 자리에 씁니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="card__media" aria-hidden="true"></span>
<div class="card__body">
<h3 class="card__title">무선 이어폰</h3>
<p class="card__meta">48,000원</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 뜨는 시간
$easing: $ease-spring; // 탄력 있게 붙는 느낌
$color: $subject-cream; // 카드 배경
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); }
// 스포트라이트(커서 하이라이트) + 미세 3D 틸트 — 기존 lift 효과 위에 얹는 층
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.card {
position: relative;
width: min(150px, 48vw);
border-radius: $r-card;
background: $color;
box-shadow: $shadow-press;
overflow: hidden;
outline: none;
cursor: pointer;
transform: translateY(0) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing, box-shadow $duration $easing;
}
.card::after {
content: '';
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, .32), transparent 70%);
opacity: var(--sp);
}
.card__media {
display: block;
aspect-ratio: 16 / 9;
background: linear-gradient(155deg, $subject-blue 0%, $stage-ink 100%);
}
.card__body { position: relative; z-index: 1; padding: 8px 12px; }
.card__title { margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .6); }
.card:hover,
.card:focus-visible,
.card:active,
.card.is-demo {
transform: translateY(-8px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
box-shadow: $shadow-raised;
}
.card.is-demo { animation: lift-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
@keyframes lift-loop {
0%, 20% { transform: translateY(0) rotateX(0deg) rotateY(0deg); box-shadow: $shadow-press; }
50% { transform: translateY(-8px) rotateX(-4deg) rotateY(5deg); box-shadow: $shadow-raised; }
75% { transform: translateY(-8px) rotateX(3deg) rotateY(-5deg); box-shadow: $shadow-raised; }
100% { transform: translateY(0) rotateX(0deg) rotateY(0deg); box-shadow: $shadow-press; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active { transform: translateY(-8px); box-shadow: $shadow-raised; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.card.is-demo { animation: none; box-shadow: $shadow-raised; --rx: 0deg; --ry: 0deg; --sp: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.card {
position: relative;
width: min(150px, 48vw);
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
overflow: hidden;
outline: none;
cursor: pointer;
transform: translateY(0) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__media {
display: block;
aspect-ratio: 16/9;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__body {
position: relative;
z-index: 1;
padding: 8px 12px;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.card:hover,
.card:focus-visible,
.card:active,
.card.is-demo {
transform: translateY(-8px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.card.is-demo {
animation: lift-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, spot-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes lift-loop {
0%, 20% {
transform: translateY(0) rotateX(0deg) rotateY(0deg);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
50% {
transform: translateY(-8px) rotateX(-4deg) rotateY(5deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
75% {
transform: translateY(-8px) rotateX(3deg) rotateY(-5deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
100% {
transform: translateY(0) rotateX(0deg) rotateY(0deg);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active {
transform: translateY(-8px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card.is-demo {
animation: none;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
--rx: 0deg;
--ry: 0deg;
--sp: 0;
}
}// 커서 위치로 스포트라이트(--mx/--my/--sp)와 미세 3D 틸트(--rx/--ry)를 얹는다. 기존 lift 효과는 :hover 로 그대로 유지.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5, py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-py * 6) + 'deg');
card.style.setProperty('--ry', (px * 7) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent, ReactNode } from "react";
import styles from "./LiftShadowCard.module.scss";
export type LiftShadowCardProps = {
title: string;
meta: string;
/** 뜨는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 카드 배경색 */
color?: string;
media?: ReactNode;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LiftShadowCard({
title,
meta,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
media,
}: LiftShadowCardProps) {
const ref = useRef<HTMLElement>(null);
// 커서 위치로 스포트라이트(--mx/--my/--sp)와 미세 3D 틸트(--rx/--ry)를 얹는다.
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
el.style.setProperty("--sp", "1");
el.style.setProperty("--rx", `${-py * 6}deg`);
el.style.setProperty("--ry", `${px * 7}deg`);
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.setProperty("--sp", "0");
el.style.setProperty("--rx", "0deg");
el.style.setProperty("--ry", "0deg");
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article
ref={ref}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={move}
onPointerLeave={leave}
>
<span className={styles.media} aria-hidden="true">{media}</span>
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}01 · 들림+그림자 — 측정 누적면적 11.669% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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;
}
02테두리 빛 회전
어떻게 움직이나
카드 테두리 안쪽에 conic-gradient 빛 한 줄기를 두고 카드에 overflow: hidden을 걸어 가둔 채 2초마다 한 바퀴 돌립니다.
어디에 쓰나
각도는 @property로 등록한 --angle을 keyframes로 0deg에서 360deg까지 옮겨 매끄럽게 이어지게 했습니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="card__ring" aria-hidden="true"></span>
<div class="card__inner">
<h3 class="card__title">프리미엄 플랜</h3>
<p class="card__meta">AI 기능 포함</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 빛이 한 바퀴 도는 시간
$easing: linear; // 회전은 일정 속도로
$color: $subject-blue; // 빛 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
// 스포트라이트(커서 하이라이트) + 미세 3D 틸트 — 기존 테두리 회전 효과 위에 얹는 층
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.card {
position: relative;
width: min(190px, 58vw);
padding: 2px;
border-radius: $r-card;
outline: none;
cursor: pointer;
overflow: hidden; // 빛이 카드 테두리 밖으로 안 번지게, 얇은 띠만 보이게
isolation: isolate;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $dur-base $ease-spring;
}
.card::after {
content: '';
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, .32), transparent 70%);
opacity: var(--sp);
}
.card__ring {
position: absolute;
inset: -50%;
background: conic-gradient(from var(--angle), transparent 0deg, $color 40deg, transparent 100deg);
opacity: 0;
transition: opacity $dur-quick $ease-out;
z-index: -1;
}
.card__inner {
position: relative;
z-index: 1;
border-radius: $r-card - 2px;
background: $stage-ink;
padding: 16px 14px;
}
.card__title { margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $subject-cream; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($subject-cream, .7); }
.card:hover .card__ring,
.card:focus-visible .card__ring,
.card:active .card__ring,
.card.is-demo .card__ring {
opacity: 1;
animation: spin $duration $easing infinite;
}
.card.is-demo { animation: tilt-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
@keyframes spin { to { --angle: 360deg; } }
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active .card__ring { opacity: 1; animation: spin $duration $easing infinite; }
}
@media (prefers-reduced-motion: reduce) {
.card__ring { transition: none; animation: none !important; opacity: .55; }
.card { transition: none; }
.card.is-demo { animation: none; --rx: 0deg; --ry: 0deg; --sp: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.card {
position: relative;
width: min(190px, 58vw);
padding: 2px;
border-radius: 16px;
outline: none;
cursor: pointer;
overflow: hidden;
isolation: isolate;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__ring {
position: absolute;
inset: -50%;
background: conic-gradient(from var(--angle), transparent 0deg, #2f6df6 40deg, transparent 100deg);
opacity: 0;
transition: opacity 220ms ease-out;
z-index: -1;
}
.card__inner {
position: relative;
z-index: 1;
border-radius: 14px;
background: #17141a;
padding: 16px 14px;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 247, 230, 0.7);
}
.card:hover .card__ring,
.card:focus-visible .card__ring,
.card:active .card__ring,
.card.is-demo .card__ring {
opacity: 1;
animation: spin 2s linear infinite;
}
.card.is-demo {
animation: tilt-loop 2s linear infinite, spot-loop 2s linear infinite;
}
@keyframes spin {
to {
--angle: 360deg;
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active .card__ring {
opacity: 1;
animation: spin 2s linear infinite;
}
}
@media (prefers-reduced-motion: reduce) {
.card__ring {
transition: none;
animation: none !important;
opacity: 0.55;
}
.card {
transition: none;
}
.card.is-demo {
animation: none;
--rx: 0deg;
--ry: 0deg;
--sp: 0;
}
}// 커서 위치로 스포트라이트(--mx/--my/--sp)와 미세 3D 틸트(--rx/--ry)를 얹는다. 기존 테두리 회전 효과는 :hover 로 그대로 유지.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5, py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-py * 6) + 'deg');
card.style.setProperty('--ry', (px * 7) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BorderLightSpinCard.module.scss";
export type BorderLightSpinCardProps = {
title: string;
meta: string;
/** 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 빛 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BorderLightSpinCard({
title,
meta,
duration = 2000,
easing = "linear",
color = "#2f6df6",
}: BorderLightSpinCardProps) {
const ref = useRef<HTMLElement>(null);
// 커서 위치로 스포트라이트(--mx/--my/--sp)와 미세 3D 틸트(--rx/--ry)를 얹는다.
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
el.style.setProperty("--sp", "1");
el.style.setProperty("--rx", `${-py * 6}deg`);
el.style.setProperty("--ry", `${px * 7}deg`);
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.setProperty("--sp", "0");
el.style.setProperty("--rx", "0deg");
el.style.setProperty("--ry", "0deg");
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article
ref={ref}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={move}
onPointerLeave={leave}
>
<span className={styles.ring} aria-hidden="true" />
<div className={styles.inner}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}02 · 테두리 빛 회전 — 측정 누적면적 1.778% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.card__ring {
background: conic-gradient(from var(--angle), transparent 0deg, $color 40deg, transparent 100deg);
opacity: 0;
}
.card:hover .card__ring,
.card.is-demo .card__ring {
opacity: 1;
animation: spin $duration linear infinite;
}
@keyframes spin { to { --angle: 360deg; } }
033D 틸트 시차
어떻게 움직이나
커서가 카드 위 어디 있는지에 따라 rotateX·rotateY로 기울고, 배경 레이어는 반대 방향으로 살짝 밀려 층이 갈라진 것처럼 보입니다.
어디에 쓰나
좌표 계산은 JS 여덟 줄이 맡고 CSS는 그 값을 변수로만 받습니다.

<main class="stage">
<div class="frame">
<article class="card is-demo" tabindex="0">
<span class="card__layer card__layer--back" aria-hidden="true"></span>
<div class="card__layer card__layer--front">
<h3 class="card__title">포트폴리오 프로젝트</h3>
<p class="card__meta">자세히 보기</p>
</div>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 기울기가 붙는 시간
$easing: $ease-spring; // 탄력 있게
$color: $subject-cream; // 카드 배경
@include stage($stage-orange);
.stage { padding: clamp(6px, 2.4vw, 16px); }
// 스포트라이트(커서 하이라이트) — 기존 틸트 효과는 그대로, 이 층만 얹는다
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
.frame { perspective: 700px; } // 자식 카드가 이 값 기준으로 기운다
.card {
position: relative;
width: min(190px, 58vw);
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
outline: none;
cursor: pointer;
overflow: hidden;
transform-style: preserve-3d;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card__layer--back {
position: absolute;
inset: -12%;
background: linear-gradient(155deg, $subject-blue-deep 0%, $stage-ink 100%);
transform: translateZ(-20px) translateX(var(--pz, 0px));
transition: transform $duration $easing;
}
.card__layer--front {
position: relative;
padding: 36px 14px 14px;
transform: translateZ(20px);
}
.card__layer--front::after {
content: '';
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, .32), transparent 70%);
opacity: var(--sp);
}
.card__title { position: relative; z-index: 1; margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $subject-cream; }
.card__meta { position: relative; z-index: 1; margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($subject-cream, .72); }
.card:focus-visible,
.card:active {
transform: rotateX(-8deg) rotateY(12deg);
}
.card:focus-visible .card__layer--back,
.card:active .card__layer--back {
transform: translateZ(-20px) translateX(10px);
}
.card.is-demo { animation: tilt-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
.card.is-demo .card__layer--back { animation: tilt-loop-bg $dur-loop $easing infinite; }
@keyframes tilt-loop {
0%, 15% { transform: rotateX(0) rotateY(0); }
40% { transform: rotateX(-8deg) rotateY(12deg); }
65% { transform: rotateX(6deg) rotateY(-10deg); }
100% { transform: rotateX(0) rotateY(0); }
}
@keyframes tilt-loop-bg {
0%, 15% { transform: translateZ(-20px) translateX(0); }
40% { transform: translateZ(-20px) translateX(10px); }
65% { transform: translateZ(-20px) translateX(-8px); }
100% { transform: translateZ(-20px) translateX(0); }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active { transform: rotateX(-8deg) rotateY(12deg); }
.card:active .card__layer--back { transform: translateZ(-20px) translateX(10px); }
}
@media (prefers-reduced-motion: reduce) {
.card, .card__layer--back { transition: none; }
.card.is-demo, .card.is-demo .card__layer--back { animation: none; }
.card.is-demo { --sp: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.frame {
perspective: 700px;
}
.card {
position: relative;
width: min(190px, 58vw);
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
overflow: hidden;
transform-style: preserve-3d;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card__layer--back {
position: absolute;
inset: -12%;
background: linear-gradient(155deg, #0a4fe8 0%, #17141a 100%);
transform: translateZ(-20px) translateX(var(--pz, 0px));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card__layer--front {
position: relative;
padding: 36px 14px 14px;
transform: translateZ(20px);
}
.card__layer--front::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__title {
position: relative;
z-index: 1;
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
}
.card__meta {
position: relative;
z-index: 1;
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 247, 230, 0.72);
}
.card:focus-visible,
.card:active {
transform: rotateX(-8deg) rotateY(12deg);
}
.card:focus-visible .card__layer--back,
.card:active .card__layer--back {
transform: translateZ(-20px) translateX(10px);
}
.card.is-demo {
animation: tilt-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, spot-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card.is-demo .card__layer--back {
animation: tilt-loop-bg 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tilt-loop {
0%, 15% {
transform: rotateX(0) rotateY(0);
}
40% {
transform: rotateX(-8deg) rotateY(12deg);
}
65% {
transform: rotateX(6deg) rotateY(-10deg);
}
100% {
transform: rotateX(0) rotateY(0);
}
}
@keyframes tilt-loop-bg {
0%, 15% {
transform: translateZ(-20px) translateX(0);
}
40% {
transform: translateZ(-20px) translateX(10px);
}
65% {
transform: translateZ(-20px) translateX(-8px);
}
100% {
transform: translateZ(-20px) translateX(0);
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active {
transform: rotateX(-8deg) rotateY(12deg);
}
.card:active .card__layer--back {
transform: translateZ(-20px) translateX(10px);
}
}
@media (prefers-reduced-motion: reduce) {
.card, .card__layer--back {
transition: none;
}
.card.is-demo, .card.is-demo .card__layer--back {
animation: none;
}
.card.is-demo {
--sp: 0;
}
}// 카드 위 커서 위치로 기울기를 만든다. 배경 레이어가 반대로 움직여 층이 갈린다(시차).
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect(), px = (e.clientX - r.left) / r.width - .5, py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--rx', (-py * 14) + 'deg');
card.style.setProperty('--ry', (px * 16) + 'deg');
card.style.setProperty('--pz', (px * -12) + 'px');
// 스포트라이트(커서 하이라이트) — 틸트는 그대로, 이 층만 얹는다
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
});
card.addEventListener('pointerleave', function () { card.style.cssText = ''; });import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./TiltParallaxCard.module.scss";
export type TiltParallaxCardProps = {
title: string;
meta: string;
/** 기울기가 붙는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TiltParallaxCard({
title,
meta,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: TiltParallaxCardProps) {
const ref = useRef<HTMLElement>(null);
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
el.style.setProperty("--rx", `${-py * 14}deg`);
el.style.setProperty("--ry", `${px * 16}deg`);
el.style.setProperty("--pz", `${px * -12}px`);
// 스포트라이트(커서 하이라이트) — 틸트는 그대로, 이 층만 얹는다
el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
el.style.setProperty("--sp", "1");
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (el) {
el.style.removeProperty("--rx"); el.style.removeProperty("--ry"); el.style.removeProperty("--pz");
el.style.removeProperty("--mx"); el.style.removeProperty("--my"); el.style.removeProperty("--sp");
}
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.frame}>
<article
ref={ref}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={move}
onPointerLeave={leave}
>
<span className={styles.back} aria-hidden="true" />
<div className={styles.front}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
</div>
);
}03 · 3D 틸트 시차 — 측정 누적면적 11.272% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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');
});
04내용 슬라이드업
어떻게 움직이나
이미지 아래 숨어 있던 안내 패널이 호버하면 이미지 위로 슬라이드업해, 카드 크기는 그대로 둔 채 정보만 하나 더 보여줍니다.

<main class="stage">
<div class="frame">
<article class="card is-demo" tabindex="0">
<span class="card__media" aria-hidden="true"></span>
<div class="card__panel">
<h3 class="card__title">가을 컬렉션</h3>
<p class="card__meta">지금 보기</p>
</div>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 패널이 올라오는 시간
$easing: $ease-spring;
$color: $subject-cream; // 패널 배경
@include stage($stage-paper);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.frame { perspective: 700px; } // 커서 틸트가 이 값 기준으로 기운다
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card::after {
content: '';
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255,255,255,.32), transparent 70%);
opacity: var(--sp);
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, $subject-blue 0%, $stage-ink 100%);
}
.card__panel {
position: absolute;
z-index: 1; // 스포트라이트(::after) 위로 올라오게
left: 0; right: 0; bottom: 0;
padding: 10px 12px;
background: $color;
border-radius: $r-card $r-card 0 0;
transform: translateY(70%);
transition: transform $duration $easing;
}
.card__title { margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta {
margin: 0;
font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit;
color: rgba($stage-ink, .6);
opacity: 0;
transition: opacity $duration $easing;
}
.card:hover .card__panel,
.card:focus-visible .card__panel,
.card:active .card__panel,
.card.is-demo .card__panel {
transform: translateY(0);
}
.card:hover .card__meta,
.card:focus-visible .card__meta,
.card:active .card__meta,
.card.is-demo .card__meta {
opacity: 1;
}
.card.is-demo .card__panel { animation: slide-loop $dur-loop $easing infinite; }
.card.is-demo .card__meta { animation: fade-loop $dur-loop $easing infinite; }
.card.is-demo { animation: tilt-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
@keyframes slide-loop {
0%, 20% { transform: translateY(70%); }
50%, 75% { transform: translateY(0); }
100% { transform: translateY(70%); }
}
@keyframes fade-loop {
0%, 20% { opacity: 0; }
50%, 75% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active .card__panel { transform: translateY(0); }
.card:active .card__meta { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card__panel, .card__meta { transition: none; }
.card.is-demo .card__panel, .card.is-demo .card__meta { animation: none; }
.card__panel { transform: translateY(0); }
.card__meta { opacity: 1; }
.card { transition: none; --rx: 0deg; --ry: 0deg; --sp: 0; }
.card.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.frame {
perspective: 700px;
}
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__panel {
position: absolute;
z-index: 1;
left: 0;
right: 0;
bottom: 0;
padding: 10px 12px;
background: #fff7e6;
border-radius: 16px 16px 0 0;
transform: translateY(70%);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
opacity: 0;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card:hover .card__panel,
.card:focus-visible .card__panel,
.card:active .card__panel,
.card.is-demo .card__panel {
transform: translateY(0);
}
.card:hover .card__meta,
.card:focus-visible .card__meta,
.card:active .card__meta,
.card.is-demo .card__meta {
opacity: 1;
}
.card.is-demo .card__panel {
animation: slide-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card.is-demo .card__meta {
animation: fade-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card.is-demo {
animation: tilt-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, spot-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes slide-loop {
0%, 20% {
transform: translateY(70%);
}
50%, 75% {
transform: translateY(0);
}
100% {
transform: translateY(70%);
}
}
@keyframes fade-loop {
0%, 20% {
opacity: 0;
}
50%, 75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active .card__panel {
transform: translateY(0);
}
.card:active .card__meta {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.card__panel, .card__meta {
transition: none;
}
.card.is-demo .card__panel, .card.is-demo .card__meta {
animation: none;
}
.card__panel {
transform: translateY(0);
}
.card__meta {
opacity: 1;
}
.card {
transition: none;
--rx: 0deg;
--ry: 0deg;
--sp: 0;
}
.card.is-demo {
animation: none;
}
}// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5;
var py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-py * 8) + 'deg');
card.style.setProperty('--ry', (px * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent, ReactNode } from "react";
import styles from "./ContentSlideUpCard.module.scss";
export type ContentSlideUpCardProps = {
title: string;
meta: string;
/** 패널이 올라오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
media?: ReactNode;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ContentSlideUpCard({
title,
meta,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
media,
}: ContentSlideUpCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cardRef = useRef<HTMLElement>(null);
// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
const handlePointerMove = useCallback((e: PointerEvent<HTMLElement>) => {
const card = cardRef.current;
if (!card) return;
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
card.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
card.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
card.style.setProperty("--sp", "1");
card.style.setProperty("--rx", `${-py * 8}deg`);
card.style.setProperty("--ry", `${px * 9}deg`);
}, []);
const handlePointerLeave = useCallback(() => {
const card = cardRef.current;
if (!card) return;
card.style.setProperty("--sp", "0");
card.style.setProperty("--rx", "0deg");
card.style.setProperty("--ry", "0deg");
}, []);
return (
<div className={styles.frame}>
<article
ref={cardRef}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
<span className={styles.media} aria-hidden="true">{media}</span>
<div className={styles.panel}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
</div>
);
}04 · 내용 슬라이드업 — 측정 누적면적 11.14% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.card__panel {
transform: translateY(70%);
transition: transform $duration $easing;
}
.card:hover .card__panel,
.card.is-demo .card__panel {
transform: translateY(0);
}
05배경 이미지 확대
어떻게 움직이나
카드를 덮은 배경 전체가 1.12배 확대되고, overflow: hidden이 카드의 둥근 모서리 밖을 잘라냅니다.
어디에 쓰나
매끈한 그러데이션만으로는 확대가 잘 안 보여서 해와 언덕 모양의 도형을 더했습니다.

<main class="stage">
<div class="frame">
<article class="card is-demo" tabindex="0">
<span class="card__bg" aria-hidden="true">
<span class="card__sun"></span>
<span class="card__hill"></span>
</span>
<div class="card__overlay">
<h3 class="card__title">제주 바다</h3>
<p class="card__meta">여행 후기</p>
</div>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 배경이 다 커지는 시간
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.frame { perspective: 700px; } // 커서 틸트가 이 값 기준으로 기운다
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden; // 확대된 배경이 둥근 모서리 밖으로 안 나가게
box-shadow: $shadow-raised;
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card::after {
content: '';
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255,255,255,.32), transparent 70%);
opacity: var(--sp);
}
.card__bg {
position: absolute;
inset: 0;
overflow: hidden;
background: linear-gradient(155deg, $subject-blue 0%, $color 100%);
transform: scale(1);
transition: transform $duration $easing;
}
.card__sun {
position: absolute;
top: 14%;
right: 16%;
width: 26%;
aspect-ratio: 1;
border-radius: 50%;
background: $stage-yellow;
}
.card__hill {
position: absolute;
left: -10%;
right: -10%;
bottom: -12%;
height: 46%;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: $stage-ink;
opacity: .85;
}
.card__overlay {
position: absolute;
z-index: 1; // 스포트라이트(::after) 위로 올라오게
left: 0; right: 0; bottom: 0;
padding: 8px 12px;
background: linear-gradient(to top, rgba($stage-ink, .78), transparent);
}
.card__title { margin: 0 0 2px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .8); }
.card:hover .card__bg,
.card:focus-visible .card__bg,
.card:active .card__bg,
.card.is-demo .card__bg {
transform: scale(1.12);
}
.card.is-demo .card__bg { animation: zoom-loop $dur-loop $ease-spring infinite; }
.card.is-demo { animation: tilt-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
@keyframes zoom-loop {
0%, 20% { transform: scale(1); }
50%, 75% { transform: scale(1.12); }
100% { transform: scale(1); }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active .card__bg { transform: scale(1.12); }
}
@media (prefers-reduced-motion: reduce) {
.card__bg { transition: none; }
.card.is-demo .card__bg { animation: none; }
.card { transition: none; --rx: 0deg; --ry: 0deg; --sp: 0; }
.card.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.frame {
perspective: 700px;
}
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 600ms ease-out;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__bg {
position: absolute;
inset: 0;
overflow: hidden;
background: linear-gradient(155deg, #2f6df6 0%, #fff7e6 100%);
transform: scale(1);
transition: transform 600ms ease-out;
}
.card__sun {
position: absolute;
top: 14%;
right: 16%;
width: 26%;
aspect-ratio: 1;
border-radius: 50%;
background: #ffd23f;
}
.card__hill {
position: absolute;
left: -10%;
right: -10%;
bottom: -12%;
height: 46%;
border-radius: 50% 50% 0 0/100% 100% 0 0;
background: #17141a;
opacity: 0.85;
}
.card__overlay {
position: absolute;
z-index: 1;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px;
background: linear-gradient(to top, rgba(23, 20, 26, 0.78), transparent);
}
.card__title {
margin: 0 0 2px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.8);
}
.card:hover .card__bg,
.card:focus-visible .card__bg,
.card:active .card__bg,
.card.is-demo .card__bg {
transform: scale(1.12);
}
.card.is-demo .card__bg {
animation: zoom-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card.is-demo {
animation: tilt-loop 2s ease-out infinite, spot-loop 2s ease-out infinite;
}
@keyframes zoom-loop {
0%, 20% {
transform: scale(1);
}
50%, 75% {
transform: scale(1.12);
}
100% {
transform: scale(1);
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active .card__bg {
transform: scale(1.12);
}
}
@media (prefers-reduced-motion: reduce) {
.card__bg {
transition: none;
}
.card.is-demo .card__bg {
animation: none;
}
.card {
transition: none;
--rx: 0deg;
--ry: 0deg;
--sp: 0;
}
.card.is-demo {
animation: none;
}
}// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5;
var py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-py * 8) + 'deg');
card.style.setProperty('--ry', (px * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent, ReactNode } from "react";
import styles from "./BackgroundZoomCard.module.scss";
export type BackgroundZoomCardProps = {
title: string;
meta: string;
/** 배경이 다 커지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
media?: ReactNode;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BackgroundZoomCard({
title,
meta,
duration = 600,
easing = "ease-out",
color = "#fff7e6",
media,
}: BackgroundZoomCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cardRef = useRef<HTMLElement>(null);
// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
const handlePointerMove = useCallback((e: PointerEvent<HTMLElement>) => {
const card = cardRef.current;
if (!card) return;
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
card.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
card.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
card.style.setProperty("--sp", "1");
card.style.setProperty("--rx", `${-py * 8}deg`);
card.style.setProperty("--ry", `${px * 9}deg`);
}, []);
const handlePointerLeave = useCallback(() => {
const card = cardRef.current;
if (!card) return;
card.style.setProperty("--sp", "0");
card.style.setProperty("--rx", "0deg");
card.style.setProperty("--ry", "0deg");
}, []);
return (
<div className={styles.frame}>
<article
ref={cardRef}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
<span className={styles.bg} aria-hidden="true">{media}</span>
<div className={styles.overlay}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
</div>
);
}05 · 배경 이미지 확대 — 측정 누적면적 7.676% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.card { overflow: hidden; }
.card__bg {
transform: scale(1);
transition: transform $duration $easing;
}
.card:hover .card__bg,
.card.is-demo .card__bg {
transform: scale(1.12);
}
06아이콘 튀어오름
어떻게 움직이나
카드 모서리의 원형 화살표 배지가 호버하면 공처럼 두 번 튀어 올라, 눌러도 된다는 걸 몸짓으로 먼저 보여줍니다.
<main class="stage">
<div class="frame">
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">최신 소식</h3>
<p class="card__meta">더 읽기</p>
</div>
<span class="card__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</span>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 한 번 튀어오르는 시간
$easing: $ease-pop; // 통통 튀는 느낌
$color: $subject-blue; // 아이콘 배지 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.frame { perspective: 700px; } // 커서 틸트가 이 값 기준으로 기운다
.card {
position: relative;
width: min(190px, 58vw);
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
padding: 12px 38px 12px 12px;
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card::after {
content: '';
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255,255,255,.32), transparent 70%);
opacity: var(--sp);
}
.card__body { position: relative; z-index: 1; } // 스포트라이트(::after) 위로 올라오게
.card__title { margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .6); }
.card__icon {
position: absolute;
z-index: 1; // 스포트라이트(::after) 위로 올라오게
top: 12px; right: 12px;
display: grid;
place-items: center;
width: 24px; height: 24px;
border-radius: 50%;
background: $color;
color: $stage-paper;
transform: translateY(0);
}
.card:hover .card__icon,
.card:focus-visible .card__icon,
.card:active .card__icon {
animation: bounce $duration $easing 2;
}
.card.is-demo .card__icon {
animation: bounce-loop $dur-loop $easing infinite;
}
.card.is-demo { animation: tilt-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite; }
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes bounce-loop {
0%, 55%, 100% { transform: translateY(0); }
20% { transform: translateY(-9px); }
32% { transform: translateY(0); }
42% { transform: translateY(-4px); }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@media (hover: none) {
.card:active .card__icon { animation: bounce $duration $easing 2; }
}
@media (prefers-reduced-motion: reduce) {
.card__icon { animation: none !important; }
.card { transition: none; --rx: 0deg; --ry: 0deg; --sp: 0; }
.card.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.frame {
perspective: 700px;
}
.card {
position: relative;
width: min(190px, 58vw);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
padding: 12px 38px 12px 12px;
outline: none;
cursor: pointer;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__body {
position: relative;
z-index: 1;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.card__icon {
position: absolute;
z-index: 1;
top: 12px;
right: 12px;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: #2f6df6;
color: #ffffff;
transform: translateY(0);
}
.card:hover .card__icon,
.card:focus-visible .card__icon,
.card:active .card__icon {
animation: bounce 220ms cubic-bezier(0.2, 1.4, 0.4, 1) 2;
}
.card.is-demo .card__icon {
animation: bounce-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.card.is-demo {
animation: tilt-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite, spot-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@keyframes bounce-loop {
0%, 55%, 100% {
transform: translateY(0);
}
20% {
transform: translateY(-9px);
}
32% {
transform: translateY(0);
}
42% {
transform: translateY(-4px);
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@media (hover: none) {
.card:active .card__icon {
animation: bounce 220ms cubic-bezier(0.2, 1.4, 0.4, 1) 2;
}
}
@media (prefers-reduced-motion: reduce) {
.card__icon {
animation: none !important;
}
.card {
transition: none;
--rx: 0deg;
--ry: 0deg;
--sp: 0;
}
.card.is-demo {
animation: none;
}
}// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5;
var py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-py * 8) + 'deg');
card.style.setProperty('--ry', (px * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./IconBounceCard.module.scss";
export type IconBounceCardProps = {
title: string;
meta: string;
/** 한 번 튀어오르는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 아이콘 배지 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function IconBounceCard({
title,
meta,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: IconBounceCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cardRef = useRef<HTMLElement>(null);
// 커서 위치로 스포트라이트(--mx/--my/--sp) 와 미세 3D 틸트(--rx/--ry) 를 함께 만든다.
const handlePointerMove = useCallback((e: PointerEvent<HTMLElement>) => {
const card = cardRef.current;
if (!card) return;
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
card.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
card.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
card.style.setProperty("--sp", "1");
card.style.setProperty("--rx", `${-py * 8}deg`);
card.style.setProperty("--ry", `${px * 9}deg`);
}, []);
const handlePointerLeave = useCallback(() => {
const card = cardRef.current;
if (!card) return;
card.style.setProperty("--sp", "0");
card.style.setProperty("--rx", "0deg");
card.style.setProperty("--ry", "0deg");
}, []);
return (
<div className={styles.frame}>
<article
ref={cardRef}
className={styles.card}
style={vars}
tabIndex={0}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</span>
</article>
</div>
);
}06 · 아이콘 튀어오름 — 측정 누적면적 2.056% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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); }
}
07그라데이션 테두리
어떻게 움직이나
평소엔 얇은 회색 테두리인 카드가 호버하면 두 색 그라데이션 테두리로 바뀝니다.
어디에 쓰나
mask-composite로 안쪽만 파낸 층의 opacity만 0에서 1로 바꿔, 레이아웃은 전혀 흔들리지 않습니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="card__border" aria-hidden="true"></span>
<div class="card__inner">
<h3 class="card__title">추천 요금제</h3>
<p class="card__meta">가장 인기 있는 선택</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 테두리가 나타나는 시간
$easing: $ease-out;
$color: $subject-blue; // 그라데이션 시작 색
@include stage($stage-orange);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.card {
position: relative;
width: min(190px, 58vw);
border-radius: $r-card;
padding: 2px;
outline: none;
cursor: pointer;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, .32), transparent 70%);
opacity: var(--sp);
}
.card__border {
position: absolute;
inset: 0;
border-radius: $r-card;
padding: 2px;
background: linear-gradient(135deg, $color, $subject-cream);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity $duration $easing;
}
.card__inner {
position: relative;
border: 2px solid rgba($stage-ink, .12);
border-radius: $r-card - 2px;
background: $stage-paper;
padding: 14px 12px;
}
.card__title { margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .6); }
.card:hover .card__border,
.card:focus-visible .card__border,
.card:active .card__border,
.card.is-demo .card__border {
opacity: 1;
}
.card.is-demo .card__border { animation: glow-loop $dur-loop $easing infinite; }
.card.is-demo { animation: spot-loop $dur-loop $easing infinite, tilt-loop $dur-loop $easing infinite; }
@keyframes glow-loop {
0%, 20% { opacity: 0; }
50%, 75% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@media (hover: none) {
.card:active .card__border { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card__border { transition: none; }
.card.is-demo .card__border { animation: none; }
.card { transition: none; }
.card.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.card {
position: relative;
width: min(190px, 58vw);
border-radius: 16px;
padding: 2px;
outline: none;
cursor: pointer;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 300ms ease-out;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__border {
position: absolute;
inset: 0;
border-radius: 16px;
padding: 2px;
background: linear-gradient(135deg, #2f6df6, #fff7e6);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 300ms ease-out;
}
.card__inner {
position: relative;
border: 2px solid rgba(23, 20, 26, 0.12);
border-radius: 14px;
background: #ffffff;
padding: 14px 12px;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.card:hover .card__border,
.card:focus-visible .card__border,
.card:active .card__border,
.card.is-demo .card__border {
opacity: 1;
}
.card.is-demo .card__border {
animation: glow-loop 2s ease-out infinite;
}
.card.is-demo {
animation: spot-loop 2s ease-out infinite, tilt-loop 2s ease-out infinite;
}
@keyframes glow-loop {
0%, 20% {
opacity: 0;
}
50%, 75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@media (hover: none) {
.card:active .card__border {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.card__border {
transition: none;
}
.card.is-demo .card__border {
animation: none;
}
.card {
transition: none;
}
.card.is-demo {
animation: none;
}
}// 커서 위치로 스포트라이트(반사광)와 미세 3D 틸트를 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
card.style.setProperty('--mx', (px * 100) + '%');
card.style.setProperty('--my', (py * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-(py - .5) * 8) + 'deg');
card.style.setProperty('--ry', ((px - .5) * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./GradientBorderCard.module.scss";
export type GradientBorderCardProps = {
title: string;
meta: string;
/** 테두리가 나타나는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 그라데이션 시작 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GradientBorderCard({
title,
meta,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: GradientBorderCardProps) {
const ref = useRef<HTMLElement>(null);
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
el.style.setProperty("--mx", `${px * 100}%`);
el.style.setProperty("--my", `${py * 100}%`);
el.style.setProperty("--sp", "1");
el.style.setProperty("--rx", `${-(py - 0.5) * 8}deg`);
el.style.setProperty("--ry", `${(px - 0.5) * 9}deg`);
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.setProperty("--sp", "0");
el.style.setProperty("--rx", "0deg");
el.style.setProperty("--ry", "0deg");
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article ref={ref} className={styles.card} style={vars} tabIndex={0} onPointerMove={move} onPointerLeave={leave}>
<span className={styles.border} aria-hidden="true" />
<div className={styles.inner}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}07 · 그라데이션 테두리 — 측정 누적면적 2.281% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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.is-demo .card__border {
opacity: 1;
}
08커서 스포트라이트
어떻게 움직이나
커서 좌표를 CSS 변수 두 개로 넘기면 radial-gradient 빛이 그 지점에서 카드 표면을 비춥니다.
어디에 쓰나
--mx --my --sp 세 값을 @property로 등록해 스포트라이트가 순간 이동 없이 미끄러지듯 움직이게 했습니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">다크 모드 카드</h3>
<p class="card__meta">인터랙티브 카드</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 빛이 나타나는 시간
$easing: $ease-out;
$color: $subject-blue; // 빛 색
@include stage($stage-paper);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.card {
position: relative;
width: min(190px, 58vw);
border-radius: $r-card;
background: $stage-ink;
box-shadow: $shadow-raised;
padding: 16px 14px;
outline: none;
cursor: pointer;
overflow: hidden;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform $duration $easing;
}
.card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(140px circle at var(--mx) var(--my), rgba($color, .5), transparent 70%);
opacity: var(--sp);
transition: opacity $duration $easing;
pointer-events: none;
}
.card__title { position: relative; margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.card__meta { position: relative; margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .65); }
.card.is-demo { animation: spot-loop $dur-loop $easing infinite, tilt-loop $dur-loop $easing infinite; }
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
.card:focus-visible::before,
.card:active::before {
--mx: 50%;
--my: 50%;
opacity: 1;
}
@media (hover: none) {
.card:active::before { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card::before { transition: none; }
.card.is-demo { animation: none; }
.card { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.card {
position: relative;
width: min(190px, 58vw);
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 16px 14px;
outline: none;
cursor: pointer;
overflow: hidden;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 220ms ease-out;
}
.card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(140px circle at var(--mx) var(--my), rgba(47, 109, 246, 0.5), transparent 70%);
opacity: var(--sp);
transition: opacity 220ms ease-out;
pointer-events: none;
}
.card__title {
position: relative;
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.card__meta {
position: relative;
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.65);
}
.card.is-demo {
animation: spot-loop 2s ease-out infinite, tilt-loop 2s ease-out infinite;
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
.card:focus-visible::before,
.card:active::before {
--mx: 50%;
--my: 50%;
opacity: 1;
}
@media (hover: none) {
.card:active::before {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.card::before {
transition: none;
}
.card.is-demo {
animation: none;
}
.card {
transition: none;
}
}// 카드 위 커서 좌표를 CSS 변수로 넘겨 radial-gradient 가 그 지점에서 뜨게 하고, 미세한 3D 틸트도 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
card.style.setProperty('--mx', (px * 100) + '%');
card.style.setProperty('--my', (py * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-(py - .5) * 8) + 'deg');
card.style.setProperty('--ry', ((px - .5) * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpotlightCard.module.scss";
export type SpotlightCardProps = {
title: string;
meta: string;
/** 빛이 나타나는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 빛 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SpotlightCard({
title,
meta,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: SpotlightCardProps) {
const ref = useRef<HTMLElement>(null);
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
el.style.setProperty("--mx", `${px * 100}%`);
el.style.setProperty("--my", `${py * 100}%`);
el.style.setProperty("--sp", "1");
el.style.setProperty("--rx", `${-(py - 0.5) * 8}deg`);
el.style.setProperty("--ry", `${(px - 0.5) * 9}deg`);
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.setProperty("--sp", "0");
el.style.setProperty("--rx", "0deg");
el.style.setProperty("--ry", "0deg");
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article ref={ref} className={styles.card} style={vars} tabIndex={0} onPointerMove={move} onPointerLeave={leave}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</article>
);
}08 · 커서 스포트라이트 — 측정 누적면적 9.937% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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);
});
09눌림 피드백
어떻게 움직이나
카드를 실제로 누르면 scale(1)이 scale(.97)로 살짝 줄고 그림자도 얕아져, 손끝에 닿았다는 확인을 줍니다.
어디에 쓰나
호버로는 반응하지 않고 누르는 동안에만 움직입니다.

<main class="stage">
<article class="card is-demo" tabindex="0" role="button" aria-pressed="false">
<div class="card__body">
<h3 class="card__title">카드 선택하기</h3>
<p class="card__meta">탭하면 선택됩니다</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant; // 눌렸다가 돌아오는 시간
$easing: $ease-out;
$color: $subject-cream; // 카드 배경
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); }
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --sp { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --rx { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --ry { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
.card {
position: relative;
width: min(190px, 58vw);
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
padding: 14px 12px;
outline: none;
cursor: pointer;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, .32), transparent 70%);
opacity: var(--sp);
}
.card__title { margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .6); }
.card:active,
.card.is-demo {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(.97);
box-shadow: $shadow-press;
}
.card.is-demo { animation: press-loop $dur-loop $easing infinite, spot-loop $dur-loop $easing infinite, tilt-loop $dur-loop $easing infinite; }
@keyframes press-loop {
0%, 30% { transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1); box-shadow: $shadow-raised; }
45%, 55% { transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(.97); box-shadow: $shadow-press; }
70%, 100% { transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1); box-shadow: $shadow-raised; }
}
@keyframes spot-loop {
0%, 15% { --sp: 0; --mx: 15%; --my: 20%; }
30% { --sp: 1; }
50% { --mx: 85%; --my: 30%; }
70% { --mx: 50%; --my: 80%; }
85%, 100% { --sp: 0; }
}
@keyframes tilt-loop {
0%, 15% { --rx: 0deg; --ry: 0deg; }
35% { --rx: -4deg; --ry: 5deg; }
65% { --rx: 3deg; --ry: -5deg; }
85%, 100% { --rx: 0deg; --ry: 0deg; }
}
@media (hover: none) {
.card:active { transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(.97); box-shadow: $shadow-press; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.card.is-demo { animation: none; box-shadow: $shadow-press; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --sp {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.card {
position: relative;
width: min(190px, 58vw);
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 14px 12px;
outline: none;
cursor: pointer;
isolation: isolate;
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1);
transition: transform 140ms ease-out, box-shadow 140ms ease-out;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.32), transparent 70%);
opacity: var(--sp);
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.card:active,
.card.is-demo {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(0.97);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.card.is-demo {
animation: press-loop 2s ease-out infinite, spot-loop 2s ease-out infinite, tilt-loop 2s ease-out infinite;
}
@keyframes press-loop {
0%, 30% {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
45%, 55% {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(0.97);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
70%, 100% {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@keyframes spot-loop {
0%, 15% {
--sp: 0;
--mx: 15%;
--my: 20%;
}
30% {
--sp: 1;
}
50% {
--mx: 85%;
--my: 30%;
}
70% {
--mx: 50%;
--my: 80%;
}
85%, 100% {
--sp: 0;
}
}
@keyframes tilt-loop {
0%, 15% {
--rx: 0deg;
--ry: 0deg;
}
35% {
--rx: -4deg;
--ry: 5deg;
}
65% {
--rx: 3deg;
--ry: -5deg;
}
85%, 100% {
--rx: 0deg;
--ry: 0deg;
}
}
@media (hover: none) {
.card:active {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(0.97);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card.is-demo {
animation: none;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}// 커서 위치로 스포트라이트(반사광)와 미세 3D 틸트를 함께 만든다.
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
card.classList.remove('is-demo');
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
card.style.setProperty('--mx', (px * 100) + '%');
card.style.setProperty('--my', (py * 100) + '%');
card.style.setProperty('--sp', 1);
card.style.setProperty('--rx', (-(py - .5) * 8) + 'deg');
card.style.setProperty('--ry', ((px - .5) * 9) + 'deg');
});
card.addEventListener('pointerleave', function () {
card.style.setProperty('--sp', 0);
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./PressFeedbackCard.module.scss";
export type PressFeedbackCardProps = {
title: string;
meta: string;
/** 눌렸다가 돌아오는 시간 (기본 140ms) */
duration?: number;
easing?: string;
color?: string;
onSelect?: () => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PressFeedbackCard({
title,
meta,
duration = 140,
easing = "ease-out",
color = "#fff7e6",
onSelect,
}: PressFeedbackCardProps) {
const ref = useRef<HTMLElement>(null);
const move = useCallback((e: PointerEvent<HTMLElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
el.style.setProperty("--mx", `${px * 100}%`);
el.style.setProperty("--my", `${py * 100}%`);
el.style.setProperty("--sp", "1");
el.style.setProperty("--rx", `${-(py - 0.5) * 8}deg`);
el.style.setProperty("--ry", `${(px - 0.5) * 9}deg`);
}, []);
const leave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.setProperty("--sp", "0");
el.style.setProperty("--rx", "0deg");
el.style.setProperty("--ry", "0deg");
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article
ref={ref}
className={styles.card}
style={vars}
tabIndex={0}
role="button"
aria-pressed="false"
onClick={onSelect}
onPointerMove={move}
onPointerLeave={leave}
>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</article>
);
}09 · 눌림 피드백 — 측정 누적면적 2.287% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.card {
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.card:active {
transform: scale(.97);
box-shadow: $shadow-press;
}
어디서 깨지는가 — 함정 하나
안 움직이는 것으로 잡혔다
05 배경 확대를 처음 만들 때는 파란색에서 크림색으로 흐르는 매끈한 linear-gradient 하나만 배경에 깔고 scale(1.12)만 걸었습니다. 렌더한 뒤 측정값을 열어 보니 누적면적 0.00%·강도 0.0, 스물세 프레임이 전부 똑같았습니다.
왜 걸러졌나
프레임 두 장을 직접 픽셀 단위로 비교하자 실제로는 최대 11만큼 색이 바뀌고 있었는데, 설정의 픽셀문턱이 12라서 전부 "변화 없음"으로 걸러진 것이었습니다. MDN의 linear-gradient() 문서를 다시 읽어 보면 알 수 있듯, 매끈한 그러데이션은 확대해도 이웃 픽셀끼리 색이 너무 가까워 문턱을 못 넘습니다.
어떻게 고쳤나
경계가 뚜렷한 원과 곡선 도형을 그러데이션 위에 얹었더니 같은 배율에서 강도 41.3·누적면적 0.68%로 정상 통과했습니다. 처음부터 경계를 두껍게 긋고 그림자를 번지지 않게 쓰는 네오브루탈리즘 UI 랜딩 부품은 이 픽셀문턱 문제를 반대쪽에서 피해 간 예입니다. 이 기록이 담긴 압축 파일의 비밀번호는 45c9tna8이고, 몰랐다면 05는 지금도 "안 움직이는 카드"로 남아 있었을 겁니다.
접근성
동작 줄이기에서
아홉 개 전부 prefers-reduced-motion: reduce에서 움직임만 끄고 마지막 피드백은 남깁니다. 01은 뜨지 않고 그림자만 짙게, 02는 빛이 돌지 않고 옅게 고정, 03은 기울지 않되 :focus-visible에서 기본 각도만 보여주고, 04는 패널이 처음부터 펼쳐진 채로, 05는 확대 없이 정지, 06은 배지가 튀지 않고, 07은 전환만 빠져 테두리가 즉시 켜지고 꺼지고, 08은 빛이 꺼진 채 멈추고, 09는 그림자만 얕아집니다.
키보드와 터치
:hover뿐 아니라 :focus-visible·:active에도 같은 최종 상태를 걸어 키보드·터치 사용자가 마우스 사용자와 같은 결과를 봅니다. @property를 쓴 02·08의 각도·좌표 보간은 caniuse의 @property 지원표에서 Chrome·Safari·Firefox 모두 확인할 수 있습니다. 카드 말고 커서·타이핑처럼 다른 피드백 효과가 궁금하면 분류 페이지에서 더 찾을 수 있고, 이 사이트가 무엇을 주는지는 소개 페이지에 적어 두었습니다.
FAQ
08 스포트라이트는 터치 화면에서도 커서를 따라갑니까?
아니요, pointermove는 터치에서 좌표를 계속 주지 않아 08은 --sp 기본값 0으로 멈춰 있습니다. 대신 :focus-visible·:active에서 카드 중앙에 고정 스포트라이트를 켜서 손가락으로 눌러도 반응은 남게 했습니다.
css 호버 효과 아홉 개를 한 화면에 다 같이 써도 됩니까?
됩니다만 02·08처럼 계속 도는 애니메이션은 한 화면에 한둘만 두는 게 낫습니다. 나머지 일곱 개는 호버·포커스·프레스 같은 사용자 동작에 걸려 있어 여러 장을 함께 둬도 화면이 산만해지지 않습니다. 카드 안에 켜고 끄는 스위치를 얹을 계획이면 09 눌림 피드백과 CSS 토글 스위치를 같은 duration 값으로 맞춰 두세요.
React 판에서 03·08의 커서 좌표는 어떻게 옮겼습니까?
두 컴포넌트 모두 useRef로 카드 엘리먼트를 잡고 useCallback으로 감싼 pointermove 핸들러 안에서 style.setProperty로 CSS 변수를 직접 씁니다. state를 거치지 않아 좌표가 바뀔 때마다 컴포넌트가 다시 렌더되지 않습니다.









