I run a small comparison site for crypto debit cards, and the main page is a 3D ring of 11 cards you can spin, tap and flip. It is built with React 19, @react-three/fiber, drei and three.js r185. You can try it here: cryptocardinsider.com/?view=gallery.
Getting a ring of cards on screen was the easy part. Making them look like real cards and run smoothly on a mid-range phone took most of the work. These are the fixes, with the code.
1. Use the real card proportions
My first cards were "roughly card shaped" and looked wrong next to the card art. Payment cards follow ISO/IEC 7810 ID-1: 85.60 × 53.98 mm with a 3.18 mm corner radius. I put those numbers in one constants file and derived everything else from them, including the texture size and the corner radius in pixels.
export const CARD_ISO = { widthMm: 85.6, heightMm: 53.98, cornerMm: 3.18 } as const
export const CARD_W = 1.42 // scene units
export const CARD_H = 0.89
export const CARD_CORNER_R = CARD_W * (CARD_ISO.cornerMm / CARD_ISO.widthMm)
export const CARD_TEX_W = 1024
export const CARD_TEX_H = 640
Once the mesh, the texture and the corner mask all come from the same ratio, the edges line up and the card stops looking like a tablet.
2. ShapeGeometry needs its own UVs
A rounded rectangle is easiest as a THREE.Shape with four absarc corners passed to ShapeGeometry. The catch is that the UVs it generates follow the shape's coordinates, not 0 to 1, so a photo texture comes out smeared. The fix is to remap every vertex into the bounding box:
function assignPlanarUVs(geo: THREE.ShapeGeometry) {
const pos = geo.attributes.position as THREE.BufferAttribute
const uv = geo.attributes.uv as THREE.BufferAttribute
const bbox = new THREE.Box3().setFromBufferAttribute(pos)
const size = new THREE.Vector3()
bbox.getSize(size)
for (let i = 0; i < pos.count; i++) {
uv.setXY(i, (pos.getX(i) - bbox.min.x) / size.x, (pos.getY(i) - bbox.min.y) / size.y)
}
uv.needsUpdate = true
}
3. Clip the card art to the same rounded corners
Card images from different issuers arrive in different states. Some PNGs already have transparent rounded corners. Others are rectangles with a black or white background in the corners, which shows up as little dark triangles on the 3D mesh.
I draw each image to a canvas and cut the alpha to the ISO corner radius with destination-in:
const r = rh * (CARD_ISO.cornerMm / CARD_ISO.heightMm)
ctx.globalCompositeOperation = 'destination-in'
ctx.fillStyle = '#ffffff'
roundRectPath(ctx, rx, ry, rw, rh, r)
ctx.fill()
ctx.globalCompositeOperation = 'source-over'
Images that already have baked corners skip this step, because masking them twice leaves a thin halo.
4. Share one geometry across all cards
Every card has the same outline, so there is no reason to build 11 copies of it. A module-level singleton is enough:
let shared: THREE.ShapeGeometry | null = null
export function getSharedCardPlaneGeometry() {
if (!shared) shared = createCardPlaneGeometry()
return shared
}
Only the materials differ per card.
5. Pick a quality tier once, then scale everything from it
Phones were the problem. I now pick one of three tiers at startup from prefers-reduced-motion, a mobile check and navigator.hardwareConcurrency. A mobile device with 6 or fewer cores, or anything with 2 or fewer, gets low.
The tier then sets several things at once:
const maxDpr = tier === 'high' ? Math.min(window.devicePixelRatio || 1, 1.5)
: tier === 'medium' ? 1.25 : 1
// textures: 1024×640 on high, 640×400 on medium, 512×320 on low
// floor segments: 64 / 48 / 32
// antialias off on low, powerPreference 'default' on low
Capping DPR at 1.5 even on the high tier matters more than it looks: at 3× DPR a canvas renders nine times the pixels of 1×, for detail you can barely see on a card. drei's <AdaptiveDpr pixelated /> lowers it further while the frame rate drops.
6. Stop rendering when nobody is looking
The canvas renders every frame while it is on screen, and not at all when the tab is hidden or the gallery is covered by another view:
const pageVisible = usePageVisible() // listens to visibilitychange
const canvasActive = pageVisible && !suspended
<Canvas frameloop={canvasActive ? 'always' : 'never'} /* ... */ />
With frameloop="never", a hidden tab draws no frames at all instead of spinning a WebGL loop in the background.
A small one: tap vs drag
On touch screens, a tap that moves a few pixels was being read as the start of a spin, so cards would not open. I now treat any pointer movement under 22 px as a tap. It is a small number, but it decides whether the gallery feels responsive or broken on a phone.
What it is for
The gallery compares fees, availability and scores for 11 crypto cards (KAST, RedotPay, Crypto.com, OKX and others). For RedotPay there is also a country availability checker built from its help center lists.
Disclosure: I'm a RedotPay partner and may earn a commission. If you order a RedotPay card, the code RDVIR took the virtual card from $10 to $8 and RDPHY took the physical card from $100 to $80 when I re-checked both in the app on October 26, 2026. The partner sign-up link is cryptocardinsider.com/go/devto (invite code xn03i), and checkout screenshots are on the promo code page. None of this is financial advice.
If you have built something similar in R3F, I'd like to hear how you handled texture memory on low-end phones.
Top comments (0)