← All gadgets

Tilt Card

A card that leans toward your cursor

ui #hover #3d #css 3.3 kB

Running live from https://fun.whitneys.co, cross-origin and sandboxed.

Put it on your site

<iframe
  src="https://fun.whitneys.co/g/tilt-card"
  width="340"
  height="240"
  title="Tilt Card"
  sandbox="allow-scripts allow-popups-to-escape-sandbox"
  loading="lazy"
  style="border:0"></iframe>

An iframe, not a script tag -- so this gadget cannot touch the page you paste it into, and neither can we. Your settings above are baked into the URL.

The whole thing

Raw file

Every gadget is one file, and this is it -- all 3.3 kB. Take it, change it, ship it. Just keep the credit.

Show source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="application/json" id="gadget-manifest">
{
  "name": "Tilt Card",
  "tagline": "A card that leans toward your cursor",
  "category": "ui",
  "tags": ["hover", "3d", "css"],
  "order": 2,
  "author": "rwhitney",
  "background": "transparent",
  "size": { "w": 340, "h": 240, "resizable": false },
  "config": {
    "label":  { "type": "text",  "label": "Card text", "max": 24, "default": "Hover me" },
    "accent": { "type": "color", "label": "Accent",    "default": "#F92672" },
    "tilt":   { "type": "range", "label": "Tilt amount", "min": 0, "max": 25, "step": 1, "default": 12 },
    "glare":  { "type": "bool",  "label": "Glare",      "default": true }
  },
  "permissions": []
}
</script>
<style>
  html, body {
    margin: 0; height: 100%;
    display: grid; place-items: center;
    font: 16px/1.4 ui-sans-serif, system-ui, sans-serif;
    background: transparent;
    overflow: hidden;
  }
  #stage { perspective: 800px; }
  #card {
    width: 240px; height: 150px;
    display: grid; place-items: center;
    border-radius: 14px;
    background: linear-gradient(145deg, var(--accent), #272822 130%);
    color: #F8F8F2;
    font-weight: 700; font-size: 20px; letter-spacing: -0.01em;
    box-shadow: 0 12px 30px rgb(0 0 0 / 0.45);
    transform-style: preserve-3d;
    transition: transform 120ms ease-out;
    position: relative; overflow: hidden;
    user-select: none;
  }
  #glare {
    position: absolute; inset: 0;
    background: radial-gradient(circle at var(--gx, 50%) var(--gy, 50%),
                rgb(255 255 255 / 0.35), transparent 55%);
    opacity: 0; transition: opacity 160ms ease;
    pointer-events: none;
  }
  #card.lit #glare { opacity: 1; }
  @media (prefers-reduced-motion: reduce) {
    #card { transition: none; }
  }
</style>
</head>
<body>
  <div id="stage">
    <div id="card"><span id="label"></span><div id="glare"></div></div>
  </div>
<script>
(function () {
  var cfg   = window.gadget ? window.gadget.config : {};
  var card  = document.getElementById('card');
  var label = document.getElementById('label');
  var glare = document.getElementById('glare');

  // Config values are attacker-controlled -- anyone can craft the embed URL.
  // textContent, never innerHTML.
  label.textContent = cfg.label || 'Hover me';
  card.style.setProperty('--accent', cfg.accent || '#F92672');
  if (!cfg.glare) glare.style.display = 'none';

  var maxTilt = typeof cfg.tilt === 'number' ? cfg.tilt : 12;
  var reduce  = matchMedia('(prefers-reduced-motion: reduce)').matches;

  function move(e) {
    var r = card.getBoundingClientRect();
    var px = (e.clientX - r.left) / r.width;
    var py = (e.clientY - r.top) / r.height;
    if (!reduce) {
      card.style.transform =
        'rotateY(' + ((px - 0.5) * 2 * maxTilt).toFixed(2) + 'deg) ' +
        'rotateX(' + ((0.5 - py) * 2 * maxTilt).toFixed(2) + 'deg)';
    }
    glare.style.setProperty('--gx', (px * 100).toFixed(1) + '%');
    glare.style.setProperty('--gy', (py * 100).toFixed(1) + '%');
    card.classList.add('lit');
  }

  function reset() {
    card.style.transform = '';
    card.classList.remove('lit');
  }

  addEventListener('pointermove', move);
  addEventListener('pointerleave', reset);
  card.addEventListener('pointerleave', reset);
})();
</script>
</body>
</html>