Flashy Card

PreviousNext

Interactive card with smooth animated activation and visual effects.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/flashy-card.tsx
"use client";
import React, { useState, useRef, useEffect } from 'react';
import { cn } from "@/lib/utils";

interface FlashyCardProps extends React.HTMLAttributes<HTMLDivElement> {
  children?: React.ReactNode;
  onStateChange?: (isActive: boolean) => void;
  disabled?: boolean;
  iconClassName?: string;
  rippleClassName?: string;
  glareClassName?: string;
  defaultSrc?: string;
  activeSrc?: string;
  activeType?: 'image' | 'video';
}

interface FlashyCardContentProps {
  children: React.ReactNode;
}

const FlashyCardDefault: React.FC<FlashyCardContentProps> = ({ children }) => {
  return <>{children}</>;
};

const FlashyCardActive: React.FC<FlashyCardContentProps> = ({ children }) => {
  return <>{children}</>;
};

const FlashyCard = React.forwardRef<HTMLDivElement, FlashyCardProps>(({ 
  children,
  className,
  onStateChange,
  disabled = false,
  iconClassName,
  rippleClassName,
  glareClassName,
  defaultSrc,
  activeSrc,
  activeType,
  ...props
}, ref) => {
  const [isSelected, setIsSelected] = useState(false);
  const [isButtonHovered, setIsButtonHovered] = useState(false);
  const [isAnimating, setIsAnimating] = useState(false);
  const [videoReady, setVideoReady] = useState(false);
  const glareRef = useRef<HTMLDivElement>(null);
  const cardRef = useRef<HTMLDivElement>(null);
  const videoRef = useRef<HTMLVideoElement>(null);

  React.useImperativeHandle(ref, () => cardRef.current as HTMLDivElement);

  const childArray = React.Children.toArray(children);
  const defaultContentChild = childArray.find(
    (child) => React.isValidElement(child) && child.type === FlashyCardDefault
  );
  const activeContentChild = childArray.find(
    (child) => React.isValidElement(child) && child.type === FlashyCardActive
  );

  const defaultContent = defaultContentChild || (defaultSrc && (
    <div className="aspect-[4/3] relative">
      <img
        src={defaultSrc}
        alt="Default content"
        className="w-full h-full object-cover"
      />
    </div>
  ));

  const isVideo = (src: string) => {
    const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov'];
    return videoExtensions.some(ext => src.toLowerCase().endsWith(ext));
  };

  const shouldShowVideo = activeType === 'video' || (!activeType && activeSrc && isVideo(activeSrc));

  useEffect(() => {
    if (shouldShowVideo && videoRef.current) {
      const video = videoRef.current;
      video.load();
      const handleCanPlay = () => setVideoReady(true);
      video.addEventListener('canplay', handleCanPlay);
      return () => video.removeEventListener('canplay', handleCanPlay);
    }
  }, [shouldShowVideo]);

  const activeContent = activeContentChild || (activeSrc && (
    <div className="aspect-[4/3] relative">
      {shouldShowVideo ? (
        <video
          key="active-video"
          ref={videoRef}
          src={activeSrc}
          autoPlay
          loop
          muted
          playsInline
          preload="auto"
          className="w-full h-full object-cover"
        />
      ) : (
        <img
          src={activeSrc}
          alt="Active content"
          className="w-full h-full object-cover"
        />
      )}
    </div>
  ));

  const handleClick = () => {
    if (isAnimating || disabled) return;
    
    setIsAnimating(true);
    
    const card = cardRef.current;
    
    if (!isSelected) {
      if (videoRef.current) {
        videoRef.current.currentTime = 0;
        videoRef.current.play();
      }
      
      if (card) {
        card.style.transition = 'border-color 300ms ease, box-shadow 300ms ease';
        card.style.borderColor = 'hsl(var(--primary))';
        card.style.boxShadow = '0 25px 50px -12px hsl(var(--primary) / 0.3)';
      }
      
      setTimeout(() => {
        const el = glareRef.current;
        if (el) {
          el.style.transition = 'none';
          el.style.backgroundPosition = '-100% -100%, 0 0';
          
          requestAnimationFrame(() => {
            el.style.transition = '650ms ease';
            el.style.backgroundPosition = '100% 100%, 0 0';
          });
        }
      }, 300);
      
      setTimeout(() => {
        setIsSelected(true);
        setIsAnimating(false);
        onStateChange?.(true);
      }, 950);
    } else {
      if (card) {
        card.style.transition = 'border-color 300ms ease, box-shadow 300ms ease';
        card.style.borderColor = 'hsl(var(--border))';
        card.style.boxShadow = 'none';
      }
      
      setTimeout(() => {
        const el = glareRef.current;
        if (el) {
          el.style.transition = 'none';
          el.style.backgroundPosition = '-100% -100%, 0 0';
          
          requestAnimationFrame(() => {
            el.style.transition = '650ms ease';
            el.style.backgroundPosition = '100% 100%, 0 0';
          });
        }
      }, 300);
      
      setTimeout(() => {
        setIsSelected(false);
        setIsAnimating(false);
        onStateChange?.(false);
      }, 950);
    }
  };

  return (
    <div 
      ref={cardRef}
      className={cn(
        "relative rounded-xl bg-card border-2 transition-all duration-300",
        isSelected ? "border-primary shadow-2xl" : "border-border",
        className
      )}
      {...props}
    >
      <div
        ref={glareRef}
        className={cn(
          "absolute inset-0 pointer-events-none z-20 rounded-xl",
          glareClassName
        )}
        style={{
          background: 'linear-gradient(-45deg, hsla(0,0%,0%,0) 60%, rgba(255,255,255,0.5) 70%, hsla(0,0%,0%,0) 100%)',
          backgroundSize: '250% 250%, 100% 100%',
          backgroundRepeat: 'no-repeat',
          backgroundPosition: '-100% -100%, 0 0'
        }}
      />

      <div className="relative rounded-xl overflow-hidden bg-card">
        <div className={cn(
          "transition-opacity duration-300",
          isSelected ? "opacity-100" : "opacity-0 absolute inset-0"
        )}>
          {activeContent}
        </div>
        <div className={cn(
          "transition-opacity duration-300",
          !isSelected ? "opacity-100" : "opacity-0 absolute inset-0"
        )}>
          {defaultContent}
        </div>
      </div>

      {shouldShowVideo && !isSelected && (
        <div className="absolute inset-0 pointer-events-none opacity-0 -z-10">
          <video
            src={activeSrc}
            loop
            muted
            playsInline
            preload="auto"
            className="w-full h-full object-cover"
          />
        </div>
      )}

      <div className="relative h-0 z-30">
        <div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 z-30">
          {!isButtonHovered && !isSelected && !disabled && (
            <div className={cn(
              "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-24 h-24 z-30 pointer-events-none",
              rippleClassName
            )}>
              <div 
                className="absolute inset-0 border-2 border-primary/30 rounded-full"
                style={{
                  animation: 'ripple 2.5s ease-out infinite'
                }}
              />
              <div 
                className="absolute inset-0 border-2 border-primary/40 rounded-full"
                style={{
                  animation: 'ripple 2.5s cubic-bezier(0, 0.2, 0.8, 1) infinite 0.5s'
                }}
              />
              <div 
                className="absolute inset-0 border-2 border-primary/50 rounded-full"
                style={{
                  animation: 'ripple 2.5s cubic-bezier(0, 0.2, 0.8, 1) infinite 1s'
                }}
              />
            </div>
          )}

          <button
            onClick={handleClick}
            onMouseEnter={() => setIsButtonHovered(true)}
            onMouseLeave={() => setIsButtonHovered(false)}
            disabled={isAnimating || disabled}
            aria-label={isSelected ? "Deactivate card" : "Activate card"}
            className={cn(
              "relative z-40 w-14 h-14 rounded-full flex items-center justify-center transition-all duration-300",
              isAnimating || disabled ? 'opacity-50 cursor-not-allowed' : '',
              isButtonHovered || isSelected 
                ? 'bg-primary shadow-lg shadow-primary/50 hover:scale-110 active:scale-95' 
                : 'bg-transparent border-2 border-primary/50',
              iconClassName
            )}
          >
            <svg
              className={cn(
                "w-7 h-7 transition-colors duration-300",
                isButtonHovered || isSelected ? 'text-primary-foreground' : 'text-primary'
              )}
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
              strokeWidth={2.5}
            >
              {isSelected ? (
                <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
              ) : (
                <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
              )}
            </svg>
          </button>
        </div>
      </div>

      <style jsx>{`
        @keyframes ripple {
          0% {
            transform: scale(0.5);
            opacity: 1;
          }
          100% {
            transform: scale(3);
            opacity: 0;
          }
        }
      `}</style>
    </div>
  );
});

FlashyCard.displayName = "FlashyCard";
FlashyCardDefault.displayName = "FlashyCardDefault";
FlashyCardActive.displayName = "FlashyCardActive";

export { FlashyCard, FlashyCardDefault, FlashyCardActive };

Installation

npx shadcn@latest add @scrollxui/flashy-card

Usage

import { FlashyCard } from "@/components/flashy-card"
<FlashyCard />