Move the pointer over the card
Holo Tilt & Glare
A 250 × 156 px card tilted up to ±11° / ±9° by the pointer, an iridescent conic glare centered under the cursor and a layer of micro-stripes sliding the opposite way — six CSS variables written every frame by a smoothed rAF loop (12% of the way per frame) that stops at rest. Without JS, the card is static.
This effect is free — the Cards category contains 41 effects total, including 6 free. Effect.Labs has 811 vanilla effects. Explore the category →
Usage examples
Move the pointer over the card
Move the pointer over the card
How it works
Three layers inside .htg-card (250 × 156 px, border-radius: 14px; overflow: hidden, shadow 0 18px 40px rgba(0,0,0,.5)), itself inside .htg-wrap which carries perspective: 900px. The card's background is a white radial-gradient at 16% centered on (--htg-mx, --htg-my) laid over a five-stop conic-gradient (from 230deg at 40% 30%: #1c1440 → #232e63 → #14324d → #3a1c55 → #1c1440). Its transform is rotateX(var(--htg-ry)) rotateY(var(--htg-rx)) — the names are crossed (rx feeds rotateY); the rendering is right, the code is harder to read.
The glare: .htg-glare (inset: 0) is a conic-gradient(from 0deg at var(--htg-mx) var(--htg-my), …) with six stops — cyan .5, pink .45, yellow .4, violet .45, green .4, cyan .5 — centered under the pointer, in mix-blend-mode: screen (it can only lighten), opacity .62, filter: blur(6px). The foil: .htg-foil overflows by 30% (inset: -30%) and repeats a repeating-linear-gradient(104deg) with a 7 px period (1 px white at 9%, 2 px gap, 1 px white at 4.3%, 3 px gap) in color-dodge; it is translated by (--htg-fx, --htg-fy) = −(mx − 50) × 0.5 px, −(my − 50) × 0.4 px, up to ±25 / ±20 px opposite to the pointer — the inverse parallax that makes the stripes read as a layer beneath the surface.
The script, per .htg-wrap: pointermove normalizes the pointer position within the card's rectangle (px, py from 0 to 1) and sets targets — tx = (px − .5) × 22 → rotateY ±11°, ty = −(py − .5) × 18 → rotateX ±9°, tmx, tmy = px, py × 100 for the glare center. A requestAnimationFrame loop moves each value 12% of the remaining distance per frame (95% in ≈ 23 frames, ≈ 390 ms at 60 Hz; measured 325 ms to return to rest), writes the six variables (toFixed) and stops as soon as the rotations are within .05° and mx within .2% of their targets — at rest, nothing runs. pointerleave sends the targets back to 0°, 0°, 70%, 25%: the CSS defaults, so the card settles exactly where it was.
Two things the code intends and doesn't deliver. .htg-content carries transform: translateZ(24px) and the card transform-style: preserve-3d to lift the text off the surface — but overflow: hidden on the same card forces the used value to flat: the content isn't lifted. Measured in Chromium with the card tilted: the name's bounding rectangle is identical with and without translateZ; it shifts by 6 px as soon as overflow: visible is set. And the loop's stop test ignores --htg-my: since the damping is uniform, the value lands within ~0.3%, with no visible consequence. Under prefers-reduced-motion: reduce, the script returns before attaching any listener: static card, glare at 70% / 25%.
Accessibility
- prefers-reduced-motion handled in the JS: no listener is attached, the card stays in its rest state with its holographic material (glare fixed at 70% / 25%) — the fallback is visually complete.
.htg-wraphastabindex="0"and anaria-labelbut norole: it's a tab stop that reacts to no key (the script only listens to the pointer), and anaria-labelon adivwithout a role is ignored by several screen readers. Either removetabindex(decorative element) or addrole="img"with a label describing the card (“Membership card, Gold tier, A. Dubois”) — and translate it, the shipped label is in French.- Contrast measured (Chromium, text rendered transparent, pixels sampled under the text, glare included, three pointer positions): name
#fff16.8 px bold ≥ 4.7:1 everywhere — large text, 3:1 threshold, compliant; numberrgba(255,255,255,.75)11.2 px: 3.4 to 4.1:1 at minimum, below 4.5:1; badge#7dffc49.3 px bold: 3.3 to 4.1:1 at minimum (median 4.1 to 5.2) in the cyan-green pass of the glare, below 4.5:1. Fix computed on the lightest measured pixel: a dark gradient under the content,.htg-content { background: linear-gradient(transparent 40%, rgba(10,8,30,.55)) }→ number 7.2:1 (11.3:1 in#fff); and.htg-badge { background: rgba(10,8,30,.6) }→ badge 7.2 to 9.7:1. - Sizes: badge
.58rem= 9.3 px, number.7rem= 11.2 px — below any comfortable reading size; thetext-shadow: 0 1px 3px rgba(0,0,0,.65)doesn't count for WCAG (the measured minimum doesn't change with it). Bring the badge to at least 11 px. - Touch: Pointer Events cover the finger — dragging over the card tilts it, scrolling isn't blocked,
pointerleavefires on release and the card comes back.will-change: transformon a 250 × 156 px element is cheap. No generated content: the accessible name depends on the label alone.
Browser compatibility
Requires conic-gradient(), mix-blend-mode (screen, color-dodge), 3D transforms with perspective, custom properties written through setProperty and Pointer Events. No library.
Without JavaScript, the card is static: glare at 70% / 25%, foil not shifted — the holographic material remains, only the motion is missing. Without conic-gradient (browsers from before 2019-2020), the whole background declaration is invalid and the card loses its background: add background-color: #1c1440 as a fallback.
The code
Copy the three blocks into your page. No dependencies.
<div class="htg-wrap" tabindex="0" aria-label="Carte holographique pilotée au pointeur">
<div class="htg-card">
<span class="htg-glare" aria-hidden="true">
</span>
<span class="htg-foil" aria-hidden="true">
</span>
<span class="htg-content">
<span class="htg-badge">GOLD MEMBER</span>
<strong class="htg-name">A. Dubois</strong>
<span class="htg-num">MBR‑2026‑0042</span>
</span>
</div>
</div>
.htg-wrap {
perspective: 900px;
display: inline-block;
border-radius: 16px;
}
.htg-wrap:focus-visible {
outline: rgb(168, 85, 247) solid 2px;
outline-offset: 6px;
}
.htg-card {
position: relative;
width: 250px;
height: 156px;
border-radius: 14px;
overflow: hidden;
background: radial-gradient(120% 160% at var(--htg-mx, 70%) var(--htg-my, 20%), rgba(255,255,255,.16), transparent 55%),
conic-gradient(from 230deg at 40% 30%, #1c1440, #232e63, #14324d, #3a1c55, #1c1440);
transform: rotateX(var(--htg-ry, 0deg)) rotateY(var(--htg-rx, 0deg));
box-shadow: rgba(0, 0, 0, 0.5) 0px 18px 40px;
transform-style: preserve-3d;
will-change: transform;
}
.htg-glare {
position: absolute;
inset: 0px;
pointer-events: none;
background: conic-gradient(from 0deg at var(--htg-mx, 70%) var(--htg-my, 25%),
rgba(98,230,255,.5), rgba(255,138,225,.45), rgba(255,233,92,.4), rgba(177,140,255,.45), rgba(125,255,196,.4), rgba(98,230,255,.5));
mix-blend-mode: screen;
opacity: 0.62;
filter: blur(6px);
}
.htg-foil {
position: absolute;
inset: -30%;
pointer-events: none;
background: repeating-linear-gradient(104deg, rgba(255, 255, 255, 0.09) 0px, rgba(255, 255, 255, 0.09) 1px, transparent 1px, transparent 3px, rgba(255, 255, 255, 0.043) 3px, rgba(255, 255, 255, 0.043) 4px, transparent 4px, transparent 7px);
mix-blend-mode: color-dodge;
transform: translate(var(--htg-fx, 0px), var(--htg-fy, 0px));
}
.htg-content {
position: absolute;
inset: 0px;
padding: 16px 18px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 3px;
transform: translateZ(24px);
text-shadow: rgba(0, 0, 0, 0.65) 0px 1px 3px;
}
.htg-badge {
align-self: flex-start;
font-size: 0.58rem;
font-weight: 800;
letter-spacing: 0.16em;
color: rgb(125, 255, 196);
border: 1px solid rgba(125, 255, 196, 0.45);
border-radius: 20px;
padding: 3px 9px;
margin-bottom: auto;
}
.htg-name {
font-size: 1.05rem;
color: rgb(255, 255, 255);
}
.htg-num {
font-size: 0.7rem;
letter-spacing: 0.18em;
color: rgba(255, 255, 255, 0.75);
}
(function() {
/* fx-0821 — Holo Tilt & Glare : tilt + glare + foil pilotés au pointeur (rAF + lerp) */
function initHtg() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('.htg-wrap').forEach(function(wrap) {
var card = wrap.querySelector('.htg-card');
if (!card) return;
var tx = 0, ty = 0, tmx = 70, tmy = 25, x = 0, y = 0, mx = 70, my = 25, raf = null;
function loop() {
x += (tx - x) * 0.12; y += (ty - y) * 0.12; mx += (tmx - mx) * 0.12; my += (tmy - my) * 0.12;
card.style.setProperty('--htg-rx', x.toFixed(2) + 'deg');
card.style.setProperty('--htg-ry', y.toFixed(2) + 'deg');
card.style.setProperty('--htg-mx', mx.toFixed(1) + '%');
card.style.setProperty('--htg-my', my.toFixed(1) + '%');
card.style.setProperty('--htg-fx', (-(mx - 50) * 0.5).toFixed(1) + 'px');
card.style.setProperty('--htg-fy', (-(my - 50) * 0.4).toFixed(1) + 'px');
if (Math.abs(tx - x) > 0.05 || Math.abs(ty - y) > 0.05 || Math.abs(tmx - mx) > 0.2) raf = requestAnimationFrame(loop);
else raf = null;
}
function kick() { if (!raf) raf = requestAnimationFrame(loop); }
wrap.addEventListener('pointermove', function(e) {
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
tx = (px - 0.5) * 22; ty = -(py - 0.5) * 18; tmx = px * 100; tmy = py * 100; kick();
});
wrap.addEventListener('pointerleave', function() { tx = 0; ty = 0; tmx = 70; tmy = 25; kick(); });
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initHtg);
else initHtg();
})();
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Amplitude (JS — tx = (px − .5) * 22, ty = −(py − .5) * 18) |
±11° / ±9° | Horizontal and vertical tilt in degrees. 30 / 24 = very mobile card; 12 / 10 = discreet. Beyond 30°, the edges reveal the lack of thickness. |
Smoothing (JS — * 0.12, four occurrences) |
0.12 | Fraction of the remaining distance covered per frame. .25 = responsive (~180 ms); .06 = heavy (~800 ms). The value depends on the refresh rate: at 120 Hz, twice as fast. |
| Glare (CSS — .htg-glare: six stops, opacity .62, blur 6px) | cyan, pink, yellow, violet, green | The colors of the reflection in screen mode. Opacity .4 for a sober card; remove the blur for sharp rays; two colors are enough for a two-tone reflection. |
| Foil (CSS — .htg-foil repeating-linear-gradient 104deg, 7 px period) | 1 px white 9% / 1 px white 4.3% | The micro-stripes. Space them out (transparent 1px 5px) for a more visible grain; change the angle (60deg) to cross the glare's light. |
Inverse parallax (JS — −(mx − 50) * 0.5, −(my − 50) * 0.4) |
±25 / ±20 px | Travel of the foil. The negative sign makes the depth; positive and the layer looks stuck on top. Stay under the 30% margin (inset −30%), or the foil uncovers the edges. |
| Rest (JS — pointerleave 70 / 25; CSS — var(--htg-mx, 70%) var(--htg-my, 25%)) | 70% / 25% | Position of the reflection at rest. The JS values and the CSS defaults must stay identical, otherwise the card jumps on the first hover and again on return. |
| Background (CSS — .htg-card conic-gradient from 230deg) | #1c1440 → #232e63 → #14324d → #3a1c55 | The base material. Keep dark tones: the glare lightens (screen) and the white text needs a background. Add background-color as a fallback for browsers without conic-gradient. |
FAQ
The text has no relief when the card tilts — is the translateZ(24px) useless?
In the shipped code, yes. overflow: hidden on .htg-card flattens the 3D context (used transform-style = flat), and a translateZ in a flattened context has no visible effect — measured: the name doesn't move a pixel. To get the relief back, move the clipping: wrap .htg-glare and .htg-foil in a span with inset: 0; border-radius: 14px; overflow: hidden, and remove overflow: hidden from the card. The text then stands 24 px off the surface and shifts slightly against the rotation.
Several cards on the same page?
Yes: the script runs querySelectorAll('.htg-wrap') and gives each card its own state and its own loop, which only runs while that card moves. Cost per moving card: six setProperty per frame and one getBoundingClientRect per pointermove. No id, no global variable.
How do I keep the tilt without the iridescent reflection — or the other way round?
Without glare: delete the span.htg-glare (the script keeps writing --htg-mx/my, now only used by the background's white halo). Without tilt: set tx = 0; ty = 0 in pointermove or replace the card's transform line with none — the reflection and the foil keep following the pointer, the card stays face-on.