A collector-style card for a product launch, printed in holographic foil. The card turns in 3D toward the pointer, and one pair of numbers (where the light sits) drives everything else: the rainbow printed over the artwork, the pearl frame, the foil-stamped lettering and a soft glare. When nobody is touching it, the light wanders on its own so the foil never sits still. On phones the copy stacks above the card and a finger drag tilts it.
import { useEffect, useRef } from "react";
const card = {
product: "Vanta Loop",
kind: "Pocket groovebox",
edition: "Launch Edition",
series: "Series 01",
serial: "0042",
run: "1000",
blurb: "Eight voices, a sixteen-step sequencer and a speaker that fits in a jacket pocket.",
ships: "Ships 14 Nov",
};
const stats = [
{ label: "Voices", value: "8" },
{ label: "Battery", value: "14 h" },
{ label: "Weight", value: "310 g" },
];
/** Most the card turns, in degrees, when the light reaches an edge. */
const TILT = { x: 13, y: 18 };
/** The rainbow the foil is printed with. Fewer, softer stops read as pearl; these read as a trading card. */
const FOIL = "#ff3d77 0%, #ffb23e 9%, #f7f052 18%, #3dff9e 27%, #33c9ff 36%, #7a5cff 45%, #ff3df2 54%, #ff3d77 63%";
const PEARL = "#7dd3fc, #c4b5fd, #f9a8d4, #fde68a, #86efac, #7dd3fc, #c4b5fd, #f9a8d4, #fde68a";
export default function HolographicFoilCardWithTiltDrivenRainbowSheen() {
const zoneRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const shadowRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const zone = zoneRef.current;
const el = cardRef.current;
if (!zone || !el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
let raf = 0;
let visible = true;
let held = false;
const start = performance.now();
// Where the light sits on the card, in percent. Everything else is derived from it.
let px = 66;
let py = 30;
let tx = px;
let ty = py;
const paint = () => {
const ry = ((px - 50) / 50) * TILT.y;
const rx = (-(py - 50) / 50) * TILT.x;
const edge = Math.min(1, Math.hypot(px - 50, py - 50) / 50);
el.style.transform = `rotateX(${rx.toFixed(2)}deg) rotateY(${ry.toFixed(2)}deg)`;
el.style.setProperty("--px", `${px.toFixed(1)}%`);
el.style.setProperty("--py", `${py.toFixed(1)}%`);
el.style.setProperty("--fx", `${(px * 1.4 - 20).toFixed(1)}%`);
el.style.setProperty("--fy", `${(py * 1.2 - 10).toFixed(1)}%`);
el.style.setProperty("--ha", `${((px - 50) * 0.7).toFixed(1)}deg`);
el.style.setProperty("--edge", (0.45 + edge * 0.55).toFixed(3));
if (shadowRef.current) shadowRef.current.style.transform = `translate3d(${(-ry * 2).toFixed(1)}px, ${(rx * 2 + 10).toFixed(1)}px, 0)`;
};
const frame = (now: number) => {
raf = 0;
if (!held) {
// Nobody is touching it: the light wanders so the foil keeps shifting.
const t = (now - start) / 1000;
tx = 50 + 40 * Math.sin(t * 1.15 + 0.5);
ty = 46 + 30 * Math.sin(t * 0.8 + 2.2);
}
px += (tx - px) * 0.085;
py += (ty - py) * 0.085;
paint();
schedule();
};
const schedule = () => {
if (!raf && visible && !document.hidden && !reduce.matches) raf = requestAnimationFrame(frame);
};
const onMove = (e: PointerEvent) => {
const r = zone.getBoundingClientRect();
held = true;
tx = Math.min(100, Math.max(0, ((e.clientX - r.left) / r.width) * 100));
ty = Math.min(100, Math.max(0, ((e.clientY - r.top) / r.height) * 100));
if (reduce.matches) {
px = 50 + (tx - 50) * 0.4;
py = 50 + (ty - 50) * 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);
zone.addEventListener("pointerup", release);
document.addEventListener("visibilitychange", schedule);
paint();
schedule();
return () => {
cancelAnimationFrame(raf);
io.disconnect();
zone.removeEventListener("pointermove", onMove);
zone.removeEventListener("pointerleave", release);
zone.removeEventListener("pointercancel", release);
zone.removeEventListener("pointerup", release);
document.removeEventListener("visibilitychange", schedule);
};
}, []);
return (
<section className="relative overflow-hidden bg-zinc-950 px-5 py-16 text-white sm:px-6 sm:py-20">
<style>{`
@keyframes holographic-foil-card-with-tilt-driven-rainbow-sheen-drop {
from { opacity: 0; transform: translateY(60px) rotateX(42deg) rotateZ(9deg) scale(0.8); }
to { opacity: 1; transform: none; }
}
@keyframes holographic-foil-card-with-tilt-driven-rainbow-sheen-rise {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: none; }
}
.holographic-foil-card-with-tilt-driven-rainbow-sheen-drop { animation: holographic-foil-card-with-tilt-driven-rainbow-sheen-drop 1.1s cubic-bezier(0.2, 1.15, 0.3, 1) both; }
.holographic-foil-card-with-tilt-driven-rainbow-sheen-rise { animation: holographic-foil-card-with-tilt-driven-rainbow-sheen-rise 0.7s cubic-bezier(0.22, 1, 0.36, 1) both; }
.holographic-foil-card-with-tilt-driven-rainbow-sheen-pearl {
background-image: linear-gradient(calc(118deg + var(--ha, 0deg)), ${PEARL});
background-size: 320% 320%;
background-position: var(--fx, 60%) var(--fy, 30%);
}
.holographic-foil-card-with-tilt-driven-rainbow-sheen-rainbow {
background-image: repeating-linear-gradient(calc(122deg + var(--ha, 0deg)), ${FOIL});
background-size: 230% 230%;
background-position: var(--fx, 60%) var(--fy, 30%);
}
.holographic-foil-card-with-tilt-driven-rainbow-sheen-grain {
background-image:
repeating-linear-gradient(90deg, rgba(255,255,255,0.22) 0 1px, transparent 1px 4px),
repeating-linear-gradient(0deg, rgba(0,0,0,0.25) 0 1px, transparent 1px 3px);
}
.holographic-foil-card-with-tilt-driven-rainbow-sheen-light {
background-image: radial-gradient(farthest-corner circle at var(--px, 66%) var(--py, 30%), rgba(255,255,255,0.75), rgba(255,255,255,0.18) 26%, rgba(0,0,0,0.35) 78%);
}
.holographic-foil-card-with-tilt-driven-rainbow-sheen-ink {
background-image: linear-gradient(calc(100deg + var(--ha, 0deg)), ${PEARL});
background-size: 300% 100%;
background-position: var(--fx, 60%) 0;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
@media (prefers-reduced-motion: reduce) {
.holographic-foil-card-with-tilt-driven-rainbow-sheen-drop,
.holographic-foil-card-with-tilt-driven-rainbow-sheen-rise { animation: none; }
}
`}</style>
<span aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 h-[520px] w-[520px] -translate-y-1/2 rounded-full bg-violet-600/20 blur-3xl lg:left-[58%]" />
<span aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/3 h-72 w-72 rounded-full bg-cyan-400/10 blur-3xl lg:left-[72%]" />
<div className="relative mx-auto grid max-w-5xl items-center gap-10 lg:grid-cols-2 lg:gap-6">
<div className="holographic-foil-card-with-tilt-driven-rainbow-sheen-rise">
<p className="inline-flex items-center gap-2 rounded-full border border-white/15 px-3 py-1 text-xs font-medium text-zinc-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Launch week, {card.run} numbered units
</p>
<h2 className="mt-5 text-4xl font-semibold tracking-tight sm:text-5xl">
The first thousand ship with a <span className="holographic-foil-card-with-tilt-driven-rainbow-sheen-ink">foil-stamped</span> card.
</h2>
<p className="mt-5 max-w-md text-base leading-7 text-zinc-400">
Every launch unit of {card.product} is numbered. The card in the box carries the serial, and it is the only way to claim the lifetime sound pack.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<a href="#" className="inline-flex h-12 items-center rounded-full bg-white px-6 text-sm font-semibold text-zinc-950 transition hover:bg-zinc-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950">
Reserve a numbered unit
</a>
<a href="#" className="inline-flex h-12 items-center rounded-full border border-white/20 px-6 text-sm font-semibold text-white transition hover:border-white/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950">
Read the spec sheet
</a>
</div>
<p className="mt-8 text-sm text-zinc-500">Move across the card. The foil follows the light.</p>
</div>
{/* The pointer zone is larger than the card, so the foil starts to shift before you reach it. */}
<div ref={zoneRef} className="relative order-first flex lg:order-none items-center justify-center py-4 lg:py-10" style={{ perspective: "1200px", touchAction: "pan-y" }}>
<div className="holographic-foil-card-with-tilt-driven-rainbow-sheen-drop relative">
<span ref={shadowRef} aria-hidden="true" className="absolute inset-x-8 -bottom-6 top-20 rounded-full bg-fuchsia-500/30 blur-3xl" />
<div
ref={cardRef}
role="img"
aria-label={`${card.product} ${card.edition} collector card, number ${card.serial} of ${card.run}. ${card.blurb}`}
className="holographic-foil-card-with-tilt-driven-rainbow-sheen-pearl relative w-[292px] rounded-[26px] p-[9px] shadow-2xl shadow-black/60 sm:w-[340px]"
style={{ transform: "rotateX(5deg) rotateY(6deg)", willChange: "transform" }}
>
{/* Fine ridges pressed into the frame, like real foil. */}
<span aria-hidden="true" className="holographic-foil-card-with-tilt-driven-rainbow-sheen-grain pointer-events-none absolute inset-0 rounded-[26px] opacity-40 mix-blend-overlay" />
<div className="relative overflow-hidden rounded-[18px] bg-zinc-950">
<div className="flex items-center justify-between px-4 pb-3 pt-4">
<div>
<p className="holographic-foil-card-with-tilt-driven-rainbow-sheen-ink text-xl font-bold uppercase leading-none tracking-wide">{card.product}</p>
<p className="mt-1.5 text-[11px] font-medium uppercase tracking-[0.2em] text-zinc-400">{card.kind}</p>
</div>
<span className="holographic-foil-card-with-tilt-driven-rainbow-sheen-pearl rounded-md px-2 py-1 text-[10px] font-bold uppercase tracking-widest text-zinc-950">{card.series}</span>
</div>
{/* Art window: grey artwork that the foil layers colour in. */}
<div className="relative mx-3 aspect-[4/3] overflow-hidden rounded-xl bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 ring-1 ring-white/20">
<svg viewBox="0 0 280 210" aria-hidden="true" className="absolute inset-0 h-full w-full">
<g stroke="#e2e8f0" strokeOpacity="0.16">
<path d="M0 150 L280 96 M0 176 L280 122 M0 124 L280 70 M0 98 L280 44 M0 72 L280 18" />
</g>
<ellipse cx="146" cy="176" rx="104" ry="13" fill="#020617" fillOpacity="0.55" />
<g transform="rotate(-9 140 105)">
<rect x="38" y="40" width="204" height="126" rx="20" fill="#0f172a" />
<rect x="34" y="34" width="204" height="126" rx="20" fill="#a8b4c4" />
<rect x="34" y="34" width="204" height="126" rx="20" fill="none" stroke="#f8fafc" strokeOpacity="0.8" strokeWidth="1.5" />
<rect x="50" y="50" width="92" height="40" rx="7" fill="#0f172a" />
<path d="M56 70 q6 -18 12 0 t12 0 q5 -12 10 0 t10 0 q6 -16 12 0 t12 0 l6 0" fill="none" stroke="#f8fafc" strokeWidth="2" strokeLinecap="round" />
<circle cx="174" cy="70" r="15" fill="#475569" />
<circle cx="174" cy="70" r="15" fill="none" stroke="#f8fafc" strokeOpacity="0.7" />
<path d="M174 70 L183 60" stroke="#f8fafc" strokeWidth="2.5" strokeLinecap="round" />
<circle cx="212" cy="70" r="15" fill="#475569" />
<circle cx="212" cy="70" r="15" fill="none" stroke="#f8fafc" strokeOpacity="0.7" />
<path d="M212 70 L202 62" stroke="#f8fafc" strokeWidth="2.5" strokeLinecap="round" />
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
<rect key={i} x={50 + (i % 4) * 45} y={102 + Math.floor(i / 4) * 24} width="37" height="18" rx="5" fill={i === 1 || i === 6 ? "#f8fafc" : "#64748b"} />
))}
</g>
</svg>
<span aria-hidden="true" className="holographic-foil-card-with-tilt-driven-rainbow-sheen-rainbow pointer-events-none absolute inset-0 opacity-90 mix-blend-color-dodge" style={{ filter: "saturate(1.3) brightness(0.72)" }} />
<span aria-hidden="true" className="holographic-foil-card-with-tilt-driven-rainbow-sheen-grain pointer-events-none absolute inset-0 opacity-50 mix-blend-overlay" />
<span aria-hidden="true" className="holographic-foil-card-with-tilt-driven-rainbow-sheen-light pointer-events-none absolute inset-0 mix-blend-overlay" />
<span className="absolute bottom-2.5 left-2.5 rounded-full bg-black/60 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-widest text-white backdrop-blur-sm">{card.edition}</span>
</div>
<p className="px-4 pt-4 text-[13px] leading-5 text-zinc-300">{card.blurb}</p>
<dl className="mx-4 mt-4 grid grid-cols-3 divide-x divide-white/10 rounded-lg border border-white/10">
{stats.map((s) => (
<div key={s.label} className="px-2 py-2.5 text-center">
<dt className="text-[10px] uppercase tracking-widest text-zinc-400">{s.label}</dt>
<dd className="mt-0.5 text-base font-semibold tabular-nums">{s.value}</dd>
</div>
))}
</dl>
<div className="flex items-center justify-between px-4 pb-4 pt-4">
<p className="font-mono text-xs text-zinc-400">
<span className="holographic-foil-card-with-tilt-driven-rainbow-sheen-ink text-sm font-bold">#{card.serial}</span> / {card.run}
</p>
<p className="text-[11px] font-medium uppercase tracking-widest text-zinc-400">{card.ships}</p>
</div>
{/* A faint wash of the same rainbow over the whole face, strongest when the card is turned hard. */}
<span aria-hidden="true" className="holographic-foil-card-with-tilt-driven-rainbow-sheen-rainbow pointer-events-none absolute inset-0 mix-blend-screen" style={{ opacity: "calc(var(--edge, 0.6) * 0.16)" }} />
<span aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(240px circle at var(--px, 66%) var(--py, 30%), rgba(255,255,255,0.16), transparent 65%)" }} />
</div>
</div>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/holographic-foil-card-with-tilt-driven-rainbow-sheen.json
The artwork is drawn in greys. On top sits a repeating rainbow gradient with `mix-blend-mode: color-dodge`, which brightens mid greys into saturated colour and leaves dark areas dark. A layer of fine ridges and a radial light are blended with `overlay`. A small script writes the light position into CSS variables, and the gradients read those variables for their position and angle, so the colours slide as the card turns.
Colour dodge needs something to brighten. Light greys turn into bright foil, mid greys into deep colour and black stays black, which gives the picture its shape. A coloured picture works too, but the result is muddier and harder to control.
No. It uses React for the markup and one effect with `requestAnimationFrame`. There is no animation library and no image file. The loop stops when the card leaves the screen or the tab is hidden.
The light wanders by itself, so the foil keeps shifting without a pointer. Dragging a finger across the card steers it, and vertical scrolling still works because the zone sets `touch-action: pan-y`.
Yes, but give each card its own refs and effect by moving the card into its own component. Three or four on screen is fine. For a long list, drop the idle wander so only the card under the pointer animates.
Related: 3D Tilt Card with Parallax Layers and Glare, Card with Diagonal Glare Sweep on Hover, Spotlight Cards with Pointer Light.
More animated cards or all free UI components. Start for free to have a full site built around it.
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.