Parallax Card
A tilting card that broadcasts pointer position as CSS variables, so layered content parallaxes with it.
Depth on demand
One shape at rest. Tilt the card and the stack fans open into a well.
ParallaxCard tilts in 3D toward the pointer and exposes the pointer position
as unitless --px / --py custom properties. Any ParallaxLayer inside reads
those vars and counter-translates in pure CSS, scaled by its depth, so one
spring pair drives every layer at once. ParallaxStack extrudes a shape into a
well or a stack by rendering it at a range of depths. Built with
Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/parallax-card.tsx.
'use client';import * as React from 'react';import { motion, useReducedMotion, useSpring, useTransform } from 'motion/react';import { cn } from '@/lib/cn';const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--parallax-accent)]';export type ParallaxCardProps = { maxTilt?: number; shift?: number; accent?: string; children?: React.ReactNode; className?: string;};export function ParallaxCard({ maxTilt = 10, shift = 14, accent = '#f0883e', children, className,}: ParallaxCardProps) { const reduce = useReducedMotion(); const ref = React.useRef<HTMLDivElement>(null); const spring = { stiffness: 200, damping: 22, mass: 0.6 } as const; const px = useSpring(0, spring); const py = useSpring(0, spring); const lift = useSpring(0, spring); const rotateX = useTransform(py, (v) => -v * maxTilt * 2); const rotateY = useTransform(px, (v) => v * maxTilt * 2); const y = useTransform(lift, (l) => l * -6); const onMove = (e: React.PointerEvent<HTMLDivElement>) => { if (reduce) return; const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); px.set((e.clientX - r.left) / r.width - 0.5); py.set((e.clientY - r.top) / r.height - 0.5); }; const onLeave = () => { px.set(0); py.set(0); lift.set(0); }; return ( <div style={ { perspective: 900, '--parallax-accent': accent, '--parallax-shift': `${shift}px`, } as React.CSSProperties } className={cn('relative', className)} > <motion.div ref={ref} tabIndex={0} onPointerMove={onMove} onPointerEnter={() => !reduce && lift.set(1)} onPointerLeave={onLeave} whileTap={reduce ? undefined : { scale: 0.985 }} style={{ rotateX, rotateY, y, borderRadius: 14, ['--px' as string]: px, ['--py' as string]: py, }} className={cn( 'bloom-edge group relative overflow-hidden', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_8px_22px_-12px_rgba(0,0,0,0.18)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_12px_28px_-14px_rgba(0,0,0,0.6)]', FOCUS, )} > {children ?? <ParallaxCardSample />} </motion.div> </div> );}export type ParallaxLayerProps = { depth?: number; children?: React.ReactNode; className?: string;};export function ParallaxLayer({ depth = 1, children, className }: ParallaxLayerProps) { return ( <div style={ { '--i': depth, translate: 'calc(var(--px, 0) * var(--i) * var(--parallax-shift, 14px)) calc(var(--py, 0) * var(--i) * var(--parallax-shift, 14px))', willChange: 'translate', } as React.CSSProperties } className={className} > {children} </div> );}export type ParallaxStackProps = { layers?: number; from?: number; to?: number; fade?: boolean; children?: React.ReactNode | ((t: number, index: number) => React.ReactNode); className?: string;};export function ParallaxStack({ layers = 6, from = -0.3, to = -3.6, fade = true, children, className,}: ParallaxStackProps) { const steps = Math.max(2, layers); const renderChild = (t: number, index: number) => typeof children === 'function' ? children(t, index) : children; const clones = Array.from({ length: steps - 1 }, (_, i) => { const index = steps - 1 - i; const t = index / (steps - 1); return { index, t, depth: from + (to - from) * t, opacity: fade ? 1 - t * 0.72 : 1 }; }); return ( <div className={cn('relative', className)}> {clones.map((c) => ( <ParallaxLayer key={c.index} depth={c.depth} className="absolute inset-0"> <div aria-hidden style={{ opacity: c.opacity }} className="size-full"> {renderChild(c.t, c.index)} </div> </ParallaxLayer> ))} <ParallaxLayer depth={from}>{renderChild(0, 0)}</ParallaxLayer> </div> );}function WallDisc({ t }: { t: number }) { const vars = { '--wall-light': `${Math.round(56 + t * 26)}%`, '--wall-dark': `${Math.round(52 - t * 22)}%`, } as React.CSSProperties; return ( <span style={vars} className="block size-full rounded-full border border-black/15 bg-[hsl(0_0%_var(--wall-light))] dark:border-white/10 dark:bg-[hsl(0_0%_var(--wall-dark))]" /> );}function WeaveDisc() { const id = React.useId(); return ( <svg viewBox="0 0 200 200" className="size-full" aria-hidden> <defs> <pattern id={`${id}a`} width="3" height="3" patternUnits="userSpaceOnUse" patternTransform="rotate(45)" > <rect width="3" height="0.9" fill="#fff" /> </pattern> <pattern id={`${id}b`} width="3" height="3" patternUnits="userSpaceOnUse" patternTransform="rotate(-45)" > <rect width="3" height="0.9" fill="#fff" /> </pattern> </defs> <circle cx="100" cy="100" r="99" className="fill-[#161616] dark:fill-[#0a0a0a]" /> <circle cx="100" cy="100" r="99" fill={`url(#${id}a)`} opacity="0.09" /> <circle cx="100" cy="100" r="99" fill={`url(#${id}b)`} opacity="0.09" /> <circle cx="100" cy="100" r="99" fill="none" stroke="currentColor" strokeOpacity="0.5" strokeWidth="1.25" className="text-neutral-900 dark:text-white" /> </svg> );}function ParallaxCardSample() { return ( <div className="relative h-[260px] w-[340px] max-w-full overflow-hidden p-6"> <div aria-hidden className="pointer-events-none absolute -right-[70px] top-1/2 -mt-[100px] size-[200px]" > <ParallaxStack layers={6} from={-0.5} to={-8} fade={false} className="size-full"> {(t) => (t === 0 ? <WeaveDisc /> : <WallDisc t={t} />)} </ParallaxStack> </div> <ParallaxLayer depth={1.4} className="absolute left-6 top-6"> <span className="bloom-edge grid size-10 place-items-center rounded-[10px] bg-black/[0.03] text-neutral-700 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:bg-black/20 dark:text-neutral-200 dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden > <path d="M2 12h4l3-8 4 16 3-8h6" /> </svg> </span> </ParallaxLayer> <ParallaxLayer depth={0.5} className="absolute bottom-6 left-6 max-w-[180px]"> <h3 className="text-[15px] font-semibold tracking-[-0.01em]"> Depth on demand </h3> <p className="mt-1 text-[12px] leading-relaxed text-neutral-500 dark:text-[#929292]"> One shape at rest. Tilt the card and the stack fans open into a well. </p> </ParallaxLayer> </div> );}Usage
import { ParallaxCard, ParallaxLayer } from '@/components/ui/parallax-card';
export default function Example() {
return (
<ParallaxCard>
<div className="p-6">
<ParallaxLayer depth={1.4}>
<h3 className="text-[15px] font-semibold">Depth on demand</h3>
</ParallaxLayer>
<ParallaxLayer depth={0.5} className="mt-2">
<p className="text-[12px] text-neutral-500">
Layers ride at different depths as the card tilts.
</p>
</ParallaxLayer>
</div>
</ParallaxCard>
);
}Examples
ParallaxStack extrudes one shape into a fanned stack of copies.
A dashboard-style card with a badge, a chart, and copy all riding at different depths.
Weekly active
48,205
Badge, chart, and copy sit at different depths, so the card reads as a physical object when it leans.
Props
ParallaxCard
| Prop | Type | Default | Description |
|---|---|---|---|
maxTilt | number | 10 | Maximum tilt in degrees at the card's edges. |
shift | number | 14 | How far (px) a depth-1 layer travels at the card's edge. |
accent | string | "#f0883e" | Carried by --parallax-accent (focus ring, sample art). |
children | React.ReactNode | - | Custom content; falls back to a sample card. |
className | string | - | Forwarded to the outer wrapper. |
ParallaxLayer
| Prop | Type | Default | Description |
|---|---|---|---|
depth | number | 1 | Positive rides with the pointer, negative runs opposite, 0 is glued. |
children | React.ReactNode | - | Content to translate. |
className | string | - | Forwarded to the layer. |
ParallaxStack
| Prop | Type | Default | Description |
|---|---|---|---|
layers | number | 6 | How many stacked copies to render. |
from | number | -0.3 | Depth of the visible top copy. |
to | number | -3.6 | Depth of the deepest copy; it travels furthest. |
fade | boolean | true | Fade deeper copies so the stack reads as receding. |
children | React.ReactNode | ((t: number, index: number) => React.ReactNode) | - | The shape to extrude, or a render function by depth. |
className | string | - | Forwarded to the stack wrapper. |