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.
import { ParallaxCard } from "@/components/ui/parallax-card";
export default function ParallaxCardDemo({ fullscreen = false }: { fullscreen?: boolean }) {
return (
<div className={fullscreen ? "w-full min-h-screen grid place-items-center bg-canvas p-4 sm:p-10" : "w-full"}>
<ParallaxCard
title="Ready when"
highlightTitle="you are."
description="Grab the latest build and make it yours in minutes."
/>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install framer-motion lucide-react clsx tailwind-merge2. Add the
cnhelper, 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. 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
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | "Ready when" | Main headline text. |
| highlightTitle | string | "you are." | Accent-highlighted headline part. |
| description | string | "Grab the latest build..." | Supporting subtitle copy. |
| platforms | PlatformItem[] | — | List of items with icons and notes (e.g. React, Parallax & Fast, Latest release). |
| tag | string | — | Optional eyebrow badge above the headline. |