Skip to content
jswnthui

Parallax

Parallax Card

A 3D perspective-tilted card with scroll-linked parallax entry and interactive spring physics.

Ready when
you are.

Grab the latest build and make it yours in minutes.

Installation

  1. 1. Install the dependencies.

    terminal
    npm install framer-motion lucide-react clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/parallax-card.tsx
    import React, { useEffect, useRef, useState } from "react";
    import {
      AnimatePresence,
      motion,
      useScroll,
      useTransform,
      useSpring,
      useReducedMotion,
    } from "framer-motion";
    import {
      ArrowRight,
      Camera,
      Image as ImageIcon,
      Sparkles,
      Zap,
    } from "lucide-react";
    import { cn } from "@/lib/utils";
    
    export interface PlatformItem {
      icon?: React.ComponentType<{ className?: string }>;
      name: string;
      note: string;
      href?: string;
    }
    
    export interface ParallaxCardProps {
      initialIndex?: number;
      tag?: string;
      title?: string;
      highlightTitle?: string;
      description?: string;
      platforms?: PlatformItem[];
      className?: string;
    }
    
    function ReactIcon({ className }: { className?: string }) {
      return (
        <svg
          viewBox="-11.5 -10.23174 23 20.46348"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.2"
          className={className}
        >
          <circle cx="0" cy="0" r="2.05" fill="currentColor" stroke="none" />
          <g stroke="currentColor">
            <ellipse rx="11" ry="4.2" />
            <ellipse rx="11" ry="4.2" transform="rotate(60)" />
            <ellipse rx="11" ry="4.2" transform="rotate(120)" />
          </g>
        </svg>
      );
    }
    
    const defaultPlatforms: PlatformItem[] = [
      { icon: ReactIcon, name: "React", note: "React 19 & Astro ready", href: "#" },
      { icon: Zap, name: "Parallax & Fast", note: "Ultra fast 60 FPS physics", href: "#" },
      { icon: Sparkles, name: "Latest release", note: "To have fun", href: "#" },
    ];
    
    export function ParallaxCard({
      initialIndex = 0,
      tag,
      title = "Ready when",
      highlightTitle = "you are.",
      description = "Grab the latest build and make it yours in minutes.",
      platforms = defaultPlatforms,
      className,
    }: ParallaxCardProps) {
      const [activeCard, setActiveCard] = useState<number>(initialIndex);
      const totalCards = 3;
    
      const sectionRef = useRef<HTMLDivElement>(null);
      const cardDOMRef = useRef<HTMLDivElement>(null);
      const shouldReduceMotion = useReducedMotion();
    
      // Elastic Spring Physics Ref for 3D perspective tilt & stretch
      const physicsRef = useRef({
        targetX: 0,
        targetY: 0,
        smoothX: 0,
        smoothY: 0,
        x: 0,
        y: 0,
        vx: 0,
        vy: 0,
        rotX: 0,
        rotY: 0,
        vRotX: 0,
        vRotY: 0,
        isHovered: false,
      });
    
      // Scroll-linked parallax progress
      const { scrollYProgress } = useScroll({
        target: sectionRef,
        offset: ["start end", "end start"],
      });
    
      const smoothProgress = useSpring(scrollYProgress, {
        stiffness: 110,
        damping: 24,
        mass: 0.6,
      });
    
      const cardY = useTransform(smoothProgress, [0.08, 0.42, 0.62, 0.95], [60, 0, 0, -15]);
      const cardScale = useTransform(smoothProgress, [0.08, 0.42, 0.62, 0.95], [0.94, 1, 1, 0.96]);
      const cardOpacity = useTransform(smoothProgress, [0.08, 0.38, 0.65, 0.95], [0.4, 1, 1, 0.4]);
      const leftX = useTransform(smoothProgress, [0.12, 0.42], [-18, 0]);
      const rightX = useTransform(smoothProgress, [0.12, 0.42], [18, 0]);
    
      // 120 FPS Direct GPU Elastic Simulation Loop
      useEffect(() => {
        if (shouldReduceMotion) return;
    
        let animId: number;
        const stiffness = 0.045;
        const damping = 0.78;
        const maxPull = 22;
        const maxTilt = 4.5;
    
        const updateLoop = () => {
          const p = physicsRef.current;
          const el = cardDOMRef.current;
    
          const targetX = p.isHovered ? p.targetX : 0;
          const targetY = p.isHovered ? p.targetY : 0;
    
          p.smoothX += (targetX - p.smoothX) * 0.12;
          p.smoothY += (targetY - p.smoothY) * 0.12;
    
          // Displacement
          const destX = p.smoothX * maxPull;
          const destY = p.smoothY * maxPull;
          const ax = (destX - p.x) * stiffness;
          const ay = (destY - p.y) * stiffness;
    
          p.vx = (p.vx + ax) * damping;
          p.vy = (p.vy + ay) * damping;
          p.x += p.vx;
          p.y += p.vy;
    
          // 3D Perspective Tilt
          const destRotX = -p.smoothY * maxTilt;
          const destRotY = p.smoothX * maxTilt;
          const aRotX = (destRotX - p.rotX) * stiffness;
          const aRotY = (destRotY - p.rotY) * stiffness;
    
          p.vRotX = (p.vRotX + aRotX) * damping;
          p.vRotY = (p.vRotY + aRotY) * damping;
          p.rotX += p.vRotX;
          p.rotY += p.vRotY;
    
          // Elastic stretch
          const stretchX = 1 + (Math.abs(p.x) / maxPull) * 0.015 - (Math.abs(p.y) / maxPull) * 0.006;
          const stretchY = 1 + (Math.abs(p.y) / maxPull) * 0.015 - (Math.abs(p.x) / maxPull) * 0.006;
    
          if (el) {
            el.style.transform = `perspective(1400px) translate3d(${p.x.toFixed(2)}px, ${p.y.toFixed(2)}px, 0px) rotateX(${p.rotX.toFixed(2)}deg) rotateY(${p.rotY.toFixed(2)}deg) scaleX(${stretchX.toFixed(4)}) scaleY(${stretchY.toFixed(4)})`;
    
            const glowX = (50 + p.smoothX * 35).toFixed(1);
            const glowY = (50 + p.smoothY * 35).toFixed(1);
            const shadowY = (16 + Math.abs(p.y) * 0.4).toFixed(1);
            const shadowBlur = (32 + Math.abs(p.x) * 0.5).toFixed(1);
    
            el.style.boxShadow = `0 ${shadowY}px ${shadowBlur}px rgba(0, 0, 0, 0.35), radial-gradient(circle at ${glowX}% ${glowY}%, var(--color-accent, rgba(235, 90, 55, 0.14)), transparent 65%), inset 0 1px 0 rgba(255, 255, 255, 0.12)`;
          }
    
          animId = requestAnimationFrame(updateLoop);
        };
    
        animId = requestAnimationFrame(updateLoop);
        return () => cancelAnimationFrame(animId);
      }, [shouldReduceMotion]);
    
      const handlePointerMove = (e: React.MouseEvent<HTMLDivElement>) => {
        if (!cardDOMRef.current) return;
        const rect = cardDOMRef.current.getBoundingClientRect();
        const nx = (e.clientX - rect.left) / rect.width - 0.5;
        const ny = (e.clientY - rect.top) / rect.height - 0.5;
    
        const p = physicsRef.current;
        p.targetX = Math.max(-1, Math.min(1, nx * 2));
        p.targetY = Math.max(-1, Math.min(1, ny * 2));
        p.isHovered = true;
      };
    
      const handlePointerEnter = () => {
        physicsRef.current.isHovered = true;
      };
    
      const handlePointerLeave = () => {
        const p = physicsRef.current;
        p.isHovered = false;
        p.targetX = 0;
        p.targetY = 0;
      };
    
      const handleNextCard = () => {
        setActiveCard((prev) => (prev + 1) % totalCards);
      };
    
      return (
        <div
          ref={sectionRef}
          className={cn(
            "relative flex w-full items-center justify-center overflow-hidden py-6 sm:py-10",
            className
          )}
        >
          {/* Scroll-Animated Wrapper */}
          <motion.div
            style={
              shouldReduceMotion
                ? undefined
                : {
                    y: cardY,
                    scale: cardScale,
                    opacity: cardOpacity,
                  }
            }
            className="relative z-10 w-full max-w-5xl px-2 sm:px-4"
          >
            {/* Elastic 3D Card with Upper Rounded Corners Only */}
            <div
              ref={cardDOMRef}
              onMouseMove={handlePointerMove}
              onMouseEnter={handlePointerEnter}
              onMouseLeave={handlePointerLeave}
              className="relative min-h-[460px] rounded-t-[2.25rem] sm:rounded-t-[3rem] border-t border-x border-line/80 bg-surface/90 p-6 sm:p-10 lg:p-12 shadow-2xl backdrop-blur-xl transition-colors"
              style={{
                willChange: "transform, box-shadow",
                transformOrigin: "center center",
              }}
            >
              {/* Top highlight line across upper curved rim */}
              <div
                aria-hidden="true"
                className="pointer-events-none absolute -top-[1px] left-1/2 -translate-x-1/2 h-[1.5px] w-3/4 max-w-xl bg-gradient-to-r from-transparent via-accent/50 to-transparent opacity-80"
              />
    
              {/* Invisible Top-Left Hotspot to switch cards on click */}
              <button
                type="button"
                onClick={handleNextCard}
                aria-label="Switch card"
                className="absolute top-0 left-0 z-30 size-28 sm:size-36 rounded-tl-[2.25rem] sm:rounded-tl-[3rem] cursor-pointer bg-transparent focus:outline-none"
                title="Click top-left to switch card"
              />
    
              {/* Dynamic Card Transition Layer */}
              <AnimatePresence mode="wait">
                {activeCard === 0 && (
                  <motion.div
                    key="card-01"
                    initial={{ opacity: 0, filter: "blur(8px)", scale: 0.99 }}
                    animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
                    exit={{ opacity: 0, filter: "blur(8px)", scale: 0.99 }}
                    transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
                    className="grid grid-cols-1 gap-8 lg:grid-cols-12 lg:gap-12"
                  >
                    {/* Left Column: Heading & Subtitle */}
                    <motion.div
                      style={shouldReduceMotion ? undefined : { x: leftX }}
                      className="flex flex-col justify-center space-y-4 lg:col-span-7"
                    >
                      <div className="space-y-3.5">
                        {tag && (
                          <span className="inline-flex items-center gap-1.5 font-mono text-xs font-semibold tracking-wider text-accent uppercase">
                            <Sparkles className="size-3.5" />
                            {tag}
                          </span>
                        )}
    
                        {/* Bricolage Grotesque Main Heading */}
                        <h2 className="font-[family-name:var(--font-heading)] text-3xl font-bold tracking-tight text-ink sm:text-4xl lg:text-5xl leading-[1.08]">
                          {title}
                          <br />
                          <span className="text-accent">{highlightTitle}</span>
                        </h2>
    
                        <p className="max-w-md text-sm sm:text-base leading-relaxed text-muted">
                          {description}
                        </p>
                      </div>
                    </motion.div>
    
                    {/* Right Column: Feature / Stack Items */}
                    <motion.div
                      style={shouldReduceMotion ? undefined : { x: rightX }}
                      className="flex flex-col justify-center divide-y divide-line/60 lg:col-span-5"
                    >
                      {platforms.map((platform) => {
                        const Icon = platform.icon || Sparkles;
                        return (
                          <a
                            key={platform.name}
                            href={platform.href || "#"}
                            className="group flex items-center gap-3.5 py-4 px-2 rounded-lg transition-all duration-200 hover:translate-x-1 hover:bg-elevated/60"
                          >
                            <div className="grid size-10 shrink-0 place-items-center rounded-lg border border-line bg-elevated/70 text-ink transition-colors group-hover:border-accent/50 group-hover:text-accent">
                              <Icon className="size-4.5" />
                            </div>
    
                            <div className="flex-1 min-w-0">
                              <p className="font-[family-name:var(--font-heading)] text-sm font-semibold text-ink transition-colors group-hover:text-accent">
                                {platform.name}
                              </p>
                              <p className="text-xs text-muted">{platform.note}</p>
                            </div>
    
                            <ArrowRight className="size-4 text-muted/60 transition-all duration-200 group-hover:translate-x-1 group-hover:text-accent" />
                          </a>
                        );
                      })}
                    </motion.div>
                  </motion.div>
                )}
    
                {activeCard === 1 && (
                  <motion.div
                    key="card-02"
                    initial={{ opacity: 0, filter: "blur(8px)", scale: 0.99 }}
                    animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
                    exit={{ opacity: 0, filter: "blur(8px)", scale: 0.99 }}
                    transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
                    className="w-full"
                  >
                    {/* Pure Photo Placeholder Container */}
                    <div className="group/photo relative flex min-h-[340px] sm:min-h-[380px] w-full items-center justify-center overflow-hidden rounded-2xl border border-line/80 bg-canvas p-6 backdrop-blur-md">
                      {/* Subtle Grid Background */}
                      <div className="absolute inset-0 opacity-20 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:16px_16px]" />
    
                      {/* Framing Corner Marks */}
                      <div className="pointer-events-none absolute top-4 left-4 size-5 border-t-2 border-l-2 border-accent/60 z-10" />
                      <div className="pointer-events-none absolute top-4 right-4 size-5 border-t-2 border-r-2 border-accent/60 z-10" />
                      <div className="pointer-events-none absolute bottom-4 left-4 size-5 border-b-2 border-l-2 border-accent/60 z-10" />
                      <div className="pointer-events-none absolute bottom-4 right-4 size-5 border-b-2 border-r-2 border-accent/60 z-10" />
    
                      {/* Center Placeholder Icon */}
                      <div className="relative z-10 flex items-center justify-center">
                        <div className="grid size-16 place-items-center rounded-2xl border border-accent/30 bg-surface/90 text-accent shadow-md backdrop-blur-md transition-transform duration-300 group-hover/photo:scale-110">
                          <ImageIcon className="size-8" />
                        </div>
                      </div>
                    </div>
                  </motion.div>
                )}
    
                {activeCard === 2 && (
                  <motion.div
                    key="card-03"
                    initial={{ opacity: 0, filter: "blur(8px)", scale: 0.99 }}
                    animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
                    exit={{ opacity: 0, filter: "blur(8px)", scale: 0.99 }}
                    transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
                    className="w-full"
                  >
                    {/* Pure Photo Placeholder Frame */}
                    <div className="group/photo relative flex min-h-[340px] sm:min-h-[380px] w-full items-center justify-center overflow-hidden rounded-2xl border border-line/80 bg-canvas p-6 backdrop-blur-md">
                      {/* Subtle Grid Background */}
                      <div className="absolute inset-0 opacity-20 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:16px_16px]" />
    
                      {/* Framing Corner Marks */}
                      <div className="pointer-events-none absolute top-4 left-4 size-5 border-t-2 border-l-2 border-line group-hover/photo:border-accent/60 transition-colors z-10" />
                      <div className="pointer-events-none absolute top-4 right-4 size-5 border-t-2 border-r-2 border-line group-hover/photo:border-accent/60 transition-colors z-10" />
                      <div className="pointer-events-none absolute bottom-4 left-4 size-5 border-b-2 border-l-2 border-line group-hover/photo:border-accent/60 transition-colors z-10" />
                      <div className="pointer-events-none absolute bottom-4 right-4 size-5 border-b-2 border-r-2 border-line group-hover/photo:border-accent/60 transition-colors z-10" />
    
                      {/* Center Camera Placeholder Icon */}
                      <div className="relative z-10 flex items-center justify-center">
                        <div className="grid size-16 place-items-center rounded-2xl border border-line bg-surface/90 text-muted shadow-md backdrop-blur-md transition-transform duration-300 group-hover/photo:scale-110 group-hover/photo:text-accent group-hover/photo:border-accent/30">
                          <Camera className="size-8" />
                        </div>
                      </div>
                    </div>
                  </motion.div>
                )}
              </AnimatePresence>
            </div>
          </motion.div>
        </div>
      );
    }
    
    export default ParallaxCard;

Props

PropTypeDefaultDescription
titlestring"Ready when"Main headline text.
highlightTitlestring"you are."Accent-highlighted headline part.
descriptionstring"Grab the latest build..."Supporting subtitle copy.
platformsPlatformItem[]—List of items with icons and notes (e.g. React, Parallax & Fast, Latest release).
tagstring—Optional eyebrow badge above the headline.