Velocity UI
Loading…
Menu

Spotlight Product Card

A premium e-commerce card with 3D tilt, spotlight hover effect, and expandable actions. Features smooth spring physics and color selection.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add spotlight-product-card

Source Code

spotlight-product-card.tsx
'use client'

import React, { useRef, useState } from 'react'
import { motion, useMotionTemplate, useMotionValue, useSpring, useTransform } from 'framer-motion'
import { ShoppingBag, Star, ArrowRight, Heart } from 'lucide-react'
import { cn } from '@/lib/utils'

export interface ProductData {
  id: string
  name: string
  category: string
  price: number
  rating: number
  image: string
  colors: string[]
}

const DEFAULT_PRODUCT: ProductData = {
  id: '1',
  name: 'Velocity Air Max',
  category: 'Premium Footwear',
  price: 189.00,
  rating: 4.8,
  image: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?q=80&w=2070&auto=format&fit=crop',
  colors: ['#ef4444', '#3b82f6', '#10b981', '#171717']
}

interface SpotlightProductCardProps {
    product?: ProductData;
    onAddToCart?: (product: ProductData, color: string) => void;
    onToggleFavorite?: (product: ProductData) => void;
}

export default function SpotlightProductCard({ 
    product = DEFAULT_PRODUCT,
    onAddToCart,
    onToggleFavorite
}: SpotlightProductCardProps) {
  return (
    <div className="flex items-center justify-center min-h-[600px] bg-neutral-100 dark:bg-neutral-950 p-8 perspective-1000">
      <ProductCard 
        product={product} 
        onAddToCart={onAddToCart}
        onToggleFavorite={onToggleFavorite}
      />
    </div>
  )
}

function ProductCard({ 
    product,
    onAddToCart,
    onToggleFavorite
}: { 
    product: ProductData;
    onAddToCart?: (product: ProductData, color: string) => void;
    onToggleFavorite?: (product: ProductData) => void;
}) {
  const ref = useRef<HTMLDivElement>(null)
  const [selectedColor, setSelectedColor] = useState(product.colors[0])
  const [isHovered, setIsHovered] = useState(false)
  const [isFavorite, setIsFavorite] = useState(false)

  // Mouse position logic
  const x = useMotionValue(0)
  const y = useMotionValue(0)

  // Smooth mouse motion
  const mouseXSpring = useSpring(x)
  const mouseYSpring = useSpring(y)

  const rotateX = useTransform(mouseYSpring, [-0.5, 0.5], ["15deg", "-15deg"])
  const rotateY = useTransform(mouseXSpring, [-0.5, 0.5], ["-15deg", "15deg"])

  const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
    if (!ref.current) return
    const rect = ref.current.getBoundingClientRect()
    const width = rect.width
    const height = rect.height
    const mouseX = e.clientX - rect.left
    const mouseY = e.clientY - rect.top
    const xPct = mouseX / width - 0.5
    const yPct = mouseY / height - 0.5
    x.set(xPct)
    y.set(yPct)
  }

  const handleMouseLeave = () => {
    x.set(0)
    y.set(0)
    setIsHovered(false)
  }

  const handleMouseEnter = () => {
    setIsHovered(true)
  }

  return (
    <motion.div
      ref={ref}
      onMouseMove={handleMouseMove}
      onMouseLeave={handleMouseLeave}
      onMouseEnter={handleMouseEnter}
      style={{
        rotateX,
        rotateY,
        transformStyle: "preserve-3d",
      }}
      className="relative w-full max-w-sm rounded-3xl bg-white dark:bg-neutral-900 shadow-xl cursor-pointer"
      whileHover={{ scale: 1.02 }}
      transition={{ type: "spring", stiffness: 300, damping: 30 }}
    >
      {/* Spotlight Overlay */}
      <div 
        className="absolute inset-0 rounded-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none z-10"
        style={{
            background: useMotionTemplate`
                radial-gradient(
                  650px circle at ${mouseXSpring.get() * 350 + 175}px ${mouseYSpring.get() * 500 + 250}px,
                  rgba(255,255,255,0.1),
                  transparent 80%
                )
            `
        }}
      />

      {/* Image Section */}
      <div className="relative h-64 w-full rounded-t-3xl overflow-hidden group">
        <motion.div
            style={{ transformStyle: "preserve-3d", translateZ: "50px" }}
            className="absolute inset-0"
        >
             <img 
                src={product.image} 
                alt={product.name}
                className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
             />
             <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-60" />
        </motion.div>

        {/* Favorite Button */}
        <motion.button
            onClick={(e) => {
                e.stopPropagation();
                setIsFavorite(!isFavorite);
                onToggleFavorite?.(product);
            }}
            style={{ translateZ: "80px" }}
            className="absolute top-4 right-4 p-2.5 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-white hover:bg-white/20 transition-colors"
            whileTap={{ scale: 0.9 }}
        >
            <Heart size={18} className={cn("transition-colors", isFavorite && "fill-red-500 text-red-500")} />
        </motion.button>
        
        {/* Rating Badge */}
        <motion.div 
            style={{ translateZ: "60px" }}
            className="absolute bottom-4 left-4 flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-black/40 backdrop-blur-md border border-white/10"
        >
            <Star size={14} className="fill-yellow-400 text-yellow-400" />
            <span className="text-xs font-bold text-white">{product.rating}</span>
        </motion.div>
      </div>

      {/* Content Section */}
      <div className="p-6 relative z-20 bg-white dark:bg-neutral-900 rounded-b-3xl">
         <motion.div style={{ translateZ: "40px" }}>
            <div className="flex justify-between items-start mb-2">
                <div>
                    <h3 className="text-xl font-bold text-neutral-800 dark:text-white tracking-tight">{product.name}</h3>
                    <p className="text-sm text-neutral-500 dark:text-neutral-400 font-medium">{product.category}</p>
                </div>
                <span className="text-lg font-bold text-neutral-900 dark:text-white">${product.price}</span>
            </div>

            {/* Color Selection */}
            <div className="mt-6 mb-8">
                <p className="text-xs font-bold text-neutral-400 uppercase tracking-wider mb-3">Select Color</p>
                <div className="flex gap-3">
                    {product.colors.map((color) => (
                        <button
                            key={color}
                            onClick={() => setSelectedColor(color)}
                            className={cn(
                                "w-8 h-8 rounded-full border-2 transition-all duration-300 relative flex items-center justify-center",
                                selectedColor === color 
                                    ? "border-neutral-900 dark:border-white scale-110" 
                                    : "border-transparent hover:scale-110"
                            )}
                        >
                            <span 
                                className="w-6 h-6 rounded-full absolute" 
                                style={{ backgroundColor: color }}
                            />
                        </button>
                    ))}
                </div>
            </div>

            {/* Action Button */}
            <motion.button
                onClick={() => onAddToCart?.(product, selectedColor)}
                whileHover={{ scale: 1.02 }}
                whileTap={{ scale: 0.98 }}
                className="w-full py-3.5 rounded-xl bg-neutral-900 dark:bg-white text-white dark:text-black font-bold text-sm shadow-lg shadow-neutral-500/20 dark:shadow-white/10 flex items-center justify-center gap-2 group/btn"
            >
                <ShoppingBag size={18} />
                <span>Add to Cart</span>
                <ArrowRight size={16} className="opacity-0 -translate-x-2 group-hover/btn:opacity-100 group-hover/btn:translate-x-0 transition-all" />
            </motion.button>
         </motion.div>
      </div>
    </motion.div>
  )
}

Dependencies

  • framer-motion: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest