Skip to content
Plinth

Cards

Holographic Card

A foil-finished card that tilts in 3D while its spectral pattern slides against your cursor, so the sheen reads as depth.

#card#holographic#foil#tilt#pointer

Install

npx shadcn@latest add https://plinthui.com/r/holographic-card.json
Open in v0
'use client';

import { useCallback, useRef } from 'react';

// The pointer position lives in CSS custom properties rather than React state.
// Re-rendering on every pointermove would be a render per frame for something
// that changes no markup — writing two properties on the node is the whole
// update, and the tilt, foil and glare all read them.
const css = `
.holo {
  /* Pointer position as a 0..1 pair, written by script and read by every layer
     below. Keeping it in custom properties means one listener drives the tilt,
     the foil and the glare together — they can never disagree about where the
     cursor is, which is what separate listeners always eventually do. */
  --hx: 0.5;
  --hy: 0.5;
  position: relative;
  width: 280px;
  aspect-ratio: 5 / 7;
  border-radius: 18px;
  overflow: hidden;
  isolation: isolate;
  background:
    radial-gradient(120% 90% at 30% 0%, #2a1b53 0%, #140d2b 55%, #0b0718 100%);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.08);
  /* Perspective on the element itself, so the tilt is a real 3D rotation
     rather than a 2D skew pretending to be one. */
  transform: perspective(900px)
             rotateY(calc((var(--hx) - 0.5) * 18deg))
             rotateX(calc((0.5 - var(--hy)) * 18deg));
  transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
  will-change: transform;
}

/* The foil. Two gradients at different angles multiplied together give the
   uneven, patchy interference of real holographic stock — a single rainbow
   gradient reads as a printed stripe, because real foil is never evenly lit. */
.holo__foil {
  position: absolute;
  inset: -20%;
  background-image:
    repeating-linear-gradient(
      112deg,
      rgba(255, 60, 172, 0.55) 0 22px,
      rgba(255, 214, 92, 0.55) 22px 44px,
      rgba(74, 255, 208, 0.55) 44px 66px,
      rgba(96, 140, 255, 0.55) 66px 88px,
      rgba(255, 60, 172, 0.55) 88px 110px
    ),
    repeating-linear-gradient(
      64deg,
      rgba(255, 255, 255, 0.22) 0 14px,
      rgba(0, 0, 0, 0.22) 14px 28px
    );
  /* Shifting the foil AGAINST the cursor, and much further than the cursor
     moves, is the parallax that sells depth: the pattern appears to sit under
     the surface rather than printed on it. */
  background-position:
    calc(var(--hx) * -140px) calc(var(--hy) * -140px),
    calc(var(--hx) * 90px) calc(var(--hy) * 90px);
  /* color-dodge blows out where the foil is bright and vanishes where it is
     dark, which is exactly how foil behaves as it catches the light. */
  mix-blend-mode: color-dodge;
  opacity: 0.55;
  transition: opacity 220ms ease;
  pointer-events: none;
}

/* A soft glare that tracks the pointer, so there is a single light source the
   tilt and the foil both answer to. */
.holo__glare {
  position: absolute;
  inset: 0;
  background: radial-gradient(
    34% 34% at calc(var(--hx) * 100%) calc(var(--hy) * 100%),
    rgba(255, 255, 255, 0.55),
    transparent 70%
  );
  mix-blend-mode: overlay;
  pointer-events: none;
}

.holo:hover .holo__foil { opacity: 0.85; }

.holo__body {
  position: relative;
  z-index: 1;
  height: 100%;
  padding: 22px;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 6px;
  color: #f4f1ff;
  font-family: system-ui, sans-serif;
}

.holo__kicker {
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: rgba(226, 216, 255, 0.72);
}

.holo__title { font-size: 22px; font-weight: 650; letter-spacing: -0.01em; }

.holo__meta { font-size: 12.5px; line-height: 1.45; color: rgba(226, 216, 255, 0.7); }

@media (prefers-reduced-motion: reduce) {
  .holo { transition: none; transform: none; }
  .holo__foil { transition: none; }
}
`;

export function HolographicCard() {
  const ref = useRef<HTMLElement>(null);

  const track = useCallback((event: React.PointerEvent<HTMLElement>) => {
    const card = ref.current;
    if (!card) return;
    const rect = card.getBoundingClientRect();
    card.style.setProperty('--hx', String((event.clientX - rect.left) / rect.width));
    card.style.setProperty('--hy', String((event.clientY - rect.top) / rect.height));
  }, []);

  const reset = useCallback(() => {
    const card = ref.current;
    if (!card) return;
    card.style.setProperty('--hx', '0.5');
    card.style.setProperty('--hy', '0.5');
  }, []);

  return (
    <>
      <style>{css}</style>
      <article className="holo" ref={ref} onPointerMove={track} onPointerLeave={reset}>
        <div className="holo__foil" />
        <div className="holo__glare" />
        <div className="holo__body">
          <p className="holo__kicker">Series A · 014</p>
          <h3 className="holo__title">Prism Foil</h3>
          <p className="holo__meta">
            Tilt it toward the light. The pattern moves against your cursor, so it reads as depth
            rather than print.
          </p>
        </div>
      </article>
    </>
  );
}

More in Cards

Similar tags