liten

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.

Terminal
bun add motion

Copy the component into components/ui/parallax-card.tsx.

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

Example.tsx
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

+12.4%

Badge, chart, and copy sit at different depths, so the card reads as a physical object when it leans.

Props

ParallaxCard

PropTypeDefaultDescription
maxTiltnumber10Maximum tilt in degrees at the card's edges.
shiftnumber14How far (px) a depth-1 layer travels at the card's edge.
accentstring"#f0883e"Carried by --parallax-accent (focus ring, sample art).
childrenReact.ReactNode-Custom content; falls back to a sample card.
classNamestring-Forwarded to the outer wrapper.

ParallaxLayer

PropTypeDefaultDescription
depthnumber1Positive rides with the pointer, negative runs opposite, 0 is glued.
childrenReact.ReactNode-Content to translate.
classNamestring-Forwarded to the layer.

ParallaxStack

PropTypeDefaultDescription
layersnumber6How many stacked copies to render.
fromnumber-0.3Depth of the visible top copy.
tonumber-3.6Depth of the deepest copy; it travels furthest.
fadebooleantrueFade deeper copies so the stack reads as receding.
childrenReact.ReactNode | ((t: number, index: number) => React.ReactNode)-The shape to extrude, or a render function by depth.
classNamestring-Forwarded to the stack wrapper.
On this page0%