Copied!
Band

Skew hover cards

Three cards that bend, twist and tilt in 3D under the pointer, each with its own personality, and settle back flat when you leave.

← Library

Element · Vanilla JS + CSSUpdated November 6, 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 "Skew hover cards" for my site using the Enrichr Library element at https://enrichr.dev/elements/skew-hover-cards.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/skew-hover-cards.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:
- strength: 1
- settle: 0.8
- background: #0c1e18
- card: #173b2e
- accent: #e6ff55
- text: #fbf8f1

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/skew-hover-cards.json.

Code

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

HTML

<div class="ef-shc">
  <div class="ef-shc__grid">
    <div class="ef-shc__card">
      <span class="ef-shc__pill"><span class="ef-shc__glyph" aria-hidden="true">&#10022;</span> Skew</span>
      <span class="ef-shc__label">hover &middot; tilt</span>
      <svg class="ef-shc__mark" viewBox="0 0 50 50" aria-hidden="true" focusable="false"><path fill="currentColor" d="m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z"/></svg>
    </div>
    <div class="ef-shc__card">
      <span class="ef-shc__pill"><span class="ef-shc__glyph" aria-hidden="true">&#9672;</span> Mouse</span>
      <span class="ef-shc__label">hover &middot; twist</span>
      <svg class="ef-shc__mark" viewBox="0 0 50 50" aria-hidden="true" focusable="false"><path fill="currentColor" d="m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z"/></svg>
    </div>
    <div class="ef-shc__card">
      <span class="ef-shc__pill"><span class="ef-shc__glyph" aria-hidden="true">&#9670;</span> 3D</span>
      <span class="ef-shc__label">hover &middot; rotate</span>
      <svg class="ef-shc__mark" viewBox="0 0 50 50" aria-hidden="true" focusable="false"><path fill="currentColor" d="m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z"/></svg>
    </div>
  </div>
  <p class="ef-shc__info"><span class="ef-shc__dot" aria-hidden="true">&#11044;</span>hover each card &middot; independent 3D skew<span class="ef-shc__dot" aria-hidden="true">&#11044;</span></p>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-shc { --ef-shc-bg: #0c1e18; --ef-shc-card: #173b2e; --ef-shc-acc: #e6ff55; --ef-shc-ink: #fbf8f1; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 30rem; flex: 1 1 auto; display: flex; align-items: center; justify-content: center; padding: 1.5rem; overflow: hidden; border-radius: 1rem; background: linear-gradient(145deg, color-mix(in srgb, var(--ef-shc-bg) 58%, #000), var(--ef-shc-bg)); font-family: "Parkinsans", "Space Grotesk", system-ui, sans-serif; }
.ef-shc .ef-shc__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2rem; width: 100%; max-width: 780px; margin: 0 auto; perspective: 1000px; }
.ef-shc .ef-shc__card { position: relative; box-sizing: border-box; aspect-ratio: 3 / 4; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 1.2rem; border: 1px solid color-mix(in srgb, var(--ef-shc-ink) 4%, transparent); border-radius: 2.4rem; background: linear-gradient(160deg, var(--ef-shc-card), color-mix(in srgb, var(--ef-shc-card) 58%, #000)); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.7), inset 0 1px 2px color-mix(in srgb, var(--ef-shc-ink) 4%, transparent); color: var(--ef-shc-ink); font-size: 1.4rem; font-weight: 600; letter-spacing: 0.03em; text-shadow: 0 4px 10px rgba(0, 0, 0, 0.6); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); cursor: default; transform-style: preserve-3d; transform-origin: 50% 50%; will-change: transform; transition: box-shadow 0.25s ease; touch-action: pan-y; }
.ef-shc .ef-shc__card:hover { border-color: color-mix(in srgb, var(--ef-shc-ink) 8%, transparent); box-shadow: 0 25px 50px rgba(0, 0, 0, 0.8), 0 0 20px color-mix(in srgb, var(--ef-shc-acc) 8%, transparent); }
.ef-shc .ef-shc__pill { padding: 0.4rem 1.4rem; border: 1px solid color-mix(in srgb, var(--ef-shc-ink) 5%, transparent); border-radius: 60px; background: color-mix(in srgb, var(--ef-shc-ink) 3%, transparent); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); font-size: 1.2rem; line-height: 1.5; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; pointer-events: none; }
.ef-shc .ef-shc__label { margin-top: 0.8rem; text-align: center; font-size: 0.7rem; font-weight: 400; letter-spacing: 0.12em; text-transform: uppercase; opacity: 0.35; pointer-events: none; }
.ef-shc .ef-shc__mark { position: absolute; right: 1.8rem; bottom: 1.6rem; width: 2rem; height: 2rem; color: var(--ef-shc-acc); opacity: 0.14; pointer-events: none; transition: opacity 0.3s; }
.ef-shc .ef-shc__info { position: absolute; bottom: 2rem; left: 50%; margin: 0; padding: 0.5rem 2rem; border: 1px solid color-mix(in srgb, var(--ef-shc-ink) 4%, transparent); border-radius: 60px; background: color-mix(in srgb, color-mix(in srgb, var(--ef-shc-bg) 45%, #000) 80%, transparent); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); color: color-mix(in srgb, var(--ef-shc-ink) 70%, var(--ef-shc-bg)); font-family: "Instrument Sans", system-ui, sans-serif; font-size: 0.85rem; line-height: 1.5; letter-spacing: 0.02em; white-space: nowrap; opacity: 1; transform: translateX(-50%); pointer-events: none; }
.ef-shc .ef-shc__dot { display: inline-block; margin: 0 0.4rem; color: var(--ef-shc-acc); font-style: normal; }
.ef-shc.is-narrow .ef-shc__grid { gap: 1rem; max-width: 380px; }
.ef-shc.is-narrow .ef-shc__card { border-radius: 1.8rem; font-size: 1.1rem; }
.ef-shc.is-narrow .ef-shc__pill { padding: 0.2rem 1rem; font-size: 0.9rem; }
.ef-shc.is-narrow .ef-shc__mark { right: 1.4rem; bottom: 1.2rem; width: 1.4rem; height: 1.4rem; }
.ef-shc.is-narrow .ef-shc__info { padding: 0.3rem 1.2rem; font-size: 0.7rem; white-space: normal; text-align: center; width: max-content; max-width: calc(100% - 2rem); box-sizing: border-box; }
.ef-shc.is-tiny .ef-shc__pill { padding: 0.15rem 0.6rem; font-size: 0.72rem; letter-spacing: 0.04em; }
.ef-shc.is-tiny .ef-shc__card { padding: 0.5rem; }
.ef-shc.is-tiny .ef-shc__label { font-size: 0.55rem; letter-spacing: 0.04em; white-space: nowrap; }
.ef-shc.is-tiny .ef-shc__mark { right: 0.9rem; bottom: 0.8rem; width: 1rem; height: 1rem; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Skew hover cards: three cards that bend in 3D only while the pointer is over them. Each frame, the pointer's place
// inside the hovered card (-0.5 to 0.5 across and down) is mapped straight to a skew (up to 16 and 10 degrees at the
// edges), a twist, a tilt around both axes and a slight push in, all behind a 900 px perspective inside a
// 1000 px perspective row. Each card has its own personality: a stronger response and a small fixed lean per position.
// There is no easing on the way in, the card follows the pointer directly; leaving lets it settle back to flat over
// 0.8 s on a power3 out curve (0.7 s for every card when the window loses focus). Touch works as a short tap-and-settle.
// Nothing runs until a card is hovered. Reduced motion: the cards stay flat and only the hover glow changes.
// mount(root, { strength (1 is the pen), settle (seconds to fall back), background, card, accent, text })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-shc");
  var cards = [].slice.call(root.querySelectorAll(".ef-shc__card"));
  var ZERO = { skx: 0, sky: 0, rz: 0, rx: 0, ry: 0, s: 1 };
  var st = cards.map(function () { return { hov: false, mx: 0, my: 0, v: Object.assign({}, ZERO), tw: null }; });
  var opts = {}, raf = 0, last = 0, ro = null, offs = [], destroyed = false;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function clamp(v) { return v < -0.5 ? -0.5 : v > 0.5 ? 0.5 : v; }
  function p3o(p) { return 1 - Math.pow(1 - p, 4); } // GSAP power3.out

  // the pen's mapping from pointer offset to pose, card by card
  function pose(i) {
    var ox = st[i].mx, oy = st[i].my;
    var k = (1 + 0.2 * (i % 3)) * opts.strength;
    var dist = Math.abs(ox) + Math.abs(oy);
    var fx = 0.08 * (i - 1), fy = 0.06 * (i * 1.2);
    return {
      skx: ox * 32 * k + fx * 2,
      sky: oy * 20 * k + fy * 2,
      rz: (ox * 14 + oy * 8) * k + i * 0.5,
      rx: oy * 22 * k + fy * 3,
      ry: ox * 26 * k + fx * 3,
      s: 1 + dist * 0.12 * k * 0.6 + 0.01 * (i - 1)
    };
  }
  function paint(i) {
    var v = st[i].v;
    cards[i].style.transform = "perspective(900px) rotate(" + v.rz.toFixed(3) + "deg) rotateY(" + v.ry.toFixed(3) + "deg) rotateX(" + v.rx.toFixed(3) +
      "deg) skew(" + v.skx.toFixed(3) + "deg," + v.sky.toFixed(3) + "deg) scale(" + v.s.toFixed(4) + ")";
  }
  function settle(i, dur) {
    var s = st[i];
    s.hov = false;
    s.tw = { from: Object.assign({}, s.v), t: 0, d: dur };
    kick();
  }

  function loop(now) {
    raf = 0;
    var dt = Math.min(0.1, (now - last) / 1000 || 0); last = now;
    var busy = false;
    for (var i = 0; i < cards.length; i++) {
      var s = st[i];
      if (s.hov) { s.tw = null; s.v = pose(i); paint(i); busy = true; }
      else if (s.tw) {
        s.tw.t += dt;
        var p = Math.min(1, s.tw.t / s.tw.d), e = p3o(p);
        for (var key in ZERO) s.v[key] = s.tw.from[key] + (ZERO[key] - s.tw.from[key]) * e;
        paint(i);
        if (p >= 1) { s.tw = null; cards[i].style.transform = ""; } else busy = true;
      }
    }
    if (busy && !document.hidden) raf = requestAnimationFrame(loop);
  }
  function kick() { if (!raf && !destroyed && !reduce) { last = performance.now(); raf = requestAnimationFrame(loop); } }

  function offset(card, e) {
    var r = card.getBoundingClientRect();
    return [(e.clientX - r.left) / r.width - 0.5, (e.clientY - r.top) / r.height - 0.5];
  }
  if (!reduce) {
    cards.forEach(function (card, i) {
      var s = st[i];
      function enter(e) { var p = offset(card, e); s.hov = true; s.mx = p[0]; s.my = p[1]; kick(); }
      function move(e) { if (!s.hov) return; var p = offset(card, e); s.mx = clamp(p[0]); s.my = clamp(p[1]); kick(); }
      function leave() { if (s.hov) settle(i, opts.settle); }
      card.addEventListener("pointerenter", enter);
      card.addEventListener("pointermove", move);
      card.addEventListener("pointerleave", leave);
      card.addEventListener("pointercancel", leave);
      offs.push(function () {
        card.removeEventListener("pointerenter", enter); card.removeEventListener("pointermove", move);
        card.removeEventListener("pointerleave", leave); card.removeEventListener("pointercancel", leave);
      });
    });
    var onBlur = function () { cards.forEach(function (c, i) { if (st[i].hov || st[i].tw || cards[i].style.transform) settle(i, opts.settle * 0.875); }); };
    window.addEventListener("blur", onBlur);
    offs.push(function () { window.removeEventListener("blur", onBlur); });
  }

  function size() {
    var W = box.clientWidth;
    box.classList.toggle("is-narrow", W < 600);
    box.classList.toggle("is-tiny", W < 420);
  }
  function update(partial) {
    opts = Object.assign({ strength: 1, settle: 0.8, background: "#0c1e18", card: "#173b2e", accent: "#e6ff55", text: "#fbf8f1" }, opts, partial || {});
    opts.strength = Math.max(0, num(opts.strength, 1)); opts.settle = Math.max(0.05, num(opts.settle, 0.8));
    box.style.setProperty("--ef-shc-bg", opts.background);
    box.style.setProperty("--ef-shc-card", opts.card);
    box.style.setProperty("--ef-shc-acc", opts.accent);
    box.style.setProperty("--ef-shc-ink", opts.text);
    if (destroyed) return;
    size();
  }

  update(o);
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(box); }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf); raf = 0;
      offs.forEach(function (f) { f(); }); offs = [];
      if (ro) ro.disconnect();
      cards.forEach(function (c) { c.style.transform = ""; });
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"strength":1,"settle":0.8,"background":"#0c1e18","card":"#173b2e","accent":"#e6ff55","text":"#fbf8f1"});

How it works

While the pointer is over a card, every frame turns its place inside the card (from minus a half to a half across and down) straight into a pose: a skew of up to 16 and 10 degrees, a twist, a tilt around both axes and a slight push in, behind a 900 px perspective inside a 1000 px perspective row. Each card responds a little more strongly than the one before and has a small fixed lean of its own. There is no easing on the way in, so the card follows the pointer exactly; on leaving it settles back to flat over 0.8 seconds on a power3 out curve, and all cards settle if the window loses focus. The pointer position is read from the card's current on-screen box, as in the original.

Accessibility and performance

The cards are plain text, not controls, so they take no focus and the words read normally. The decorative glyphs and the puzzle mark are hidden from assistive technology. With prefers-reduced-motion the cards stay flat and only the hover glow changes.

Nothing runs until a card is hovered; then one requestAnimationFrame loop writes a single transform per moving card and stops once every card is flat. The cards use will-change: transform so the bend is composited.

SectionBand
TechniquesPointer interaction, CSS 3D
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byHover-only 3D skew cards by Enrichr on CodePen. 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. Card hover tilt with glareA grid of cards that tilt in 3D toward the pointer with a moving glare highlight and a lifted shadow, all driven by CSS variables. Parallax photo carouselA draggable 3D carousel of gradient photo cards with parallax inner layers, inertia and spring snap, plus arrow keys, buttons and dots.

FAQ

Does it work on touch screens?

A tap gives a short bend and settle. Dragging up or down on a card still scrolls the page.

Can I put links or buttons on the cards?

Yes. The pointer maths only reads positions, so clicks pass through. Keep text short, because the skew distorts long lines.

How do I make it calmer?

Lower Strength. At 0.5 the cards lean gently; at 0 only the small fixed lean of each card remains.