CSS + JS Buttons & forms #pointer

Pointer tilt card in CSS + JavaScript

JS writes the pointer position into custom properties; CSS turns them into tilt, pop-out and glare.

  • 71 lines of CSS
  • 7 lines of HTML
  • 21 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. CSS cannot read the pointer position — that is the only reason this needs JavaScript.
  2. The canvas is the pointer’s field, so the card reacts wherever the pointer is. JS normalises its position across the canvas to −0.5…0.5 and writes four custom properties: --rx, --ry, --mx, --my.
  3. CSS uses them for the tilt (rotateX/rotateY) and for the centre of a radial-gradient glare.
  4. Children with translateZ float above the card, producing parallax for free.
  5. A long transition eases the card back to rest; while the pointer is driving it is shortened so it tracks tightly.
  6. Every length is a multiple of one base unit, --u, tied to the canvas, and JS only ever writes angles and percentages, never lengths. So the card, its type and how far its layers float are the same share of a gallery card, the editor and a recording canvas.

Key techniques

  • CSS custom properties set from JS
  • translateZ pop-out layers
  • radial-gradient glare

Copy-paste code

HTML 7 lines

<div class="scene" tabindex="0" role="img" aria-label="Card that tilts toward the pointer, with a moving glare">
  <div class="tilt">
    <span class="chip"></span>
    <b>Move your pointer</b>
    <small>tilt · depth · glare</small>
  </div>
</div>

CSS 71 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the card is the same share of a
     gallery card, the editor, a full screen and a recording canvas */
  --u: 0.28vmin;
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  perspective: calc(800 * var(--u));
}

.tilt {
  --rx: 0deg;  --ry: 0deg;   /* tilt  — set from JS */
  --mx: 50%;   --my: 50%;    /* glare — set from JS */
  position: relative;
  display: grid;
  align-content: end;
  gap: calc(2 * var(--u));
  /* the card is 280 × 176 outside, a bank card's shape: its padding is inside that */
  box-sizing: border-box;
  width: calc(280 * var(--u));
  height: calc(176 * var(--u));
  padding: calc(20 * var(--u));
  border-radius: calc(18 * var(--u));
  color: #fff;
  /* the card's own words, so they scale with the card and not with the page */
  font: 500 calc(17 * var(--u))/1.35 Inter, system-ui, sans-serif;
  /* deep enough for its white words (5.1:1 at the worst end): the bright #8b6cff to #ff4d9d was 3:1 */
  background: linear-gradient(135deg, #6a45f5, #d1206f);
  box-shadow: 0 calc(24 * var(--u)) calc(40 * var(--u)) calc(-18 * var(--u)) #8b6cff;
  transform-style: preserve-3d;
  /* the glow shadow hangs below the card and a tilt towards the pointer throws the near edge
     further out, so what the card paints sits below its box: the lift centres the drawing */
  transform: translateY(calc(-4 * var(--u))) rotateX(var(--rx)) rotateY(var(--ry));
  transition: transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.tilt.is-live { transition-duration: 0.08s; }

.tilt::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: radial-gradient(circle at var(--mx) var(--my),
              rgb(255 255 255 / 0.55), transparent 55%);
  opacity: 0;
  transition: opacity 0.3s;
}

.tilt.is-live::after { opacity: 1; }

/* children float above the surface */
.tilt b     { transform: translateZ(calc(40 * var(--u))); }
.tilt small { transform: translateZ(calc(22 * var(--u))); opacity: 0.85; }

.chip {
  position: absolute;
  top: calc(22 * var(--u));
  left: calc(22 * var(--u));
  width: calc(46 * var(--u));
  height: calc(34 * var(--u));
  border-radius: calc(7 * var(--u));
  background: linear-gradient(135deg, #ffe08a, #d89b1d);
  transform: translateZ(calc(54 * var(--u)));
}

/* the keyboard: Tab to it and it leans as it would for a pointer near the top right, even with
   the pointer resting on the canvas (!important beats the pose the script writes inline) */
.scene:focus-visible .tilt { --rx: 8deg !important; --ry: 12deg !important; --mx: 80% !important; --my: 20% !important; }
.scene:focus-visible .tilt::after { opacity: 1; }

JS 21 lines

const scene = document.querySelector('.scene');
const card = document.querySelector('.tilt');
const MAX = 20; // degrees

scene.addEventListener('pointermove', (e) => {
  const r = scene.getBoundingClientRect();
  const x = (e.clientX - r.left) / r.width - 0.5;   // -0.5 … 0.5
  const y = (e.clientY - r.top) / r.height - 0.5;

  card.style.setProperty('--ry', x * MAX * 2 + 'deg');
  card.style.setProperty('--rx', -y * MAX * 2 + 'deg');
  card.style.setProperty('--mx', (x + 0.5) * 100 + '%');
  card.style.setProperty('--my', (y + 0.5) * 100 + '%');
  card.classList.add('is-live');
});

scene.addEventListener('pointerleave', () => {
  card.classList.remove('is-live');
  card.style.removeProperty('--rx');
  card.style.removeProperty('--ry');
});
Standalone snippet · plain CSS, no build step, no dependencies.