Live Demo
Hover a card to lift it. Adjust the sliders and toggle the deck.
Tip: Hover a card to lift it.
• 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.
Hover a card to lift it. Adjust the sliders and toggle the deck.
Tip: Hover a card to lift it.
These blocks match the live demo above. Paste into any page (keep the same IDs).
<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>
/* 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}
(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);
})();
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.