Hand‑Held Card Fan (Lerped)

UI, Animation

This is a small, dependency‑free web recreation of Gianluca Tessicini’s Unity prototype. I loved the original and rebuilt the interaction as a plain HTML/CSS/JavaScript demo for the web. All credit for the idea and inspiration goes to Gianluca. The demo fans a deck of cards, opens/closes smoothly, and supports hover lift; animations are driven by requestAnimationFrame with a damped‑lerp transform.

Live Demo

Hover a card to lift it. Adjust the sliders and toggle the deck.

Tip: Hover a card to lift it.

Notes

  • Lerp with exponential smoothing keeps motion frame‑rate friendly.
  • Transforms use translate3d/rotate/scale for GPU acceleration.
  • No dependencies; all inline to keep this demo self‑contained.

Copy/Paste Source

These blocks match the live demo above. Paste into any page (keep the same IDs).

HTML

<div class="stage" id="stage">
  <div class="deck" id="deck"></div>
</div>
<div class="controls">
  <div class="ctrl"><label for="count">Card Count</label><input id="count" type="range" min="2" max="12" value="5"></div>
  <div class="ctrl"><label for="angle">Total Angle</label><input id="angle" type="range" min="0" max="140" value="55"></div>
  <div class="ctrl"><label for="spacing">Spacing</label><input id="spacing" type="range" min="0" max="80" value="28"></div>
  <div class="ctrl"><label for="offset">Rotation Offset</label><input id="offset" type="range" min="‑40" max="40" value="0"></div>
  <button id="toggleBtn" type="button">Close Deck</button>
</div>
        

CSS

/* Stage & layout */
.stage{position:relative;width:100%;aspect-ratio:16/9;border-radius:16px;background:#0f172a;overflow:hidden;user-select:none;box-shadow:0 14px 40px rgba(0,0,0,.45)}
.deck{position:absolute;inset:0;display:grid;place-items:end center;perspective:1000px}
.controls{display:grid;grid-template-columns:repeat(4,1fr) auto;gap:12px;align-items:center;margin-top:10px}
.ctrl{display:grid;gap:6px}

/* Cards */
.card{position:absolute;width:160px;height:220px;border-radius:14px;transform-origin:50% 90%;background:#fff;border:4px solid currentColor;color:#020617;box-shadow:0 12px 22px rgba(0,0,0,.25)}
.card.red{color:#e11d48}
.card.black{color:#020617}

/* Faces (Unicode pips) */
.face{position:absolute;inset:6px;pointer-events:none}
.corner{position:absolute;font:800 18px/1.1 system-ui,sans-serif;white-space:pre}
.corner.top{top:8px;left:10px}
.corner.bot{bottom:8px;right:10px;transform:rotate(180deg)}
.pip{position:absolute;transform:translate(-50%,-50%);font:700 28px/1 system-ui,sans-serif}
.red{color:#e11d48}.black{color:#020617}
        

JavaScript

(function () {
  const d = document;
  const deck = d.getElementById('deck');
  const stage = d.getElementById('stage');

  const ui = {
    count: d.getElementById('count'),
    angle: d.getElementById('angle'),
    spacing: d.getElementById('spacing'),
    offset: d.getElementById('offset'),
    toggle: d.getElementById('toggleBtn'),
  };

  const state = {
    openT: 1,
    openTarget: 1,
    cards: [],
    params: {
      count: +ui.count.value,
      totalAngle: +ui.angle.value,
      spacing: +ui.spacing.value,
      offset: +ui.offset.value,
    },
    handYaw: 0,
    handYawTarget: 0,
  };

  const lerp = (a, b, t) => a + (b - a) * t;
  const damp = (x, target, smoothing, dt) => lerp(x, target, 1 - Math.exp(-smoothing * dt));

  const SUITS = [
    { sym: '♠', color: 'black' },
    { sym: '♥', color: 'red' },
    { sym: '♣', color: 'black' },
    { sym: '♦', color: 'red' },
  ];
  const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10'];
  const PIPS = {
    A: [[50, 50]],
    '2': [[50, 25], [50, 75]],
    '3': [[50, 20], [50, 50], [50, 80]],
    '4': [[30, 25], [70, 25], [30, 75], [70, 75]],
    '5': [[30, 25], [70, 25], [50, 50], [30, 75], [70, 75]],
    '6': [[30, 20], [70, 20], [30, 50], [70, 50], [30, 80], [70, 80]],
    '7': [[30, 20], [70, 20], [30, 45], [70, 45], [30, 70], [70, 70], [50, 32]],
    '8': [[30, 20], [70, 20], [30, 45], [70, 45], [30, 70], [70, 70], [50, 32], [50, 58]],
    '9': [[30, 20], [70, 20], [30, 45], [70, 45], [30, 70], [70, 70], [50, 32], [50, 58], [50, 85]],
    '10': [[30, 18], [70, 18], [30, 40], [70, 40], [30, 62], [70, 62], [30, 84], [70, 84], [50, 30], [50, 70]],
  };

  function rankSuitForIndex(i) {
    return {
      rank: RANKS[i % RANKS.length],
      suit: SUITS[Math.floor(i / RANKS.length) % SUITS.length],
    };
  }

  function renderFace(el, rank, suit) {
    const old = el.querySelector('.face');
    if (old) old.remove();
    const face = d.createElement('div');
    face.className = 'face';
    const color = suit.color === 'red' ? 'red' : 'black';

    const tl = d.createElement('div');
    tl.className = `corner top ${color}`;
    tl.textContent = `${rank}
${suit.sym}`;
    const br = d.createElement('div');
    br.className = `corner bot ${color}`;
    br.textContent = `${rank}
${suit.sym}`;

    const layout = PIPS[rank] || [[50, 50]];
    layout.forEach(([x, y]) => {
      const pip = d.createElement('div');
      pip.className = `pip ${color}`;
      pip.style.left = x + '%';
      pip.style.top = y + '%';
      pip.textContent = suit.sym;
      face.appendChild(pip);
    });

    face.appendChild(tl);
    face.appendChild(br);
    el.appendChild(face);
  }

  function addCard() {
    const idx = state.cards.length;
    const el = d.createElement('div');
    const rs = rankSuitForIndex(idx);
    el.className = 'card ' + (rs.suit.color === 'red' ? 'red' : 'black');
    el.style.zIndex = String(1000 + idx);
    deck.appendChild(el);
    const c = {
      el, i: idx,
      x: 0, y: -260, rot: (Math.random() * 10 - 5), scale: 0.96,
      tx: 0, ty: 0, trot: 0, tscale: 1,
      lift: 0, removing: false, ttl: 0,
    };
    el.addEventListener('pointerenter', () => { c.lift = 1; });
    el.addEventListener('pointerleave', () => { c.lift = 0; });
    renderFace(el, rs.rank, rs.suit);
    state.cards.push(c);
  }

  function buildDeck(n) {
    deck.innerHTML = '';
    state.cards.length = 0;
    for (let i = 0; i < n; i++) addCard();
  }

  function updateTargets() {
    const active = state.cards.filter(c => !c.removing);
    active.forEach((c, i) => { c.i = i; });
    const count = active.length;
    const { totalAngle, spacing, offset } = state.params;
    const mid = (count - 1) / 2;
    const step = count > 1 ? (totalAngle / (count - 1)) : 0;
    active.forEach((c) => {
      const i = c.i;
      const fromMid = i - mid;
      const ang = (fromMid * step + offset) * state.openT;
      const x = (fromMid * spacing) * state.openT;
      const y = -Math.abs(fromMid) * 2 * state.openT;
      const liftY = c.lift * 20;
      const liftScale = 1 + c.lift * 0.03;
      c.tx = x; c.ty = y - liftY; c.trot = ang; c.tscale = liftScale;
      c.el.style.zIndex = String(1000 + i);
      const r = rankSuitForIndex(i);
      renderFace(c.el, r.rank, r.suit);
      c.el.classList.toggle('red', r.suit.color === 'red');
      c.el.classList.toggle('black', r.suit.color !== 'red');
    });
    state.cards.filter(c => c.removing).forEach((c) => {
      c.ty = -260; c.tscale = 0.96; c.trot += 0.2; c.el.style.zIndex = '2000';
    });
  }

  stage.addEventListener('pointermove', (e) => {
    const r = stage.getBoundingClientRect();
    const nx = (e.clientX - r.left) / r.width * 2 - 1;
    state.handYawTarget = nx * 10;
  });

  function adjustDeck(n) {
    state.params.count = n;
    const active = state.cards.filter(c => !c.removing).length;
    if (n > active) {
      for (let k = 0; k < n - active; k++) addCard();
    } else if (n < active) {
      let rm = active - n;
      for (let i = state.cards.length - 1; i >= 0 && rm > 0; i--) {
        const c = state.cards[i];
        if (!c.removing) { c.removing = true; c.ttl = 0.7; rm--; }
      }
    }
    updateTargets();
  }

  ui.count.addEventListener('input', () => adjustDeck(+ui.count.value));
  ui.angle.addEventListener('input', () => { state.params.totalAngle = +ui.angle.value; updateTargets(); });
  ui.spacing.addEventListener('input', () => { state.params.spacing = +ui.spacing.value; updateTargets(); });
  ui.offset.addEventListener('input', () => { state.params.offset = +ui.offset.value; updateTargets(); });
  ui.toggle.addEventListener('click', () => {
    state.openTarget = state.openTarget > 0.5 ? 0 : 1;
    ui.toggle.textContent = state.openTarget > 0.5 ? 'Close Deck' : 'Open Deck';
  });

  let last = performance.now();
  function tick(now) {
    const dt = Math.min(0.05, (now - last) / 1000);
    last = now;
    state.openT = damp(state.openT, state.openTarget, 8, dt);
    state.handYaw = damp(state.handYaw, state.handYawTarget, 6, dt);
    updateTargets();
    const cul = [];
    state.cards.forEach((c, i) => {
      c.x = damp(c.x, c.tx, 12, dt);
      c.y = damp(c.y, c.ty, 12, dt);
      c.rot = damp(c.rot, c.trot, 12, dt);
      c.scale = damp(c.scale, c.tscale, 12, dt);
      c.el.style.transform = `translate3d(${c.x}px, ${c.y}px, 0) rotate(${c.rot}deg) scale(${c.scale})`;
      if (c.removing) { c.ttl -= dt; if (c.ttl <= 0 || c.y < -220) cul.push(i); }
    });
    if (cul.length) {
      for (let i = cul.length - 1; i >= 0; i--) {
        const idx = cul[i];
        state.cards[idx].el.remove();
        state.cards.splice(idx, 1);
      }
    }
    deck.style.transform = `rotateY(${state.handYaw}deg)`;
    requestAnimationFrame(tick);
  }

  buildDeck(state.params.count);
  updateTargets();
  requestAnimationFrame(tick);
})();

Inspiration & Credit

This demo was inspired by a Unity prototype shared by Gianluca Tessicini. See the original post and video: LinkedIn post. This version re‑implements the interaction in plain HTML/CSS/JavaScript with a different visual style.