Web Animations Medium
Depth Cards
Three layered cards use CSS perspective plus a canvas accent to communicate depth without a 3D library.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
*, *::before, *::after { box-sizing: border-box; }
:root {
--bg: #0b1020;
--fg: #eef2ff;
--muted: #a8b5d1;
--line: rgba(255, 255, 255, 0.1);
--accent: #78a9ff;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1000px 620px at 50% -10%, #16203a 0%, transparent 70%),
var(--bg);
color: var(--fg);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 3rem);
}
.demo { width: min(720px, 100%); margin: auto; text-align: center; }
.muted { color: var(--muted); }
.demo__head h1 { margin: 0 0 .4rem; font-size: 1.55rem; letter-spacing: -0.02em; }
.demo__head p { margin: 0 auto 2.25rem; max-width: 48ch; font-size: .9rem; }
kbd {
font: inherit; font-size: .8em;
border: 1px solid var(--line); border-radius: 5px;
padding: 0 .3em; background: rgba(255, 255, 255, .06);
}
.stack {
position: relative;
perspective: 900px;
perspective-origin: 50% 45%;
height: 340px;
display: grid;
place-items: center;
touch-action: none;
}
.stack__glow {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
filter: blur(2px);
}
.card {
position: absolute;
width: min(340px, 82%);
padding: 1.25rem 1.35rem 1.4rem;
text-align: left;
border: 1px solid var(--line);
border-radius: 18px;
background: linear-gradient(155deg, rgba(32, 44, 74, .94), rgba(14, 19, 33, .95));
box-shadow: 0 24px 60px rgba(0, 0, 0, .55);
transform-style: preserve-3d;
transform:
translate3d(var(--tx, 0px), calc(var(--ty, 0px) + (var(--i) - 1) * 26px), calc(var(--i) * 46px))
rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: border-color .2s, box-shadow .2s;
overflow: hidden;
outline: none;
}
.card:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(120, 169, 255, .38), 0 24px 60px rgba(0, 0, 0, .55);
}
.pill {
display: inline-block;
font-size: .68rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: .5rem;
}
.card h2 { margin: 0 0 .35rem; font-size: 1.05rem; }
.card p { margin: 0; font-size: .85rem; }
.card__sheen {
position: absolute;
inset: -40%;
background: radial-gradient(300px 300px at var(--mx, 50%) var(--my, 50%), rgba(255, 255, 255, .16), transparent 65%);
opacity: var(--sheen, 0);
transition: opacity .25s;
pointer-events: none;
}
.demo__status {
margin: 1.75rem 0 0;
font-size: .78rem;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
@media (prefers-reduced-motion: reduce) {
.card, .card__sheen { transition: none; }
.stack__glow { display: none; }
}(() => {
const stack = document.getElementById("stack");
const status = document.getElementById("status");
const canvas = document.getElementById("glow");
if (!stack) return;
const cards = Array.from(stack.querySelectorAll(".card"));
const reduce = matchMedia("(prefers-reduced-motion: reduce)");
const MAX = 14; // max tilt in degrees
let px = 0, py = 0; // target, normalised -1..1
let cx = 0, cy = 0; // eased current
let raf = 0;
const ctx = canvas && canvas.getContext ? canvas.getContext("2d") : null;
function sizeCanvas() {
if (!canvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const r = stack.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(r.width * dpr));
canvas.height = Math.max(1, Math.round(r.height * dpr));
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function paintGlow(w, h) {
if (!ctx || reduce.matches) return;
ctx.clearRect(0, 0, w, h);
const gx = w / 2 + cx * w * 0.34;
const gy = h / 2 + cy * h * 0.3;
const g = ctx.createRadialGradient(gx, gy, 0, gx, gy, Math.max(w, h) * 0.55);
g.addColorStop(0, "rgba(120,169,255,0.34)");
g.addColorStop(0.45, "rgba(140,110,255,0.14)");
g.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
}
function apply() {
for (const card of cards) {
const d = parseFloat(card.dataset.depth || "1");
card.style.setProperty("--ry", (cx * MAX * d).toFixed(2) + "deg");
card.style.setProperty("--rx", (-cy * MAX * d).toFixed(2) + "deg");
card.style.setProperty("--tx", (cx * 26 * d).toFixed(1) + "px");
card.style.setProperty("--ty", (cy * 16 * d).toFixed(1) + "px");
card.style.setProperty("--mx", (50 + cx * 45).toFixed(1) + "%");
card.style.setProperty("--my", (50 + cy * 45).toFixed(1) + "%");
}
const r = stack.getBoundingClientRect();
paintGlow(r.width, r.height);
if (status) {
status.textContent =
"Tilt " + (cx * MAX).toFixed(1) + "° / " + (-cy * MAX).toFixed(1) + "°";
}
}
function loop() {
const ease = reduce.matches ? 1 : 0.14;
cx += (px - cx) * ease;
cy += (py - cy) * ease;
if (Math.abs(px - cx) < 0.0005 && Math.abs(py - cy) < 0.0005) {
cx = px; cy = py; raf = 0; apply(); return;
}
apply();
raf = requestAnimationFrame(loop);
}
function setTarget(nx, ny) {
px = Math.max(-1, Math.min(1, nx));
py = Math.max(-1, Math.min(1, ny));
if (!raf) raf = requestAnimationFrame(loop);
}
const sheen = (v) => { for (const c of cards) c.style.setProperty("--sheen", v); };
stack.addEventListener("pointermove", (e) => {
const r = stack.getBoundingClientRect();
setTarget(((e.clientX - r.left) / r.width) * 2 - 1, ((e.clientY - r.top) / r.height) * 2 - 1);
sheen("1");
});
stack.addEventListener("pointerleave", () => { setTarget(0, 0); sheen("0"); });
// Keyboard fallback: arrows tilt, Esc/Home recenter.
const STEP = 0.2;
const MAP = {
ArrowLeft: [-STEP, 0], ArrowRight: [STEP, 0],
ArrowUp: [0, -STEP], ArrowDown: [0, STEP]
};
for (const card of cards) {
card.addEventListener("keydown", (e) => {
if (e.key === "Escape" || e.key === "Home") {
e.preventDefault(); setTarget(0, 0); sheen("0"); return;
}
const d = MAP[e.key];
if (!d) return;
e.preventDefault();
setTarget(px + d[0], py + d[1]);
sheen("1");
});
card.addEventListener("blur", () => {
if (!stack.matches(":hover")) { setTarget(0, 0); sheen("0"); }
});
}
addEventListener("resize", () => { sizeCanvas(); apply(); });
sizeCanvas();
apply();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Depth Cards</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Depth Cards">
<header class="demo__head">
<h1>Depth cards</h1>
<p class="muted">Move the pointer over the stack — or focus a card and use the arrow keys — to tilt the layers in 3D. Press <kbd>Esc</kbd> to recenter.</p>
</header>
<section class="stack" id="stack" aria-label="Layered depth cards">
<canvas class="stack__glow" id="glow" aria-hidden="true"></canvas>
<article class="card" tabindex="0" data-depth="0.35" style="--i:0">
<span class="pill">Layer 01</span>
<h2>Parallax</h2>
<p class="muted">Each card translates on Z by its own depth factor.</p>
<div class="card__sheen" aria-hidden="true"></div>
</article>
<article class="card" tabindex="0" data-depth="0.65" style="--i:1">
<span class="pill">Layer 02</span>
<h2>Perspective</h2>
<p class="muted">One container perspective keeps the vanishing point shared.</p>
<div class="card__sheen" aria-hidden="true"></div>
</article>
<article class="card" tabindex="0" data-depth="1" style="--i:2">
<span class="pill">Layer 03</span>
<h2>Canvas accent</h2>
<p class="muted">A 2D canvas paints the glow that tracks the pointer.</p>
<div class="card__sheen" aria-hidden="true"></div>
</article>
</section>
<p class="demo__status" id="status" role="status">Tilt 0.0° / 0.0°</p>
</main>
<script src="script.js"></script>
</body>
</html>import { useEffect, useRef, useState } from "react";
export function ThreeDepthCards() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [active, setActive] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
const gl = canvas?.getContext("webgl");
if (gl) {
gl.clearColor(0.06, 0.1, 0.22, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
}
}, []);
return (
<section className="demo">
<h2>Depth Cards</h2>
<canvas
ref={canvasRef}
style={{ width: "100%", height: 260, background: "#080d18" }}
onPointerMove={() => setActive(true)}
/>
<button onClick={() => setActive((v) => !v)}>{active ? "Active" : "Activate"}</button>
</section>
);
}Depth Cards
Three layered cards use CSS perspective plus a canvas accent to communicate depth without a 3D library.
Support notes
The WebGL examples deliberately stay raw: a canvas, context, and render loop. Use this as a small foundation for a real shader or add a renderer only when scene complexity warrants it.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.