3D Tilt Card with Parallax Layers and Glare

A season pass card that tilts in 3D toward the pointer. The sky, sun, three mountain ridges and the text each slide by a different amount as it turns, which gives real depth, while a foil sheen and a soft glare travel across the face. When the pointer is away the card keeps turning slowly on its own. It sits beside a headline and two buttons, and stacks under them on phones.

Code

import { useEffect, useRef } from "react";

const pass = {
  park: "Kestrel Ridge",
  kind: "Season Pass",
  year: "2026",
  holder: "Maya Oduya",
  number: "No. 04127",
  valid: "May 1 to Oct 31",
};

const perks = [
  { value: "212 km", label: "of marked trail" },
  { value: "9", label: "backcountry huts" },
  { value: "$89", label: "for the season" },
];

/** Pixels each layer slides per degree of tilt. The scenery sits behind the card face, the text floats in front of it, so it slides the other way. */
const DEPTH = { sky: 0.2, sun: 0.6, far: 1.1, mid: 1.8, near: 2.7, text: -0.8 };

export default function TiltCardWithParallaxLayersAndGlare() {
  const zoneRef = useRef<HTMLDivElement>(null);
  const cardRef = useRef<HTMLDivElement>(null);
  const shadowRef = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    const zone = zoneRef.current;
    const card = cardRef.current;
    if (!zone || !card) return;
    const layers = Array.from(card.querySelectorAll<HTMLElement>("[data-depth]"));
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
    let raf = 0;
    let visible = true;
    let held = false;
    const start = performance.now();
    let rx = 0;
    let ry = 0;
    let tx = 0;
    let ty = 0;

    const paint = () => {
      card.style.transform = `rotateX(${rx.toFixed(2)}deg) rotateY(${ry.toFixed(2)}deg)`;
      card.style.setProperty("--gx", `${(50 + ry * 3).toFixed(1)}%`);
      card.style.setProperty("--gy", `${(42 - rx * 3.4).toFixed(1)}%`);
      card.style.setProperty("--sheen", `${(50 + ry * 3.2 - rx * 1.5).toFixed(1)}%`);
      for (const l of layers) {
        const d = Number(l.dataset.depth);
        l.style.transform = `translate3d(${(ry * d).toFixed(2)}px, ${(-rx * d).toFixed(2)}px, 0)`;
      }
      if (shadowRef.current) shadowRef.current.style.transform = `translate3d(${(-ry * 2.2).toFixed(1)}px, ${(rx * 2.2).toFixed(1)}px, 0)`;
    };
    const frame = (now: number) => {
      raf = 0;
      if (!held) {
        // At rest the card turns slowly on its own.
        const t = (now - start) / 1000;
        ty = 13 * Math.sin(t);
        tx = 7 * Math.sin(t * 0.62 + 1.1);
      }
      rx += (tx - rx) * 0.09;
      ry += (ty - ry) * 0.09;
      paint();
      schedule();
    };
    const schedule = () => {
      if (!raf && visible && !document.hidden && !reduce.matches) raf = requestAnimationFrame(frame);
    };
    const onMove = (e: PointerEvent) => {
      const r = zone.getBoundingClientRect();
      const px = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
      const py = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
      held = true;
      ty = (px - 0.5) * 34;
      tx = (0.5 - py) * 26;
      if (reduce.matches) {
        rx = tx * 0.4;
        ry = ty * 0.4;
        paint();
      }
    };
    const release = () => {
      held = false;
    };
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      schedule();
    });
    io.observe(zone);
    zone.addEventListener("pointermove", onMove);
    zone.addEventListener("pointerleave", release);
    zone.addEventListener("pointercancel", release);
    document.addEventListener("visibilitychange", schedule);
    if (reduce.matches) {
      rx = 4;
      ry = -9;
    }
    paint();
    schedule();
    return () => {
      cancelAnimationFrame(raf);
      io.disconnect();
      zone.removeEventListener("pointermove", onMove);
      zone.removeEventListener("pointerleave", release);
      zone.removeEventListener("pointercancel", release);
      document.removeEventListener("visibilitychange", schedule);
    };
  }, []);

  return (
    <section className="overflow-hidden bg-orange-50 px-5 py-16 text-stone-900 sm:px-6 sm:py-20">
      <style>{`
        @keyframes tilt-card-with-parallax-layers-and-glare-drop {
          from { opacity: 0; transform: translateY(46px) rotateX(38deg) rotateZ(-7deg) scale(0.86); }
          to { opacity: 1; transform: none; }
        }
        @keyframes tilt-card-with-parallax-layers-and-glare-fade {
          from { opacity: 0; transform: translateY(14px); }
          to { opacity: 1; transform: none; }
        }
        .tilt-card-with-parallax-layers-and-glare-drop {
          animation: tilt-card-with-parallax-layers-and-glare-drop 1s cubic-bezier(0.2, 1.1, 0.3, 1) both;
        }
        .tilt-card-with-parallax-layers-and-glare-fade {
          animation: tilt-card-with-parallax-layers-and-glare-fade 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        @media (prefers-reduced-motion: reduce) {
          .tilt-card-with-parallax-layers-and-glare-drop,
          .tilt-card-with-parallax-layers-and-glare-fade { animation: none; }
        }
      `}</style>
      <div className="mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-2 lg:gap-8">
        <div className="tilt-card-with-parallax-layers-and-glare-fade">
          <p className="text-sm font-semibold uppercase tracking-widest text-orange-700">Season passes are open</p>
          <h2 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">The whole ridge, on one card in your pocket.</h2>
          <p className="mt-5 max-w-md text-base leading-7 text-stone-600">One pass covers every trailhead, hut booking and shuttle on Kestrel Ridge from the first thaw to the last light of October.</p>
          <div className="mt-8 flex flex-wrap gap-3">
            <a href="#" className="inline-flex h-12 items-center rounded-full bg-stone-900 px-6 text-sm font-semibold text-white transition hover:bg-stone-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-50">
              Get the pass
            </a>
            <a href="#" className="inline-flex h-12 items-center rounded-full border border-stone-300 px-6 text-sm font-semibold text-stone-900 transition hover:border-stone-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-50">
              See the trail map
            </a>
          </div>
          <dl className="mt-10 flex max-w-md gap-8 border-t border-stone-300 pt-6">
            {perks.map((p) => (
              <div key={p.label}>
                <dt className="sr-only">{p.label}</dt>
                <dd className="text-2xl font-semibold tracking-tight">{p.value}</dd>
                <dd className="mt-1 text-xs text-stone-600">{p.label}</dd>
              </div>
            ))}
          </dl>
        </div>

        {/* The pointer zone is larger than the card, so it starts turning before you reach it. */}
        <div ref={zoneRef} className="relative flex items-center justify-center py-6 lg:py-10" style={{ perspective: "1100px", touchAction: "pan-y" }}>
          <div className="tilt-card-with-parallax-layers-and-glare-drop relative">
            <span ref={shadowRef} aria-hidden="true" className="absolute inset-x-6 -bottom-8 top-16 rounded-full bg-orange-900/40 blur-2xl" />
            <div
              ref={cardRef}
              role="img"
              aria-label={`${pass.park} ${pass.kind} ${pass.year}, issued to ${pass.holder}, ${pass.number}, valid ${pass.valid}`}
              className="relative h-[410px] w-[290px] overflow-hidden rounded-[30px] bg-indigo-950 shadow-2xl shadow-orange-900/30 sm:h-[470px] sm:w-[336px]"
              style={{ transform: "rotateX(4deg) rotateY(-9deg)", willChange: "transform" }}
            >
              <div data-depth={DEPTH.sky} className="absolute -inset-6 bg-gradient-to-b from-indigo-950 via-fuchsia-800 to-orange-400">
                <span className="absolute left-[18%] top-[9%] h-1 w-1 rounded-full bg-white/80" />
                <span className="absolute left-[64%] top-[14%] h-0.5 w-0.5 rounded-full bg-white/70" />
                <span className="absolute left-[82%] top-[7%] h-1 w-1 rounded-full bg-white/60" />
                <span className="absolute left-[38%] top-[21%] h-0.5 w-0.5 rounded-full bg-white/70" />
                <span className="absolute left-[9%] top-[27%] h-0.5 w-0.5 rounded-full bg-white/50" />
              </div>
              <div data-depth={DEPTH.sun} className="absolute inset-0">
                <span className="absolute left-1/2 top-[44%] h-40 w-40 -translate-x-1/2 -translate-y-1/2 rounded-full bg-orange-300/50 blur-2xl" />
                <span className="absolute left-1/2 top-[44%] h-24 w-24 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gradient-to-b from-amber-100 to-orange-300" />
              </div>
              <svg data-depth={DEPTH.far} viewBox="0 0 400 240" preserveAspectRatio="none" aria-hidden="true" className="absolute -inset-x-8 bottom-0 h-[62%] w-[calc(100%+4rem)]">
                <path d="M0 96 L46 70 L88 92 L140 40 L186 84 L228 58 L276 98 L330 46 L372 82 L400 66 V240 H0Z" fill="#a21caf" fillOpacity="0.55" />
              </svg>
              <svg data-depth={DEPTH.mid} viewBox="0 0 400 240" preserveAspectRatio="none" aria-hidden="true" className="absolute -inset-x-10 -bottom-2 h-[50%] w-[calc(100%+5rem)]">
                <path d="M0 110 L60 58 L96 92 L150 30 L212 104 L262 66 L310 108 L356 72 L400 100 V240 H0Z" fill="#581c87" />
              </svg>
              <svg data-depth={DEPTH.near} viewBox="0 0 400 240" preserveAspectRatio="none" aria-hidden="true" className="absolute -inset-x-12 -bottom-4 h-[36%] w-[calc(100%+6rem)]">
                <path d="M0 120 L34 96 L50 110 L62 60 L76 108 L92 84 L108 112 L150 78 L176 104 L190 52 L206 104 L240 90 L270 112 L300 70 L316 108 L332 46 L350 106 L376 88 L400 104 V240 H0Z" fill="#1e1b4b" />
              </svg>
              <div className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-indigo-950 via-indigo-950/70 to-transparent" />

              <div data-depth={DEPTH.text} className="absolute inset-0 flex flex-col justify-between p-6 text-white">
                <div className="flex items-start justify-between">
                  <div>
                    <p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-orange-100">{pass.park}</p>
                    <p className="mt-2 text-3xl font-semibold leading-none tracking-tight">{pass.kind}</p>
                  </div>
                  <span className="rounded-full border border-white/40 px-2.5 py-1 text-xs font-semibold">{pass.year}</span>
                </div>
                <div className="flex items-end justify-between">
                  <div>
                    <p className="text-[10px] uppercase tracking-widest text-indigo-200">Pass holder</p>
                    <p className="mt-1 text-base font-semibold">{pass.holder}</p>
                    <p className="mt-0.5 text-xs text-indigo-200">{pass.valid}</p>
                  </div>
                  <p className="font-mono text-xs text-indigo-100">{pass.number}</p>
                </div>
              </div>

              {/* Foil sheen that slides across as the card turns. */}
              <span
                aria-hidden="true"
                className="pointer-events-none absolute inset-0 mix-blend-screen"
                style={{
                  backgroundImage: "linear-gradient(112deg, transparent 38%, rgba(253,186,116,0.2) 45%, rgba(255,255,255,0.26) 50%, rgba(125,211,252,0.2) 55%, transparent 62%)",
                  backgroundSize: "260% 100%",
                  backgroundPosition: "var(--sheen, 50%) 0",
                }}
              />
              {/* Glare: a soft hot spot that sits where the light would hit. */}
              <span
                aria-hidden="true"
                className="pointer-events-none absolute inset-0"
                style={{ background: "radial-gradient(260px circle at var(--gx, 30%) var(--gy, 28%), rgba(255,255,255,0.3), transparent 62%)" }}
              />
              <span aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-[30px] ring-1 ring-inset ring-white/25" />
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/tilt-card-with-parallax-layers-and-glare.json

When to use it

How to customize it

Accessibility

Questions

How does the 3D tilt work?

The wrapper has `perspective: 1100px` and the card gets `rotateX` and `rotateY` from the pointer position inside that wrapper. The values are eased toward their target on every animation frame, so the card settles instead of snapping.

How is the parallax done if the card has rounded, clipped corners?

Clipping with `overflow-hidden` flattens real 3D children, so the layers are not pushed out on the z axis. Each layer is slid sideways in 2D by the tilt angle times its depth. Near layers move more than far ones, which reads as depth and keeps the rounded corners.

How does the glare follow the tilt?

The glare is a `radial-gradient` positioned with two CSS variables, `--gx` and `--gy`, which are updated from the same tilt values. The foil sheen is a wide diagonal gradient whose `background-position` is driven the same way.

Will it scroll normally on a phone?

Yes. The pointer zone uses `touch-action: pan-y`, so a vertical swipe scrolls the page as usual. Sideways drags turn the card, and the resting rotation runs on its own.

Does it need framer-motion or a 3D library?

No. It is one `useEffect` with `requestAnimationFrame`, plain CSS transforms and inline SVG. The loop stops when the card is off screen or the tab is hidden.

Related: Spotlight Cards with Pointer Light, App Landing Hero with Phone Mockup, Split Hero with Email Capture.

More animated cards or all free UI components. Start for free to have a full site built around it.

More in Animated Cards

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap