返回资源库

Hover Card

UI组件

预览

卡片标题

这是一个测试预览实例,11111111

代码

组件代码
'use client';

import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import './HoverCard.scss';

interface CardProps {
  dataImage: string;
  headerContent?: React.ReactNode;
  contentContent?: React.ReactNode;
}

const HoverCard: React.FC<CardProps> = ({ dataImage, headerContent, contentContent }) => {
  const [mouseX, setMouseX] = useState(0);
  const [mouseY, setMouseY] = useState(0);
  
  const cardRef = useRef<HTMLDivElement>(null);
  const mouseLeaveDelayRef = useRef<NodeJS.Timeout | null>(null);
  const rafRef = useRef<number>();
  
  const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
    if (!cardRef.current) return;
    
    if (rafRef.current) {
      cancelAnimationFrame(rafRef.current);
    }
    
    rafRef.current = requestAnimationFrame(() => {
      const rect = cardRef.current!.getBoundingClientRect();
      const x = e.clientX - rect.left - rect.width / 2;
      const y = e.clientY - rect.top - rect.height / 2;
      setMouseX(x);
      setMouseY(y);
    });
  }, []);
  
  const handleMouseEnter = useCallback(() => {
    if (mouseLeaveDelayRef.current) {
      clearTimeout(mouseLeaveDelayRef.current);
      mouseLeaveDelayRef.current = null;
    }
  }, []);
  
  const handleMouseLeave = useCallback(() => {
    mouseLeaveDelayRef.current = setTimeout(() => {
      setMouseX(0);
      setMouseY(0);
    }, 1000);
  }, []);
  
  const cardStyle = useMemo(() => {
    const mousePX = mouseX / (cardRef.current?.offsetWidth || 1);
    const mousePY = mouseY / (cardRef.current?.offsetHeight || 1);
    return {
      transform: `rotateY(${mousePX * 30}deg) rotateX(${mousePY * -30}deg)`
    };
  }, [mouseX, mouseY]);
  
  const cardBgTransform = useMemo(() => {
    const mousePX = mouseX / (cardRef.current?.offsetWidth || 1);
    const mousePY = mouseY / (cardRef.current?.offsetHeight || 1);
    return {
      transform: `translateX(${mousePX * -40}px) translateY(${mousePY * -40}px)`
    };
  }, [mouseX, mouseY]);
  
  const cardBgImage = useMemo(() => ({
    backgroundImage: `url(${dataImage})`,
  }), [dataImage]);
  
  useEffect(() => {
    return () => {
      if (mouseLeaveDelayRef.current) {
        clearTimeout(mouseLeaveDelayRef.current);
      }
      if (rafRef.current) {
        cancelAnimationFrame(rafRef.current);
      }
    };
  }, []);
  
  return (
    <div
      className="card-wrap hover-card-wrap"
      onMouseMove={handleMouseMove}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      ref={cardRef}
    >
      <div className="card" style={cardStyle}>
        <div 
          className="card-bg" 
          style={{...cardBgTransform, ...cardBgImage}}
        ></div>
        <div className="card-info">
          {headerContent}
          {contentContent}
        </div>
      </div>
    </div>
  );
};

export default React.memo(HoverCard);
SCSS 样式代码
$hoverEasing: cubic-bezier(0.23, 1, 0.32, 1);
$returnEasing: cubic-bezier(0.445, 0.05, 0.55, 0.95);

.card-wrap {
  margin: 10px;
  transform: perspective(800px);
  transform-style: preserve-3d;
  cursor: pointer; 
  font-family: "Raleway";
  font-size: 14px;
  font-weight: 500;

  -webkit-font-smoothing: antialiased;
  // background-color: #fff;
  
  &:hover {
    .card-info {
      transform: translateY(0);
    }
    .card-info p {
      opacity: 1;
    }
    .card-info, .card-info p {
      transition: 0.6s $hoverEasing;
    }
    .card-info:after {
      transition: 5s $hoverEasing;
      opacity: 1;
      transform: translateY(0);
    }
    .card-bg {
      transition: 
        0.6s $hoverEasing,
        opacity 5s $hoverEasing;
      opacity: 0.8;
    }
    .card {
      transition:
        0.6s $hoverEasing,
        box-shadow 2s $hoverEasing;
      box-shadow:
        rgba(white, 0.2) 0 0 40px 5px,
        rgba(white, 1) 0 0 0 1px,
        rgba(black, 0.66) 0 30px 60px 0,
        inset #333 0 0 0 5px,
        inset white 0 0 0 6px;
    }
  }
}

.card {
  position: relative;
  flex: 0 0 240px;
  width: 240px;
  height: 320px;
  background-color: #333;
  overflow: hidden;
  border-radius: 10px;
  box-shadow:
    rgba(black, 0.66) 0 30px 60px 0,
    inset #333 0 0 0 5px,
    inset rgba(white, 0.5) 0 0 0 6px;
  transition: 1s $returnEasing;
}

.card-bg {
  opacity: 0.5;
  position: absolute;
  top: -20px; left: -20px;
  box-sizing: content-box;
  width: 100%;
  height: 100%;
  padding: 20px;
  background-repeat: no-repeat;
  background-position: center;
  background-size: cover;
  transition:
    1s $returnEasing,
    opacity 5s 1s $returnEasing;
  pointer-events: none;
}

.card-info {
  padding: 20px;
  position: absolute;
  bottom: 0;
  color: #fff;
  transform: translateY(40%);
  transition: 0.6s 1.6s cubic-bezier(0.215, 0.61, 0.355, 1);
  
  p {
    opacity: 0;
    text-shadow: rgba(black, 1) 0 2px 3px;
    transition: 0.6s 1.6s cubic-bezier(0.215, 0.61, 0.355, 1);
  }
  
  * {
    position: relative;
    z-index: 1;
  }
  
  &:after {
    content: '';
    position: absolute;
    top: 0; left: 0;
    z-index: 0;
    width: 100%;
    height: 100%;
    background-image: linear-gradient(to bottom, transparent 0%, rgba(#000, 0.6) 100%);
    background-blend-mode: overlay;
    opacity: 0;
    transform: translateY(100%);
    transition: 5s 1s $returnEasing;
  }
}

.card-info h1 {
  font-family: "Playfair Display";
  font-size: 36px;
  font-weight: 700;
  text-shadow: rgba(black, 0.5) 0 10px 10px;
}