Lumen Moth

No. 042

Rare · Night flyer

Afterglow. Leaves a trail of soft light that lasts until dawn.

Glow
7
Drift
4
Hush
9
Use the arrow keys to tilt the card, Shift for a full tilt, Escape to straighten it.

Point at the card, drag on touch, or focus it and use the arrow keys.

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

  1. 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.
  2. 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.
  3. Sparkle. A fine, static noise texture inside the foil (the same component as on the noise page).
  4. Glare. A soft white highlight that sits under the pointer.
  5. 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-position and 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-x without any extra wiring. They inherit calc(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:

  1. Rainbow. A linear-gradient through OKLCH hues covering Hue spread degrees of the color wheel. It is three times the size of the card (background-size: 300% 300%) and background-position follows the pointer, so a different stretch of colors slides into view as the card tilts.
  2. Relief. A greyscale repeating-linear-gradient on top of it: stripes, two crossed sets for crosshatch, or a repeating-conic-gradient centered on the pointer for sunburst. It doesn't move, like the embossing on a real card. background-blend-mode: hard-light mixes it into the rainbow: dark stripes darken, light ones brighten.
  3. Blend onto the card. mix-blend-mode on the foil layer mixes the result with the card below. color-dodge brightens the card by the foil's colors and gives the punchy, metallic look; overlay and soft-light keep the art recognizable and only tint it; screen is a gentle lighten. A filter: 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-describedby description, the keys are listed in aria-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: none on 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: reduce there 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.

Back to the demo ↑