Can I give you my card?

DevDesignInteractionMotion

I wanted something to hand people at a conference that was quicker than spelling my email into their phone. It lives at joeparkerrees.co.uk/card. It's a static page with everything stored locally, so it still loads on bad conference wifi.

The front is a relief of my face made out of cubes, and it leans as you tilt your phone.

The relief

The relief is a grid. Every cell gets a cube, pushed out towards you depending on how bright that cell is, which turns a photo into a surface you can light. It started as a p5 sketch that called box() once per pixel inside a nested loop, which is up to ten thousand draw calls a frame, and it ran at 24fps. Now it's a single InstancedMesh, so the whole thing is one draw call and the movement is done through the instance matrices.

Getting the tilt working

I spent a long evening tweaking numbers that were never actually being used. iOS only gives you access to the gyroscope over a secure connection, and if the connection isn't secure it just never sends the event. There's no error or permission prompt and nothing in the console. So a card with the sensor blocked looks exactly the same as a card where the tilt is broken, and I'd been testing over my local network on plain HTTP the whole time.

Putting a tunnel in front of the dev server fixed it in one command. I've left a note about it at the top of the repo, because I know I'll do it again.

Holding the phone

The deviceorientation event gives you three angles, and the obvious thing is to read gamma for left and right. The problem is those angles go weird near a beta of 90, which is roughly how everyone holds a phone. Rolling fifteen degrees from upright puts gamma at eighty-two, and going just past vertical flips it to minus eighty-two. So the card was jumping from one extreme to the other when the phone had only moved a couple of degrees.

Using gravity avoids that. If you convert beta and gamma into which way is down relative to the phone, you get two angles that work the same way whatever angle you're holding it at. The only rotation that doesn't register is turning around the vertical axis, and that shouldn't move the card anyway.

card.js
// Unit vector pointing along world-down, in the device's own axes.
// Third row of Rz(alpha)*Rx(beta)*Ry(gamma), negated. Alpha drops out
// on its own: a compass heading cannot change which way is down.
function gravity(betaDeg, gammaDeg) {
  const b = betaDeg * DEG
  const g = gammaDeg * DEG
  return {
    x: Math.cos(b) * Math.sin(g),
    y: -Math.sin(b),
    z: -Math.cos(b) * Math.cos(g),
  }
}

// Continuous everywhere, so no clamp-slamming near vertical.
const roll  = Math.asin(clamp(gx, -1, 1))
const pitch = Math.atan2(-gy, -g.z)

What moves

My first version had this the wrong way round. Rolling your wrist turned the face about twenty degrees and the card about five, so the face swung around inside a frame that stayed still, and it all just looked like an effect playing on a screen. You're holding the card, so now the card gets the rotation, along with a small shift and a shadow that moves the other way. The face leans the opposite way by a fraction of the tilt, heavily damped, so it only moves a little.

The corners were also disappearing. A card rotating in perspective ends up bigger than its own layout box, because the edge rotating towards you gets closer to you. At full tilt it was going over the edge of an iPhone 15 by a pixel and a Pro Max by three, and because the stage clips, the corners were getting cut off mid-movement.

The outline

For a while the outline of my head had a hard dark edge and I couldn't work out why. The source photo is a cutout on a white background, so the soft edge of my hair counts as part of me, and white ends up at the top of the contrast range. So every cell the outline crossed came out as the tallest and biggest cube on the whole thing, which drew a ring around my head.

The fix was to stop treating the outline as a yes or no. The mask runs at four times the grid resolution and gets averaged down, so a cell on the edge holds a fraction of coverage, and the colour gets weighted by that coverage before averaging so a cell's height never comes from the background. Then the mask gets shrunk and blurred back out by the same amount, so the cubes around the edge get smaller until they disappear into the card.

If you scan the code on it you get my vCard, which works with no signal at all. That was really the main reason I made it.

View project ↗

Posted on 15 August 2026
Permalink, All Playground