9 CSS Card Hover Effects — dán 1 dòng là chạy
CSS card hover effects là các hiệu ứng CSS khiến thẻ card phản hồi khi con trỏ dừng trên nó — nổi lên, viền phát sáng, nghiêng theo toạ độ hoặc rọi đèn theo
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
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Nổi lên + đổ bóng
- 02 Viền sáng xoay
- 03 Nghiêng 3D thị sai
- 04 Nội dung trượt lên
- 05 Phóng to ảnh nền
- 06 Icon nảy
- 07 Viền gradient
- 08 Đèn rọi theo con trỏ
- 09 Phản hồi khi nhấn
Chín card không xếp theo độ hoành tráng mà theo cách chúng chào bạn: 01 và 02 gây chú ý ngay cả khi bạn chưa chạm vào, 03 đến 05 thêm chiều sâu hoặc lộ thêm nội dung khi bạn dừng lại, 06 đến 08 mời bạn bấm bằng một cử chỉ nhỏ, còn 09 chỉ trả lời khi bạn thật sự nhấn xuống. 02 và 08 tận dụng @property để trình duyệt nội suy mượt góc và toạ độ thay vì nhảy khựng giữa hai giá trị — caniuse ghi Chrome/Edge 85+, Safari 16.4+, Firefox 128+ cho tính năng này. Mỗi mục dưới đây chỉ nêu khối SCSS hoặc JS cốt lõi, bản đầy đủ kèm React nằm trong file nén ở cuối bài.
01Nổi lên + đổ bóng
Bạn thấy gì
Card nhấc lên 8px và bóng đổ cũng đậm hơn ngay khi hover, nên nó tách khỏi những card còn lại trong lưới trước cả khi có ai bấm vào.

<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">Tai nghe không dây</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 · Nổi lên + đổ bóng — Diện tích đo được 11.669% · khung hình chuyển động 17/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Cách này hợp với lưới card sản phẩm hay danh sách bài viết xếp cạnh nhau, nơi mắt cần biết đang rê tới ô nào.
.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;
}
02Viền sáng xoay
Chuyện gì diễn ra trên màn hình
Một vệt sáng conic-gradient quay đều quanh viền card, còn góc quay được lưu trong biến --angle đăng ký bằng @property rồi biến thiên qua keyframes để không giật khựng giữa các vòng.

<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">Gói Premium</h3>
<p class="card__meta">Bao gồm tính năng 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 · Viền sáng xoay — Diện tích đo được 1.778% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Kiểu này hợp với card gói cước cao cấp hoặc card giới thiệu tính năng AI, nơi bạn muốn card trông "đang sống" cả trước khi ai chạm vào.
@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; } }
03Nghiêng 3D thị sai
Chuyển động thế nào
Toạ độ con trỏ trên card được JS đọc rồi ghi vào hai biến CSS điều khiển rotateX/rotateY, trong khi lớp nền phía sau dịch chuyển theo hướng ngược lại để trông như hai lớp tách rời nhau.

<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">Dự án portfolio</h3>
<p class="card__meta">Xem chi tiết</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 · Nghiêng 3D thị sai — Diện tích đo được 11.272% · khung hình chuyển động 18/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Card giới thiệu tính năng hoặc card dự án portfolio dùng lối này để có cảm giác chiều sâu mà không cần ảnh 3D thật.
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');
});
04Nội dung trượt lên
Bạn thấy gì
Một panel mô tả vốn nằm khuất dưới translateY(70%) trượt hẳn lên translateY(0) khi hover, để lộ thêm thông tin mà kích thước card không đổi một pixel.

<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">Bộ sưu tập mùa thu</h3>
<p class="card__meta">Xem ngay</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 · Nội dung trượt lên — Diện tích đo được 11.14% · khung hình chuyển động 18/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Card sản phẩm nặng ảnh hay thumbnail trong gallery hưởng lợi nhiều nhất vì caption không chiếm chỗ lúc bình thường.
.card__panel {
transform: translateY(70%);
transition: transform $duration $easing;
}
.card:hover .card__panel,
.card.is-demo .card__panel {
transform: translateY(0);
}
05Phóng to ảnh nền
Chuyện gì diễn ra trên màn hình
Lớp nền phủ kín card phóng to lên scale(1.12) ngay khi hover, còn overflow: hidden đặt trên chính card cắt sạch phần tràn ra ngoài góc bo.

<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">Biển Jeju</h3>
<p class="card__meta">Đánh giá chuyến đi</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 · Phóng to ảnh nền — Diện tích đo được 7.676% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Card giới thiệu điểm đến du lịch hay card cover bài blog dùng cách này để nền có sức sống mà khung ngoài vẫn đứng yên.
.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);
}
06Icon nảy
Chuyển động thế nào
Icon mũi tên ở góc card nảy đúng hai lần bằng keyframes bounce khi hover, như một quả bóng cao su nhỏ, báo rằng card này bấm được.
<main class="stage">
<div class="frame">
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">Tin mới nhất</h3>
<p class="card__meta">Đọc thêm</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 nảy — Diện tích đo được 2.056% · khung hình chuyển động 18/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Mũi tên "đọc thêm" trên card blog hay bất kỳ card dạng link nào đều hợp với cử chỉ mời gọi này.
.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); }
}
07Viền gradient
Bạn thấy gì
Viền xám mỏng lúc bình thường đổi sang viền gradient hai màu khi hover, nhờ opacity của một lớp cắt bằng mask-composite chuyển từ 0 lên 1 mà không đụng vào layout.

<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">Gói được đề xuất</h3>
<p class="card__meta">Lựa chọn phổ biến nhất</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 · Viền gradient — Diện tích đo được 2.281% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Card so sánh gói cước hoặc card đề xuất muốn nổi bật dùng đúng kiểu viền này.
.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Đèn rọi theo con trỏ
Chuyện gì diễn ra trên màn hình
Mười dòng JS đọc toạ độ con trỏ rồi ghi vào hai custom property, và một radial-gradient bám đúng điểm đó để rọi sáng bề mặt card khi bạn rê chuột qua.

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">Thẻ chế độ tối</h3>
<p class="card__meta">Thẻ tương tác</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 · Đèn rọi theo con trỏ — Diện tích đo được 9.937% · khung hình chuyển động 17/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Card giao diện tối hoặc card tính năng tương tác tận dụng đèn rọi này để mặt card có phản hồi ngay cả khi chưa bấm.
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);
});
09Phản hồi khi nhấn
Chuyển động thế nào
Khi bạn thật sự bấm xuống, card co nhẹ về scale(.97) và bóng đổ phẳng lại tức thì, còn nếu chỉ hover thôi thì không đổi gì cả.

<main class="stage">
<article class="card is-demo" tabindex="0" role="button" aria-pressed="false">
<div class="card__body">
<h3 class="card__title">Chọn thẻ</h3>
<p class="card__meta">Chạm để chọn</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 · Phản hồi khi nhấn — Diện tích đo được 2.287% · khung hình chuyển động 17/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Radio dạng card có thể chọn hay bất kỳ card nào dùng trên di động cần đúng xác nhận chạm này trước khi tin rằng cú bấm đã thành công.
.card {
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.card:active {
transform: scale(.97);
box-shadow: $shadow-press;
}
Card hover effects dễ hỏng ở đâu? — cái bẫy mask-composite
Bảng từ khoá mask-composite có và không tiền tố
Mục 07 là nơi dễ chép nhầm nhất nếu bạn từng thấy một đoạn CSS cũ nào đó viết -webkit-mask-composite: xor;. Cú pháp có tiền tố đó dùng bảng từ khoá riêng của WebKit (source-over, xor, plus-lighter và vài giá trị khác), còn thuộc tính chuẩn không tiền tố mask-composite lại dùng bảng từ khoá khác hẳn — add, subtract, intersect, exclude — đúng như MDN liệt kê ở trang mask-composite. Nếu bạn mang nguyên giá trị xor sang dòng không tiền tố, trình duyệt hiện đại coi đó là một từ khoá không hợp lệ và bỏ hẳn khai báo, quay về giá trị khởi tạo add — nghĩa là lớp mask không còn "khoét" phần content-box ra nữa, cả khối gradient phủ kín card thay vì chỉ hiện ra như một viền mỏng. Giá trị đúng tương đương với xor kiểu cũ chính là exclude, và phần không tiền tố này đã chạy được trên Chrome/Edge từ bản 120, Safari từ 15.4, Firefox từ bản 53 theo bảng hỗ trợ của caniuse — đủ rộng để bỏ hẳn tiền tố -webkit- nếu bạn không cần đỡ cho Safari cũ hơn 15.4. Toàn bộ chín file kèm phần chú thích chỗ dễ nhầm này nằm trong bản nén, mật khẩu là 45c9tna8, gõ nguyên văn không thêm khoảng trắng.
Khả năng tiếp cận
Giảm chuyển động theo từng card
Cả chín card đều tôn trọng prefers-reduced-motion: reduce, nhưng không phải card nào cũng chỉ đơn giản "tắt animation". 01, 04, 05, 07 và 09 bỏ transition và giữ nguyên trạng thái cuối — card 01 đứng ở dáng đã nổi lên, 07 hiện viền gradient thường trực. 02 khoá vòng xoay lại ở opacity: .55 cố định thay vì quay liên tục, còn 06 và 09 bỏ hẳn animation nảy hoặc animation vòng lặp trình diễn (.is-demo) để icon và card đứng yên. 03 và 08 vẫn phản hồi qua :focus-visible/:active với một góc nghiêng hoặc một điểm sáng cố định ở giữa, nên người dùng bàn phím vẫn thấy đúng trạng thái cuối như người dùng chuột — chỉ riêng quãng chuyển động dẫn tới đó là biến mất. Mức hỗ trợ của media query này theo từng trình duyệt được MDN ghi ở trang prefers-reduced-motion.
Ngoài chín card này, chuyên mục CSS gom các hiệu ứng khác cùng dạng, còn trang giới thiệu nói rõ vì sao mỗi demo ở đây đều đã đo đạc thật trước khi đăng.
FAQ
Chỉ cần 2-3 hiệu ứng trong 9 cái, tách riêng ra dùng được không?
Được. Mỗi hiệu ứng đóng gói trong đúng một khối .card không chia sẻ trạng thái với mục khác, nên copy phần SCSS của một mục cùng khung HTML đi kèm trong file vanilla là chạy độc lập ngay. Riêng 03 và 08 cần thêm đoạn JS pointermove của chính mục đó vì hai kiểu này không hoạt động được nếu thiếu toạ độ con trỏ.
03 và 08 có phản hồi trên điện thoại chạm không?
Có nhưng khác cơ chế. Sự kiện pointermove không bắn liên tục khi chạm nên hai mục này không "trôi" theo ngón tay, thay vào đó :focus-visible và :active bật một góc nghiêng hoặc một điểm sáng cố định ở giữa card khi bạn chạm và giữ, đủ để xác nhận đã chạm đúng chỗ.
Bản React trong zip xử lý toạ độ con trỏ ở 03 và 08 khác gì bản vanilla?
Cả hai component dùng useRef để giữ tham chiếu tới thẻ card và ghi thẳng style.setProperty trong handler pointermove bọc bởi useCallback, giống hệt cách bản vanilla dùng card.style.setProperty. Toạ độ không đi qua state nên card không re-render mỗi lần bạn di chuột, tránh giật khung hình khi rê nhanh.









