宝可梦镭射卡效果
2025年03月22日

宝可梦镭射卡效果

宝可梦镭射卡效果拆解

效果

Pokemon Card

tsx code

index.tsx
'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

index.tsx
: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
  );
}
分享文章