Building Cyber Pokedex: 3D Holographic Card Physics & PokéAPI Battle Analytics
Designing 3D tilt interaction physics, holographic foil CSS reflections, dynamic stat bar visualizations, and real-time REST data fetching in Cyber Pokedex.
By Uttam Thapa · · Frontend
⚡ Executive Summary (TL;DR)
Cyber Pokedex is a holographic encyclopedia built on React and PokéAPI. The interaction that carries it — a card that tilts
in 3D and throws a rainbow sheen under the cursor — is pure bounding-box maths plus two CSS gradients. This post covers that maths, why the transform must be
written outside React's render cycle to stay at 60 fps, and how deeply nested PokéAPI responses get flattened into a client model that a component can
actually read.
Figure 1: A species card. Glow colour, stat bars and sheen angle are all derived from data, not hand-authored per species.
Why Build a Toy Seriously
Frontend engineering can blur the line between utility software and interactive visual art, and a project like this is the cheapest place to learn the parts of
the platform that production work rarely lets you touch: 3D transforms, compositor behaviour, and the discipline of normalising a hostile API response.
Everything below is a technique that transfers directly to commercial interfaces.
The 3D Card Tilt, Explained
The effect reads as physics but is entirely a coordinate transform. Take the cursor position, express it relative to the centre of the element as a value from
−1 to 1 on each axis, then multiply by a maximum tilt angle.
const handleMouseMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left; // cursor X inside the element
const y = e.clientY - rect.top; // cursor Y inside the element
// Normalise to -1..1, then scale to a maximum tilt of 15 degrees.
const rotateX = ((y - rect.height / 2) / (rect.height / 2)) * -15;
const rotateY = ((x - rect.width / 2) / (rect.width / 2)) * 15;
e.currentTarget.style.transform =
'perspective(1000px) rotateX(' + rotateX + 'deg) rotateY(' + rotateY + 'deg) scale3d(1.05, 1.05, 1.05)';
};
Two details decide whether this feels expensive or cheap. The rotateX term is negated so the card leans towards the cursor rather than away
from it — the wrong sign is uncanny and readers notice without being able to say why. And perspective(1000px) must come first in the transform list;
CSS applies transforms right to left, so a perspective declared last never applies to the rotations before it.
🚨 Do not put the tilt in React state
Storing rotateX and rotateY in useState means a React render, a reconciliation pass, and a commit for every
mousemove — which fire faster than frames. Writing style.transform directly keeps the work on the compositor, where a transform costs
almost nothing. This is the one place where reaching past React is the correct answer rather than a shortcut.
The Holographic Sheen
The rainbow reflection is two stacked layers over the card art. A conic-gradient supplies the spectrum, a radial-gradient supplies a
soft highlight positioned at the cursor, and both are composited with mix-blend-mode: color-dodge so they brighten the artwork underneath instead of
sitting on top of it as a film.
.card__sheen {
position: absolute;
inset: 0;
background:
radial-gradient(circle at var(--px) var(--py),
rgba(255, 255, 255, 0.5), transparent 45%),
conic-gradient(from 210deg,
#ff0055, #ffe600, #00ff66, #00f0ff, #ff0055);
mix-blend-mode: color-dodge;
opacity: 0.55;
pointer-events: none;
}
The cursor position is handed to CSS as two custom properties (--px, --py) updated in the same handler as the tilt. Custom properties
are the cleanest bridge between JavaScript pointer maths and a purely declarative paint, and they keep the gradient definition in the stylesheet where it belongs.
Normalising PokéAPI Into a Client Model
PokéAPI returns richly nested JSON: base stats, ability definitions, move sets, and type relations, each behind its own array of objects with a
{ name, url } shape. Rendering directly from that structure produces components full of data.stats[3].base_stat — unreadable, and
brittle the moment the ordering changes.
// One boundary function turns the API shape into the app's shape.
function toSpecies(raw: PokeApiResponse): Species {
return {
id: raw.id,
name: raw.name,
types: raw.types.map((t) => t.type.name),
stats: Object.fromEntries(
raw.stats.map((s) => [s.stat.name, s.base_stat])
) as Record<StatName, number>,
sprite: raw.sprites.other['official-artwork'].front_default,
};
}
Every component downstream reads species.stats.speed. When the upstream API changes, exactly one function needs editing.
The Type Effectiveness Matrix
Damage multipliers are the one piece of genuine domain logic in the app. Dual types compound multiplicatively, which is what produces the full ladder of
outcomes rather than a simple strong/weak flag.
| Multiplier |
How it arises |
Example |
| 4× | Both types weak to the move | Grass move vs Water/Ground |
| 2× | One weakness, one neutral | Water move vs Fire/Flying |
| 0.5× | One resistance, one neutral | Fire move vs Water |
| 0.25× | Both types resist | Fire move vs Water/Rock |
| 0× | Either type is immune | Normal move vs Ghost |
Colour as Data
Each card derives its glow border and accent from its primary type, so a new species needs no design work at all:
Fire
#ff0055
Electric
#ffe600
Water
#00f0ff
Grass / Poison
#00ff66
💡 Accessibility note
Wrap the tilt and sheen in @media (prefers-reduced-motion: reduce) and drop to a static card. Cursor-driven parallax is a known trigger for
vestibular discomfort, and honouring the preference costs three lines.
✅ Key takeaways
- ✓Normalise pointer coordinates against the bounding box. One small maths helper drives every hover micro-interaction you will ever build.
- ✓Write high-frequency transforms to the DOM, not to state.
mousemove outruns React's render loop.
- ✓Order matters in
transform. perspective() first, or the rotations render flat.
- ✓Flatten external APIs at one boundary. Components should never index into an upstream response shape.
- ✓Respect reduced-motion. The showpiece interaction is exactly the one some readers need switched off.
Frequently asked questions
How do you build a 3D tilt effect on a card in React?
Measure the cursor position relative to the element's bounding box, normalise it to a range of -1 to 1 on each axis, and multiply by a maximum tilt angle. Write the resulting transform straight to the element rather than through state.
Why does my CSS perspective transform look flat?
Transform functions apply right to left, so perspective() must come first in the list. Declared last, it never applies to the rotations before it.
Should cursor-driven animations respect reduced motion?
Yes. Cursor parallax is a known trigger for vestibular discomfort, so wrap the effect in a prefers-reduced-motion query and fall back to a static card.
Home · Projects · Blog · Services · Résumé · Contact