Copied!
Gallery

Card hover tilt with glare

A grid of cards that tilt in 3D toward the pointer with a moving glare highlight and a lifted shadow, all driven by CSS variables.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

3DElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Card hover tilt with glare" for my site using the Enrichr Library element at https://enrichr.dev/elements/card-hover-tilt.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/card-hover-tilt.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- accent: #e6ff55
- maxTilt: 12
- glare: 0.5
- lift: 24
- count: 6

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/card-hover-tilt.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-cht">
  <div class="ef-cht__wrap"><div class="ef-cht__grid"></div></div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-cht { width: 100%; }
.ef-cht__wrap { box-sizing: border-box; width: 100%; min-height: 18rem; padding: 1.3rem; border-radius: 1rem; display: flex; align-items: center; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-cht__grid { width: 100%; display: grid; grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); gap: 1rem; perspective: 1000px; }
.ef-cht__card { --rx: 0deg; --ry: 0deg; --mx: 50%; --my: 50%; --gl: 0; --lift: 0px; position: relative; display: block; aspect-ratio: 4 / 5; border-radius: 0.9rem; color: #fbf8f1; text-decoration: none; transform-style: preserve-3d; transform: rotateX(var(--rx)) rotateY(var(--ry)) translateZ(var(--lift)); transition: transform 500ms cubic-bezier(.2,.8,.2,1), box-shadow 500ms cubic-bezier(.2,.8,.2,1); box-shadow: 0 8px 14px -8px rgba(0, 0, 0, 0.5); will-change: transform; outline: none; }
.ef-cht__card.is-live { transition: transform 90ms linear, box-shadow 300ms; box-shadow: calc(var(--ry) * -1.2) calc(var(--rx) * 1.2 + 22px) 34px -14px rgba(0, 0, 0, 0.65); }
.ef-cht__card:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55), 0 22px 30px -14px rgba(0, 0, 0, 0.65); --lift: 18px; --gl: 0.5; }
.ef-cht__art { position: absolute; inset: 0; border-radius: inherit; overflow: hidden; }
.ef-cht__glare { position: absolute; inset: 0; border-radius: inherit; pointer-events: none; opacity: var(--gl); background: radial-gradient(circle at var(--mx) var(--my), var(--ef-glare, rgba(255, 255, 255, 0.55)), transparent 58%); mix-blend-mode: soft-light; transition: opacity 300ms; }
.ef-cht__txt { position: absolute; left: 0.9rem; right: 0.9rem; bottom: 0.85rem; transform: translateZ(34px); pointer-events: none; }
.ef-cht__t { display: block; font-size: 1.05rem; font-weight: 800; letter-spacing: -0.01em; line-height: 1.15; text-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }
.ef-cht__s { display: block; margin-top: 0.2rem; font-size: 0.72rem; opacity: 0.8; letter-spacing: 0.04em; }
.ef-cht__n { position: absolute; top: 0.8rem; left: 0.9rem; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.14em; transform: translateZ(26px); opacity: 0.85; }
@media (prefers-reduced-motion: reduce) { .ef-cht__card, .ef-cht__card.is-live { transition: box-shadow 200ms; transform: none; } }
.ef-cht__art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), transparent 30%, transparent 48%, rgba(0, 0, 0, 0.6)); }

JavaScript

// Enrichr Library · https://enrichr.dev
// Card hover tilt: a grid of cards that tilt in 3D toward the pointer, with a glare highlight that follows it and a lifted shadow.
// Everything is driven by CSS variables (--rx --ry --mx --my --gl --lift) set on each card. Mouse and pen only; touch and reduced motion
// get the glare and shadow without any movement. Keyboard focus lifts a card and lights it.
// mount(root, { background, accent, maxTilt (deg), glare (0-1), lift (px), count (2-8) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-cht__wrap");
  var grid = root.querySelector(".ef-cht__grid");
  var opts = {}, cards = [], offs = [];
  var DATA = [["Tidal", "Field notes"], ["Ember", "Studio series"], ["Moss", "Quiet objects"], ["Dusk", "Night shift"], ["Arch", "Form study"], ["Grain", "Print run"], ["Haze", "Soft light"], ["Loop", "Motion kit"]];

  function art(i, a) {
    var c = "#fbf8f1", d = "#0c1e18";
    var p = i % 6;
    if (p === 0) return "radial-gradient(circle at 70% 30%, " + a + " 0 18%, transparent 19%), linear-gradient(160deg, #005236, " + d + ")";
    if (p === 1) return "conic-gradient(from 210deg at 35% 65%, #005236, " + a + ", " + c + ", #005236)";
    if (p === 2) return "repeating-linear-gradient(120deg, #005236 0 12px, " + d + " 12px 24px)";
    if (p === 3) return "radial-gradient(circle at 30% 35%, " + a + " 0 20%, transparent 21%), radial-gradient(circle at 62% 62%, " + c + " 0 20%, transparent 21%), linear-gradient(200deg, #0a6b46, " + d + ")";
    if (p === 4) return "radial-gradient(ellipse 36% 26% at 50% 44%, " + c + " 0 99%, transparent 100%), linear-gradient(" + c + ", " + c + ") 50% 70% / 72% 40% no-repeat, linear-gradient(190deg, #005236, " + a + ")";
    return "radial-gradient(circle, " + a + " 0 2.5px, transparent 3px) 0 0 / 16px 16px, linear-gradient(145deg, #005236, " + d + ")";
  }
  function clear() { offs.forEach(function (f) { f(); }); offs = []; }
  function build() {
    clear();
    grid.textContent = "";
    cards = [];
    for (var i = 0; i < opts.count; i++) (function (i) {
      var a = document.createElement("a");
      a.className = "ef-cht__card";
      a.href = "#";
      var bg = document.createElement("span"); bg.className = "ef-cht__art"; bg.style.background = art(i, opts.accent);
      var gl = document.createElement("span"); gl.className = "ef-cht__glare";
      var n = document.createElement("span"); n.className = "ef-cht__n"; n.textContent = String(i + 1).padStart(2, "0");
      var tx = document.createElement("span"); tx.className = "ef-cht__txt";
      var t = document.createElement("span"); t.className = "ef-cht__t"; t.textContent = DATA[i % DATA.length][0];
      var s = document.createElement("span"); s.className = "ef-cht__s"; s.textContent = DATA[i % DATA.length][1];
      tx.appendChild(t); tx.appendChild(s);
      a.appendChild(bg); a.appendChild(gl); a.appendChild(n); a.appendChild(tx);
      function mv(e) {
        if (e.pointerType === "touch") return;
        var r = a.getBoundingClientRect();
        var px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
        a.classList.add("is-live");
        a.style.setProperty("--mx", (px * 100).toFixed(1) + "%");
        a.style.setProperty("--my", (py * 100).toFixed(1) + "%");
        a.style.setProperty("--gl", String(opts.glare));
        if (reduce) return;
        a.style.setProperty("--ry", ((px - 0.5) * 2 * opts.maxTilt).toFixed(2) + "deg");
        a.style.setProperty("--rx", ((0.5 - py) * 2 * opts.maxTilt).toFixed(2) + "deg");
        a.style.setProperty("--lift", opts.lift + "px");
      }
      function lv() {
        a.classList.remove("is-live");
        a.style.setProperty("--rx", "0deg"); a.style.setProperty("--ry", "0deg");
        a.style.setProperty("--gl", "0"); a.style.setProperty("--lift", "0px");
      }
      function ck(e) { e.preventDefault(); }
      a.addEventListener("pointermove", mv);
      a.addEventListener("pointerleave", lv);
      a.addEventListener("click", ck);
      offs.push(function () { a.removeEventListener("pointermove", mv); a.removeEventListener("pointerleave", lv); a.removeEventListener("click", ck); });
      grid.appendChild(a);
      cards.push(a);
    })(i);
  }
  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", maxTilt: 12, glare: 0.5, lift: 24, count: 6 }, opts, next || {});
    opts.count = Math.max(2, Math.min(8, Math.round(opts.count)));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    wrap.style.setProperty("--ef-glare", "rgba(255,255,255,0.7)");
    if (prev.count !== opts.count || prev.accent !== opts.accent) build();
  }
  update(o);
  return { update: update, destroy: clear };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","maxTilt":12,"glare":0.5,"lift":24,"count":6});

How it works

On pointer move, the script converts the position inside a card to 0 to 1 and writes it to CSS variables on that card: --rx and --ry for rotation, --mx and --my for the glare centre, --gl for glare strength and --lift for depth. The stylesheet does the rest: the card uses perspective rotateX and rotateY with translateZ, the glare is a radial gradient centred on the pointer, the shadow offset follows the tilt, and the text sits on a deeper translateZ layer so it moves a little more than the card face. On leave, the variables reset and a longer transition eases the card back.

Accessibility and performance

Cards are links, so they keep keyboard focus and a visible focus ring, and focus lifts and lights the card without needing a pointer. Tilt is mouse and pen only. Touch devices and reduced motion get the static card with glare and shadow but no rotation. Check the contrast of text over your own artwork; a darkening scrim sits under the text here.

Per pointer move there are a few custom property writes on a single card. Transforms and opacity are the only properties that animate. Avoid putting heavy filters or large blurred shadows on many cards at once.

SectionGallery
TechniquesPointer interaction, CSS 3D
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byCard Hover Interactions by Ryan Mulligan on Codrops. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay. Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens.

FAQ

Why use CSS variables instead of setting transform directly?

The stylesheet then owns the look. You can change perspective, shadow or glare in CSS without touching the script, and reduced motion can switch the transform off in one rule.

Can the glare use a different colour?

Yes. The glare colour is the --ef-glare variable on the wrapper, set in update(). Change it to any colour with alpha.

Does it work with images?

Yes. Put an image inside the card art layer. The glare and scrim layers sit above it.