Can I give you my card?

DevDesign

I wanted something to hand over at a conference that was faster than spelling my email into someone's phone. It lives at joeparkerrees.co.uk/card, it is a static page with everything vendored locally, and the first thing it has to survive is conference wifi.

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

One cube per cell

The relief is a grid. Every cell gets a cube, pushed toward you by how bright that cell is, so a photograph becomes 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 immediate mode draw calls a frame and ran at 24fps. Here it is a single InstancedMesh, so the whole sculpture is one draw call and the movement rides on the instance matrices.

The tilt did nothing, and the reason was the URL

I spent a long evening tuning numbers that were never being read. iOS only hands over the gyroscope in a secure context, and when it refuses it does so by never dispatching the event. No error, no permission prompt, nothing in the console. A card whose sensor is blocked looks exactly like a card whose tilt is broken, and I had been testing over a LAN IP on plain HTTP the entire time.

Putting a tunnel in front of the dev server fixed it in one command. That is the note I left at the top of the repo, because it will happen again.

gamma falls apart where you actually hold a phone

The deviceorientation event hands over three Euler angles, and the obvious move is to read gamma for left and right. That triple degenerates near beta 90, which is roughly how everyone holds a phone. Rolling fifteen degrees from upright puts gamma at eighty two, and carrying on past vertical flips it to minus eighty two. The card was slamming between its limits for a movement of a couple of degrees.

Gravity has no seam like that. Converting beta and gamma into where down points in the phone's own frame gives two angles that behave the same everywhere. The one rotation it cannot see is a turn about the vertical axis, which should not 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)

Which part should move

My first version had this backwards. A wrist roll turned the sculpture about twenty degrees and the card about five, so the face swung around inside a frame that sat still, and the whole thing read as an effect playing on a screen. The card is the object you are holding, so it takes the rotation, a small shift, and a shadow that swings the other way. The relief leans against the tilt by a fraction of it, heavily damped, and mostly just breathes.

Something I had not thought about until corners started disappearing: a card rotating under a perspective projects larger than its own layout box, because the edge swinging toward you is nearer to you. At full tilt it was overrunning an iPhone 15 by a pixel and a Pro Max by three, and the stage clips, so the corners were being sliced off mid-movement.

The outline around my face

The silhouette had a hard dark edge I could not explain for a while. The source photograph is a cutout matted onto white, so the anti-aliased fringe of the hair reads as subject, and white normalises to the top of the contrast range. Every cell the outline crossed came out as the tallest and largest cube on the sculpture, which drew a ring around my head.

The fix was to stop asking the silhouette a yes or no question. The key runs at four times the grid and averages down, so an edge cell carries a fraction of coverage instead of a verdict, and the colour is weighted by that coverage before averaging so a cell's height never comes from the background behind it. Then the mask is eroded and blurred back out over the same distance, and the outer cubes shrink away into the card.

Scan the code on it and you get the vCard, which works with no signal at all. That part was always the point.

View project ↗

Posted on 15 August 2026
Permalink, All Playground