CardsFree

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.

JSPointer3DHolographic
GOLD MEMBER A. Dubois MBR‑2026‑0042
Hover or click the scene to interact

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

Your membership card Show it at checkout to earn points.
4 820pointsGoldtier12/2026renewal

Move the pointer over the card

① Membership card — account area or loyalty program

WhenAccount page: a title, the card in the middle, points, tier and renewal date below.
WhyThe tilt and glare mimic a premium physical card being played in the light; the 250 × 156 format is a bank card's (1.6 ratio) and the bottom-left text follows real cards. One card per page, hover is enough.
SettingsCode as sold, role="img" instead of the tabindex, dark gradient under the content and a backing behind the badge (number 7.2:1, badge ≥ 7.2:1), 11 px badge.
Ranked season 12 · Player card
68 % wins214 matches3.1 K/D

Move the pointer over the card

② Player card — profile, rank and ranked season

WhenProfile of a game or e-sports platform: the player's card, stats in chips.
WhyThe iridescent glare is the visual code of “holo” collectible cards, the stripes that of printed foil: the player recognizes the object. The elastic return to rest gives the weight of a real card.
SettingsConic base recolored in emerald and teal (#0b2a24, #12463d, #0b2a3d, #1c3a2a), #5eead4 badge, amplitude raised to ±14° (tx = (px − .5) × 28) and .16 smoothing for a livelier card.
You're on the list Your pass is personal — present it at the backstage entrance. Doors open at 9 pm

Move the pointer over the card

③ VIP invitation — event pass

WhenEvent confirmation page: the personal pass to present, the doors-open time.
WhyA card-object says “you're expected” better than a raw QR code: the tilt on hover makes you want to touch it, and the content stays selectable text.
SettingsWarm conic base (#2a1606, #4a2a10, #1c1440, #3a2410), gold badge #ffd98a, softened glare (opacity: .5; filter: blur(8px)), serif name, rest position moved to 30% / 80%.

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) × 22rotateY ±11°, ty = −(py − .5) × 18rotateX ±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-wrap has tabindex="0" and an aria-label but no role: it's a tab stop that reacts to no key (the script only listens to the pointer), and an aria-label on a div without a role is ignored by several screen readers. Either remove tabindex (decorative element) or add role="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 #fff 16.8 px bold ≥ 4.7:1 everywhere — large text, 3:1 threshold, compliant; number rgba(255,255,255,.75) 11.2 px: 3.4 to 4.1:1 at minimum, below 4.5:1; badge #7dffc4 9.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; the text-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, pointerleave fires on release and the card comes back. will-change: transform on 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.

Chrome 69+✓ Full
Firefox 83+✓ Full
Safari 13+✓ Full
Edge 79+✓ Full
Mobile iOS 13+✓ Full (with a finger)
Android Chrome✓ Full (with a finger)

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.

HTML
<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>
CSS
.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);
   }
JavaScript (fx-0821)
(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 / propertyDefaultEffect
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.