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.

  • 57 lines of CSS
  • 7 lines of HTML
  • 21 lines of JS
  • No dependencies
  • MIT licensed

How it works

  1. CSS cannot read the pointer position — that is the only reason this needs JavaScript.
  2. JS normalises the pointer 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.

Key techniques

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

Copy-paste code

HTML 7 lines

<div class="scene">
  <div class="tilt">
    <span class="chip"></span>
    <b>Move your pointer</b>
    <small>tilt · depth · glare</small>
  </div>
</div>

CSS 57 lines

.scene {
  display: grid;
  place-items: center;
  width: 100vw;
  height: 100vh;
  perspective: 800px;
}

.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: 2px;
  width: 280px;
  height: 176px;
  padding: 20px;
  border-radius: 18px;
  color: #fff;
  font-family: system-ui;
  background: linear-gradient(135deg, #8b6cff, #ff4d9d);
  box-shadow: 0 24px 40px -18px #8b6cff;
  transform-style: preserve-3d;
  transform: 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(40px); }
.tilt small { transform: translateZ(22px); opacity: 0.85; }

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

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');
});

Sass source 71 lines

.d-tilt {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;

  &__card {
    // JS writes --rx / --ry (tilt) and --mx / --my (glare position). CSS does the rest.
    --rx: 0deg;
    --ry: 0deg;
    --mx: 50%;
    --my: 50%;
    position: relative;
    display: grid;
    align-content: end;
    gap: 2px;
    width: 210px;
    height: 132px;
    padding: 16px;
    border-radius: 16px;
    background: linear-gradient(135deg, var(--accent), var(--hot));
    color: #fff;
    box-shadow: 0 24px 40px -18px var(--accent);
    transform-style: preserve-3d;
    transform: rotateX(var(--rx)) rotateY(var(--ry));
    // Slow ease back to rest; near-instant while the pointer is driving.
    transition: transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);

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

    // glare
    &::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;
    }

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

    // Children float above the card surface.
    b {
      font-size: 16px;
      transform: translateZ(36px);
    }

    small {
      opacity: 0.85;
      transform: translateZ(20px);
    }
  }

  &__chip {
    position: absolute;
    top: 18px;
    left: 18px;
    width: 38px;
    height: 28px;
    border-radius: 6px;
    background: linear-gradient(135deg, #ffe08a, #d89b1d);
    transform: translateZ(48px);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.