效果

tsx code
'use client'
import { animate, motion, useMotionValue, useTransform } from 'framer-motion'
import Image from 'next/image'
import type React from 'react'
import { useRef } from 'react'
import styles from './holographic-card.module.css'
interface HolographicCardProps {
imageSrc?: string
altText?: string
}
const HolographicCard: React.FC<HolographicCardProps> = ({
imageSrc = '/images/29_hires.png',
altText = 'Pokemon Card',
}) => {
const cardRef = useRef<HTMLDivElement>(null)
// 1. 初始化 Motion Value (由 0 到 100 映射鼠标相对于卡片的百分比位置)
const pointerX = useMotionValue(50)
const pointerY = useMotionValue(50)
// 2. 控制光效强度的 Motion Value (0 为无光效,1 为完全开启)
const effectIntensity = useMotionValue(0)
// 3. 派生出倾斜角度 (将 0-100% 的位置映射到相应的倾斜度数)
// 鼠标在最左侧(0) 倾斜 14deg,在最右侧(100) 倾斜 -14deg
const tiltLR = useTransform(pointerX, [0, 100], [14, -14])
// 鼠标在最上侧(0) 倾斜 -14deg,在最下侧(100) 倾斜 14deg
const tiltUD = useTransform(pointerY, [0, 100], [-14, 14])
// 4. 计算鼠标距离中心的距离百分比 (用于滤镜和光效的深层计算)
const pointerFromCenter = useTransform<number, number>(
[pointerX, pointerY],
(values: number[]) => {
const [x = 50, y = 50] = values
const pxFromCenter = Math.abs((x - 50) / 50)
const pyFromCenter = Math.abs((y - 50) / 50)
return Math.max(pxFromCenter, pyFromCenter)
}
)
// 5. 组合成传给 DOM 的一组包含 Motion Value 的 CSS 变量
const dynamicStyles = {
'--tilt-left-right': useTransform(tiltLR, (v) => `${v}deg`),
'--tilt-up-down': useTransform(tiltUD, (v) => `${v}deg`),
'--pointer-x': useTransform(pointerX, (v) => `${v}%`),
'--pointer-y': useTransform(pointerY, (v) => `${v}%`),
'--background-x': useTransform(pointerX, (v) => `${v}%`),
'--background-y': useTransform(pointerY, (v) => `${v}%`),
'--pointer-from-center': pointerFromCenter,
'--effect-intensity': effectIntensity,
} as React.CSSProperties
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const card = cardRef.current
if (!card) {
return
}
const rect = card.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// 计算百分比并直接更新 Motion Value (不会触发 React 的无用 Re-render)
const px = (x / rect.width) * 100
const py = (y / rect.height) * 100
pointerX.set(px)
pointerY.set(py)
effectIntensity.set(1)
}
const handlePointerLeave = () => {
// 精调弹簧参数:高刚度 + 适中阻尼 = 灵动回弹
const springConfig = {
type: 'spring' as const,
stiffness: 250, // 提高刚度,回弹更迅速
damping: 14, // 降低阻尼,允许轻微的超越边界摆动
mass: 0.8, // 减轻质量,让卡片看起来更轻盈
}
animate(pointerX, 50, springConfig)
animate(pointerY, 50, springConfig)
animate(effectIntensity, 0, springConfig)
}
return (
<div
className={`not-prose ${styles.card}`}
onPointerLeave={handlePointerLeave}
onPointerMove={handlePointerMove}
ref={cardRef}
>
{/* 将普通 div 升级为 motion.div,直接绑定 3D 转换与动态变量 */}
<motion.div
className={styles.card__rotator}
style={{
...dynamicStyles,
transform:
'rotateY(var(--tilt-left-right)) rotateX(var(--tilt-up-down))',
}}
>
<div className={styles.card__front}>
<Image alt={altText} fill priority src={imageSrc} />
<div className={styles.card__shine} />
<div className={styles.card__glare} />
</div>
</motion.div>
</div>
)
}
export default HolographicCard
css code
:root {
--card-aspect: 0.718;
--card-radius: 4.55% / 3.5%;
--text-primary: #f7f3ea;
--text-muted: rgba(247, 243, 234, 0.66);
--text-soft: rgba(247, 243, 234, 0.42);
--accent: #77f7d4;
}
.card {
width: min(72vw, 340px);
aspect-ratio: var(--card-aspect);
border-radius: var(--card-radius);
perspective: 600px;
user-select: none;
}
.card__rotator {
--space: 6%;
--angle: 133deg;
--foil: url("https://i.imgur.com/vu5azD2.jpeg");
--imgsize: 60% 30%;
width: 100%;
height: 100%;
display: grid;
border-radius: inherit;
transform-origin: center;
will-change: transform;
box-shadow:
0 18px 36px rgba(0, 0, 0, 0.42),
0 4px 12px rgba(0, 0, 0, 0.34);
}
.card__front,
.card__front > * {
grid-area: 1 / 1;
width: 100%;
height: 100%;
border-radius: inherit;
}
.card__front {
display: grid;
overflow: hidden;
}
.card__front img {
display: block;
object-fit: cover;
user-select: none;
pointer-events: none;
}
.card__shine {
display: grid;
background-image:
var(--foil),
repeating-linear-gradient(
-33deg,
hsl(2, 70%, 47%) calc(var(--space) * 1),
hsl(228, 60%, 64%) calc(var(--space) * 2),
hsl(176, 55%, 39%) calc(var(--space) * 3),
hsl(123, 68%, 35%) calc(var(--space) * 4),
hsl(283, 75%, 57%) calc(var(--space) * 5),
hsl(2, 70%, 47%) calc(var(--space) * 6)
),
repeating-linear-gradient(
var(--angle),
hsla(227, 53%, 12%, 0.5) 0%,
hsl(180, 10%, 50%) 2.5%,
hsl(83, 50%, 35%) 5%,
hsl(180, 10%, 50%) 7.5%,
hsla(227, 53%, 12%, 0.5) 10%,
hsla(227, 53%, 12%, 0.5) 15%
),
radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsla(189, 76%, 77%, 0.6) 0%,
hsla(147, 59%, 77%, 0.6) 25%,
hsla(271, 55%, 69%, 0.6) 50%,
hsla(355, 56%, 72%, 0.6) 75%
);
background-size:
var(--imgsize),
1100% 1100%,
600% 600%,
200% 200%;
background-blend-mode: difference, luminosity, soft-light, normal;
mix-blend-mode: color-dodge;
background-position:
center,
var(--background-x) var(--background-y),
var(--background-x) var(--background-y),
var(--background-x) var(--background-y);
opacity: var(--effect-intensity);
filter: brightness(calc((var(--pointer-from-center) * 0.4) + 0.4)) contrast(2)
saturate(1);
}
.card__shine::after {
--sunpillar-1: hsl(2, 100%, 73%);
--sunpillar-2: hsl(53, 100%, 69%);
--sunpillar-3: hsl(93, 100%, 69%);
--sunpillar-4: hsl(176, 100%, 76%);
--sunpillar-5: hsl(228, 100%, 74%);
--sunpillar-6: hsl(283, 100%, 73%);
content: "";
grid-area: 1 / 1;
border-radius: inherit;
background-image:
repeating-linear-gradient(
0deg,
var(--sunpillar-6) calc(var(--space) * 1),
var(--sunpillar-1) calc(var(--space) * 2),
var(--sunpillar-2) calc(var(--space) * 3),
var(--sunpillar-3) calc(var(--space) * 4),
var(--sunpillar-4) calc(var(--space) * 5),
var(--sunpillar-5) calc(var(--space) * 6),
var(--sunpillar-6) calc(var(--space) * 7)
),
repeating-linear-gradient(
var(--angle),
#0e152e 0%,
hsl(180, 10%, 60%) 3.8%,
hsl(180, 29%, 66%) 4.5%,
hsl(180, 10%, 60%) 5.2%,
#0e152e 10%,
#0e152e 12%
);
background-blend-mode: hue, normal;
background-size:
200% 700%,
300% 100%;
background-position:
0% var(--background-y),
var(--background-x) var(--background-y);
mix-blend-mode: lighten;
opacity: calc(
(0.3 * var(--effect-intensity)) +
var(--effect-intensity) *
var(--pointer-from-center) *
0.5
);
}
.card__glare {
background-image: radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsla(0, 0%, 100%, 0.75) 0%,
hsl(0, 0%, 0%) 120%
);
mix-blend-mode: hard-light;
opacity: calc(
(0.2 * var(--effect-intensity)) +
var(--effect-intensity) *
var(--pointer-from-center) *
0.8
);
}
