CSS + JS Products & branding #pointer #product #card #glare

Payment card tilt in CSS + JavaScript

The card leans toward your pointer and a glare follows it. JS writes four custom properties; CSS does the tilt, the glare and the return to rest.

  • 127 lines of CSS
  • 13 lines of HTML
  • 25 lines of JS
  • No dependencies
  • MIT licensed

How it works

  1. JS turns the pointer position into four numbers and writes them as custom properties: --rx / --ry for the tilt, --gx / --gy for the glare. CSS does everything else.
  2. The glare is a radial gradient on a layer twice the size of the card, moved with translate. Moving a transform is cheap; moving background-position would repaint.
  3. overflow: hidden would flatten the 3D card, so only the flat face clips (the glare lives inside it). The chip and the text are siblings lifted with translateZ(14px), so they parallax.
  4. One transition, two speeds: fast while the pointer is over the card (.is-live), slow and springy when it leaves, which is the glide back to rest.
  5. A gentle idle sway sits on the wrapper, the tilt on the card inside, so the two never fight over transform.

Key techniques

  • pointer → --rx / --ry / --gx / --gy
  • glare = oversized gradient moved with translate
  • chip and text lifted with translateZ
  • slow transition at rest, fast while live

Copy-paste code

HTML 13 lines

<div class="scene">
  <div class="paycard">
    <div class="card">
      <i class="shadow"></i>
      <div class="face"><i class="glare"></i></div>
      <span class="chip"></span>
      <span class="brand"><i></i><i></i></span>
      <span class="num">•••• •••• •••• 4242</span>
      <span class="name">ALEX MORGAN</span>
      <span class="exp">09/29</span>
    </div>
  </div>
</div>

CSS 127 lines

/* the scene is also the pointer area around the card */
.scene {
  padding: 70px 90px;
  perspective: 800px;
  touch-action: none;
}

.paycard {
  width: 196px;
  height: 124px;
  transform-style: preserve-3d;
  animation: idle 6s ease-in-out infinite alternate;
}

.card {
  position: relative;
  width: 100%;
  height: 100%;
  color: #fff;
  font-family: system-ui, sans-serif;
  transform-style: preserve-3d;
  transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
  transition: transform 0.6s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}

.card.is-live {
  transition-duration: 0.12s;
  transition-timing-function: ease-out;
}

/* embossed details float above the surface */
.card > span {
  position: absolute;
  transform: translateZ(14px);
  pointer-events: none;
}

.shadow {
  position: absolute;
  inset: 6px -4px -14px;
  background: radial-gradient(ellipse at center, rgb(0 0 0 / 0.55), transparent 68%);
  transform: translateZ(-40px);
}

/* only this flat layer clips; clipping the card itself would flatten it */
.face {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border: 1px solid rgb(255 255 255 / 0.28);
  border-radius: 14px;
  background:
    radial-gradient(circle at 0% 100%, rgb(46 230 214 / 0.7), transparent 55%),
    radial-gradient(circle at 100% 0%, rgb(255 77 157 / 0.85), transparent 60%),
    linear-gradient(135deg, #8b6cff, #453880);
}

/* twice the card: ±25% of itself walks the bright spot edge to edge */
.glare {
  position: absolute;
  inset: -50%;
  background: radial-gradient(circle at center, rgb(255 255 255 / 0.6), rgb(255 255 255 / 0.12) 28%, transparent 48%);
  transform: translate(calc(var(--gx, -14) * 1%), calc(var(--gy, -16) * 1%));
  transition: transform 0.6s ease-out;
}

.card.is-live .glare {
  transition-duration: 0.12s;
}

.chip {
  top: 34px;
  left: 18px;
  width: 32px;
  height: 24px;
  border-radius: 5px;
  background:
    linear-gradient(90deg, transparent 31%, rgb(0 0 0 / 0.35) 31% 34%, transparent 34% 66%, rgb(0 0 0 / 0.35) 66% 69%, transparent 69%),
    linear-gradient(transparent 46%, rgb(0 0 0 / 0.35) 46% 54%, transparent 54%),
    linear-gradient(135deg, #ffe9b0, #ffb547 55%, #b97a1c);
}

.brand {
  top: 14px;
  right: 16px;
  width: 40px;
  height: 24px;
}

.brand i {
  position: absolute;
  top: 0;
  box-sizing: border-box;
  width: 24px;
  height: 24px;
  border: 4px solid #fff;
  border-radius: 50%;
}

.brand i:first-child { left: 0; }
.brand i:last-child { right: 0; border-color: #2ee6d6; }

.num {
  left: 18px;
  bottom: 36px;
  font: 600 13px/1 ui-monospace, Consolas, monospace;
  letter-spacing: 1.5px;
  text-shadow: 0 1px 2px rgb(0 0 0 / 0.45);
  white-space: nowrap;
}

.name,
.exp {
  bottom: 15px;
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 1.2px;
  opacity: 0.85;
}

.name { left: 18px; }
.exp { right: 18px; }

@keyframes idle {
  from { transform: rotateX(7deg) rotateY(-12deg); }
  to   { transform: rotateX(3deg) rotateY(12deg); }
}

JS 25 lines

const scene = document.querySelector('.scene');
const card = document.querySelector('.card');
const clamp = (v) => Math.min(0.5, Math.max(-0.5, v));

function move(e) {
  const r = scene.getBoundingClientRect();
  // -0.5 … 0.5 across the scene
  const x = clamp((e.clientX - r.left) / r.width - 0.5);
  const y = clamp((e.clientY - r.top) / r.height - 0.5);
  card.style.setProperty('--ry', (x * 44).toFixed(1) + 'deg');
  card.style.setProperty('--rx', (-y * 34).toFixed(1) + 'deg');
  card.style.setProperty('--gx', (x * 50).toFixed(1)); // plain numbers, CSS makes them %
  card.style.setProperty('--gy', (y * 50).toFixed(1));
  card.classList.add('is-live');
}

function leave() {
  card.classList.remove('is-live');
  for (const p of ['--rx', '--ry', '--gx', '--gy']) card.style.removeProperty(p);
}

scene.addEventListener('pointermove', move);
scene.addEventListener('pointerdown', move);
scene.addEventListener('pointerleave', leave);
scene.addEventListener('pointercancel', leave);

Sass source 141 lines

.d-paycard {
  width: 196px;
  height: 124px;
  transform-style: preserve-3d;
  // A small idle sway so the card is alive before anyone touches it. It lives on the wrapper,
  // the pointer tilt lives on the card inside, so the two never fight over `transform`.
  animation: d-paycard-idle 6s ease-in-out infinite alternate;
  touch-action: none;

  &__card {
    position: relative;
    width: 100%;
    height: 100%;
    color: #fff;
    transform-style: preserve-3d;
    // JS only writes the four variables. Slow transition = the glide back to rest.
    transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
    transition: transform 0.6s cubic-bezier(0.2, 0.9, 0.3, 1.2);

    // While the pointer is over the stage the card must follow it almost instantly.
    &.is-live {
      transition-duration: 0.12s;
      transition-timing-function: ease-out;
    }

    > span {
      position: absolute;
      transform: translateZ(14px); // embossed: floats above the surface, so it parallaxes
      pointer-events: none;
    }
  }

  &__shadow {
    position: absolute;
    inset: 6px -4px -14px;
    background: radial-gradient(ellipse at center, rgb(0 0 0 / 0.55), transparent 68%);
    transform: translateZ(-40px);
  }

  // overflow: hidden would flatten a preserve-3d parent, so the clipping happens on this
  // flat layer only. The floating details are its siblings, not its children.
  &__face {
    position: absolute;
    inset: 0;
    overflow: hidden;
    border: 1px solid rgb(255 255 255 / 0.28);
    border-radius: 14px;
    background:
      radial-gradient(circle at 0% 100%, color-mix(in srgb, var(--accent-2) 70%, transparent), transparent 55%),
      radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--hot) 85%, transparent), transparent 60%),
      linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 45%, #0b0d18));
  }

  // Twice the size of the card, so moving it by ±25% of itself walks the bright spot from one
  // card edge to the other. --gx / --gy arrive as plain numbers between −25 and 25.
  &__glare {
    position: absolute;
    inset: -50%;
    background: radial-gradient(circle at center, rgb(255 255 255 / 0.6), rgb(255 255 255 / 0.12) 28%, transparent 48%);
    transform: translate(calc(var(--gx, -14) * 1%), calc(var(--gy, -16) * 1%));
    transition: transform 0.6s ease-out;
  }

  &__card.is-live &__glare {
    transition-duration: 0.12s;
  }

  &__chip {
    top: 34px;
    left: 18px;
    width: 32px;
    height: 24px;
    border-radius: 5px;
    background:
      linear-gradient(90deg, transparent 31%, rgb(0 0 0 / 0.35) 31% 34%, transparent 34% 66%, rgb(0 0 0 / 0.35) 66% 69%, transparent 69%),
      linear-gradient(transparent 46%, rgb(0 0 0 / 0.35) 46% 54%, transparent 54%),
      linear-gradient(135deg, #ffe9b0, var(--warm) 55%, #b97a1c);
  }

  // brand mark: two overlapping rings
  &__brand {
    top: 14px;
    right: 16px;
    width: 40px;
    height: 24px;

    i {
      position: absolute;
      top: 0;
      width: 24px;
      height: 24px;
      border: 4px solid #fff;
      border-radius: 50%;

      &:first-child {
        left: 0;
      }

      &:last-child {
        right: 0;
        border-color: var(--accent-2);
      }
    }
  }

  &__num {
    left: 18px;
    bottom: 36px;
    font: 600 13px/1 ui-monospace, 'SF Mono', Consolas, monospace;
    letter-spacing: 1.5px;
    text-shadow: 0 1px 2px rgb(0 0 0 / 0.45);
    white-space: nowrap;
  }

  &__name,
  &__exp {
    bottom: 15px;
    font-size: 9px;
    font-weight: 700;
    letter-spacing: 1.2px;
    opacity: 0.85;
  }

  &__name {
    left: 18px;
  }

  &__exp {
    right: 18px;
  }
}

@keyframes d-paycard-idle {
  from {
    transform: rotateX(7deg) rotateY(-12deg);
  }

  to {
    transform: rotateX(3deg) rotateY(12deg);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.