How it works
A holographic card from gradients and blend modes
No images and no WebGL: the foil is three gradients blended onto the card, the tilt is two CSS rotations, and a few lines of JavaScript feed both with the pointer position.
The stack, back to front
- Card. A dark gradient background with the art inside it. Everything below is blended onto these colors, so a mid-dark, saturated card gives the most vivid foil.
- Foil. A rainbow gradient, blended with a greyscale relief pattern, then
blended onto the card with
mix-blend-mode. Covers the whole card or, with Foil on art only, just the illustration. - Sparkle. A fine, static noise texture inside the foil (the same component as on the noise page).
- Glare. A soft white highlight that sits under the pointer.
- Text. The name and description, on a nearly opaque plate above foil and glare.
1. Pointer state in CSS custom properties
The whole effect is driven by three numbers on the card element: --pointer-x and --pointer-y (where the pointer is, 0 to 1 from the top left corner) and --pointer-active (0 at rest, 1 while someone points at the card). JavaScript
only ever writes these three, with element.style.setProperty(). Everything else
is CSS computed from them:
- --tilt-x, --tilt-y
- The rotation angles:
calc((0.5 - var(--pointer-y)) * 2 * maxTilt), so the center is flat and the edges reach the maximum. - --foil-x, --foil-y
- The same position as percentages, for
background-positionand the center of the glare. - --pointer-active
- Used directly for the glare opacity, the foil opacity, the shadow and a slight lift.
Why this split works well:
- Custom properties inherit, so the foil and glare children read
--foil-xwithout any extra wiring. They inheritcalc(0.37 * 100%), not a pixel value: the percentage is only resolved where it is used, against each child's own box. - No framework state changes per frame: the component doesn't re-render while you move the pointer, the browser just recomputes styles for one small subtree.
- The look stays in CSS. Max tilt, glare strength or blend mode are just more custom properties; the JavaScript doesn't know about any of them.
- It degrades well. Without JavaScript the properties keep their resting values from the stylesheet, and the card renders flat and still.
Unregistered custom properties can't be transitioned: the browser has no idea that 0.3 is a number, so a change jumps. Registering them with @property fixes that, but a CSS transition restarts on every pointer move and feels
laggy. The smoothing therefore happens in JavaScript, see section 3.
2. The tilt: perspective + rotateX/rotateY
transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y)) turns the card around
its horizontal and vertical axes. Without perspective, a rotated element is just squashed.
The perspective property on the parent places a virtual viewer that many pixels
in front of the screen, so the near edge gets larger and the far edge smaller. Small values (300px)
are dramatic, large ones (2000px) subtle. The signs are chosen so the part under the pointer tips
away, as if you pressed on it.
The pointer position is measured on that untransformed parent. A rotated element's getBoundingClientRect() is the box around its projection, which changes as the card
tilts. Measuring the card itself would feed the tilt back into the input and make it jitter. The
shadow moves opposite to the pointer, so the light seems to come from the front.
3. Smoothing: a frame-rate independent lerp
Copying the raw pointer straight into the card feels cheap and twitchy. Instead, a requestAnimationFrame loop moves the current pose a fraction of the way towards
the target on every frame (linear interpolation, lerp). That gives an exponential
ease out: fast at first, then gently settling.
A fixed fraction per frame would make the card twice as fast on a 120 Hz screen. So the
fraction is derived from the time since the last frame: t = 1 − smoothingΔt / 16.7 ms. Smoothing is the share of the
old value kept per 60 fps frame. Without the idle sweep, the loop stops as soon as the card has
arrived, so a card nobody touches costs nothing. The sweep itself never arrives, so while it runs
the loop keeps going, throttled to 30 fps (it moves slowly enough that nobody can tell) and paused
while the card is off-screen.
const box = document.querySelector('.holo');
const card = box.querySelector('.card');
const rest = { x: 0.5, y: 0.5, active: 0 };
let pose = { ...rest }, target = rest, last = 0, raf = 0;
box.addEventListener('pointermove', (e) => {
// the wrapper, not the card: a rotated card has a different box
const r = box.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
const y = (e.clientY - r.top) / r.height;
target = { x, y, active: 1 };
raf ||= requestAnimationFrame(frame);
});
box.addEventListener('pointerleave', () => {
target = rest;
raf ||= requestAnimationFrame(frame);
});
function frame(now) {
const dt = last ? now - last : 1000 / 60;
last = now;
// keep 0.85 of the old value per 60 fps frame, at any frame rate
const t = 1 - 0.85 ** (dt / (1000 / 60));
let done = true;
for (const k of ['x', 'y', 'active']) {
pose[k] += (target[k] - pose[k]) * t;
if (Math.abs(target[k] - pose[k]) > 0.0005) done = false;
}
card.style.setProperty('--pointer-x', pose.x);
card.style.setProperty('--pointer-y', pose.y);
card.style.setProperty('--pointer-active', pose.active);
raf = done ? 0 : requestAnimationFrame(frame);
if (done) last = 0;
} 4. The foil: rainbow × relief × blend mode
Real holographic foil has a fine embossed pattern that splits light into colors depending on the viewing angle. Imitated with three gradients:
- Rainbow. A
linear-gradientthrough OKLCH hues covering Hue spread degrees of the color wheel. It is three times the size of the card (background-size: 300% 300%) andbackground-positionfollows the pointer, so a different stretch of colors slides into view as the card tilts. - Relief. A greyscale
repeating-linear-gradienton top of it: stripes, two crossed sets for crosshatch, or arepeating-conic-gradientcentered on the pointer for sunburst. It doesn't move, like the embossing on a real card.background-blend-mode: hard-lightmixes it into the rainbow: dark stripes darken, light ones brighten. - Blend onto the card.
mix-blend-modeon the foil layer mixes the result with the card below.color-dodgebrightens the card by the foil's colors and gives the punchy, metallic look;overlayandsoft-lightkeep the art recognizable and only tint it;screenis a gentle lighten. Afilter: brightness() contrast()on the foil first pushes its dark parts towards black, which color-dodge leaves alone, so only the stripes light up.
isolation: isolate on the card limits the blending to the card's own layers;
the page behind it is never part of the mix. The foil's opacity is intensity × (0.4 + 0.6 × --pointer-active): present at rest, full while you
look at it.
/* untransformed wrapper: receives the pointer events, sets the depth */
.holo { perspective: 900px; touch-action: none; }
.card {
/* written by JS, 0–1 */
--pointer-x: 0.5;
--pointer-y: 0.5;
--pointer-active: 0;
/* derived in CSS */
--tilt-x: calc((0.5 - var(--pointer-y)) * 28deg);
--tilt-y: calc((var(--pointer-x) - 0.5) * 28deg);
--foil-x: calc(var(--pointer-x) * 100%);
--foil-y: calc(var(--pointer-y) * 100%);
position: relative;
isolation: isolate;
border-radius: 18px;
transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
}
.foil {
position: absolute;
inset: 0;
border-radius: inherit;
background-image:
repeating-linear-gradient(25deg, #0d0d0d 0px, #8a8a8a 5px, #0d0d0d 10px),
linear-gradient(115deg,
oklch(78% 0.16 0),
oklch(78% 0.16 45),
oklch(78% 0.16 90),
oklch(78% 0.16 135),
oklch(78% 0.16 180),
oklch(78% 0.16 225),
oklch(78% 0.16 270),
oklch(78% 0.16 315),
oklch(78% 0.16 0));
background-size: auto, 300% 300%;
background-position: 0 0, var(--foil-x) var(--foil-y);
background-blend-mode: hard-light, normal;
mix-blend-mode: color-dodge;
opacity: calc(0.75 * (0.4 + 0.6 * var(--pointer-active)));
filter: brightness(0.75) contrast(1.4) saturate(1.3);
pointer-events: none;
}
.glare {
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(farthest-corner circle at var(--foil-x) var(--foil-y),
rgb(255 255 255 / 0.85), rgb(255 255 255 / 0.2) 30%, rgb(0 0 0 / 0.25) 85%);
mix-blend-mode: overlay;
opacity: calc(0.55 * var(--pointer-active));
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.card { transform: none; }
} 5. Glare and sparkle
The glare is a radial-gradient centered on --foil-x/--foil-y,
white in the middle and slightly dark at the far corner, blended with overlay.
Its opacity is glare × --pointer-active, so it fades in and out with the
smoothing. The sparkle is an feTurbulence noise with very high contrast,
blended with color-dodge inside the foil: only the brightest specks survive. It is static; the
sense of glitter comes from the foil sliding underneath, so nothing flickers.
6. Readable text
color-dodge can push any color to white, so no text color is safe on top of it.
The card's text therefore sits on a nearly opaque plate (94%) that is stacked above foil and
glare (z-index inside the card). Even if the card behind it turned pure white, the
plate stays dark enough for about 15:1 on the name and 10:1 on the dimmer details, whatever the
settings.
7. Keyboard, touch, idle and reduced motion
- Keyboard. The card is focusable. Arrow keys move the virtual pointer in
steps of a tenth of the card, Shift jumps to the edge, Escape or Home straighten it, and
leaving the card returns it to rest. A focusable group doesn't tell anyone that arrow keys
do something, so the instructions are the card's
aria-describedbydescription, the keys are listed inaria-keyshortcuts, and the same text appears below the card while it has keyboard focus. Keys that would change nothing (Up at the top edge) aren't intercepted, so they still scroll the page. - Touch. Pointer events cover mouse, pen and touch alike.
touch-action: noneon the card's wrapper lets a finger drag tilt the card instead of scrolling the page; scroll by touching outside the card. - Idle sweep. While nobody interacts, the target slowly follows a Lissajous
curve (two sine waves of 9 and 13 seconds), so the foil keeps catching the light. It
pauses with the page's animation toggle and while the card is scrolled out of view (an
IntersectionObserver). - Reduced motion. With
prefers-reduced-motion: reducethere is no tilt, no sweep and no response to the pointer: motion triggered by pointing is still motion. The foil is parked at x = 0.3, y = 0.22, an angle where colors and glare are clearly visible.
Browser support and cost
Everything used here (3D transforms, mix-blend-mode, background-blend-mode, repeating-conic-gradient, custom properties
in calc()) is supported by all current engines. The page was checked in
Chromium; Firefox and Safari weren't tested. Blend modes are computed in sRGB, so colors may
differ slightly between engines.
Cost: the rotation itself is cheap, but the moving gradients and blend modes repaint the card on every frame while it moves. That is fine for one card of this size. A grid of dozens of foil cards should turn the idle sweep off: then only the card under the pointer runs its loop, and every untouched card's loop is stopped.