← Hover & cursor

Lift on Hover

Hover a card and it rises off the page while its shadow grows deeper and softer underneath, as if it were really being lifted. Move away and it settles back down with the slightest bounce.

DesignLayouts that breathe
BuildFast, tidy code
LaunchShip it with a smile

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-lift-on-hover to each card.

Options

Add any of these attributes to the same element.

data-liftHow far it rises, in px. Default 10.
data-scaleHow much it grows as it lifts. Default 1.02.
data-shadowColour of the deep shadow. Default a soft purple-grey.
data-durationHow long the lift takes. Default 450 ms.
data-autoplayCards that share a parent take turns lifting while the pointer isn’t on any. Handy for demos.

The deep shadow is a layer that fades in, which is smoother than animating box-shadow. The script sets the card’s transform, so don’t give it one of your own. A press lifts it on touch screens, and keyboard focus does too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Lift on Hover: hover a card and it rises off the page, its shadow growing
    // deeper and softer underneath as if it were really lifting. Move away and
    // it settles back down.
    // Usage: <article class="card" data-lift-on-hover>…</article>
    // Optional: data-lift (how far it rises, px, default 10), data-scale (how
    // much it grows, default 1.02), data-shadow (shadow colour, default a soft
    // purple-grey), data-duration (ms, default 450), data-autoplay (cards that
    // share a parent take turns lifting while the pointer isn't on any; handy
    // for demos and touch screens).
    // The script sets the card's transform, so don't give it one of your own.
    var DEFAULTS = { lift: 10, scale: 1.02, shadow: 'rgba(46,16,101,.3)', duration: 450 };

    var css =
      '.loh{position:relative;will-change:transform}' +
      '.loh-shade{position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    function setup(el) {
      var d = el.dataset;
      var lift = num(d.lift, DEFAULTS.lift);
      var scale = num(d.scale, DEFAULTS.scale);
      var duration = num(d.duration, DEFAULTS.duration);
      var colour = d.shadow || DEFAULTS.shadow;
      el.classList.add('loh');
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';

      // The deep shadow is its own layer that fades in: much smoother than
      // animating box-shadow itself.
      var shade = document.createElement('span');
      shade.className = 'loh-shade';
      shade.setAttribute('aria-hidden', 'true');
      shade.style.boxShadow = '0 ' + Math.round(lift * 1.6 + 8) + 'px ' + Math.round(lift * 2.8 + 18) + 'px -' + Math.round(lift * 0.4 + 4) + 'px ' + colour +
        ',0 ' + Math.round(lift * 0.5 + 2) + 'px ' + Math.round(lift * 0.8 + 6) + 'px -2px ' + colour;
      el.appendChild(shade);

      var on = false, real = false, hold = 0, drop = null;

      function enter() {
        if (on) return;
        on = true;
        var t = reduced() ? 0 : duration;
        anime.animate(el, { y: -lift, scale: scale, duration: t, ease: 'out(3)' });
        anime.animate(shade, { opacity: 1, duration: t, ease: 'out(2)' });
      }
      function leave() {
        if (!on) return;
        on = false;
        var t = reduced() ? 0 : duration * 1.3;
        anime.animate(el, { y: 0, scale: 1, duration: t, ease: 'outBack(1.6)' });
        anime.animate(shade, { opacity: 0, duration: t * 0.8, ease: 'out(2)' });
      }

      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        real = true;
        enter();
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        real = false;
        leave();
      });
      // Touch: it lifts under the finger and settles a moment after.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 4000;
        clearTimeout(drop);
        enter();
      });
      function release(e) {
        if (e.pointerType !== 'touch') return;
        clearTimeout(drop);
        drop = setTimeout(leave, 700);
      }
      el.addEventListener('pointerup', release);
      el.addEventListener('pointercancel', release);
      el.addEventListener('focusin', enter);
      el.addEventListener('focusout', function (e) { if (!el.contains(e.relatedTarget)) leave(); });

      return {
        enter: enter,
        leave: leave,
        busy: function () { return real || el.contains(document.activeElement) || Date.now() < hold; }
      };
    }

    // Cards that share a parent take turns, one at a time.
    function join(el, api) {
      var host = el.parentNode;
      var g = host._lohGroup;
      if (g) { g.items.push(api); return; }
      g = host._lohGroup = { items: [api], i: 0 };
      var visible = false, timer = null, cur = null;
      function tick() {
        if (!visible) return;
        if (g.items.some(function (a) { return a.busy(); })) {
          if (cur && !cur.busy()) cur.leave();
          cur = null;
          timer = setTimeout(tick, 800);
          return;
        }
        if (cur) { cur.leave(); cur = null; timer = setTimeout(tick, 450); return; }
        cur = g.items[g.i++ % g.items.length];
        cur.enter();
        timer = setTimeout(tick, 1300);
      }
      new IntersectionObserver(function (entries) {
        var was = visible;
        visible = entries[0].isIntersecting;
        if (visible && !was) { clearTimeout(timer); timer = setTimeout(tick, 500); }
      }).observe(host);
    }

    function init() {
      document.querySelectorAll('[data-lift-on-hover]').forEach(function (el) {
        if (el._loh) return;
        el._loh = true;
        var api = setup(el);
        var auto = el.dataset.autoplay;
        if (auto === undefined || auto === 'false' || reduced()) return;
        join(el, api);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="cards">
  <article class="card" data-lift-on-hover>…</article>
  <article class="card" data-lift-on-hover>…</article>
  <article class="card" data-lift-on-hover data-lift="16">…</article>
</div>