the card that tilts out from under you

4 min read

Reach for the edge of a card that tilts under your pointer and it buzzes. It looks like a rendering fault. It is not.

Hovering the card tilts it away from you. Tilted, it is no longer under your pointer, so the hover drops and the card comes back. Now you are hovering it again.

The pointer never moves. The card does.

Every hover in there is real and every leave is correct. Nothing fires that should not.

Two cards below. Same tilt, same geometry. One of them has a fix in it.

Hold the pointer on the tab at the left edge of each card until its bar fills.

lefthold the tab
righthold the tab
Which one let you fill it?

it stops when you look at it

Park the pointer at the edge and hold it perfectly still. The buzzing stops.

The card tilts once, drops the hover, levels, and then nothing happens at all. A browser is not required to re-run hit testing for a pointer that has not moved,1 so the edge comes back under you and no second event fires.

trotateYunder the pointer
16ms-7.14°the stage
214ms-0.24°the button, nothing fired
1512ms0.00°the button, nothing fired

One enter and one leave across the whole 1.5 seconds, with the pointer parked the entire time.

So inspecting it is what kills it. A hand is never quite still, and that jitter is the only reason you get a buzz instead of a dead card.

why the edge moves

The browser tests hover against the box it painted, which is the box after the transform. A face turning away under a perspective also gets smaller, so the edge you are aiming at pulls about eight pixels inward.2

You are still pointing at where the card was. The card is not there any more.

Hovering it moves the thing you are hovering. That is the whole bug.

the fix

Put the thing that catches the pointer on something that does not tilt.

inside the card
misses
beside the card
hits
The outlined box is what catches the pointer, and the pointer is parked at the same place in both.

The card keeps its transform and stops taking the pointer at all. The button goes beside it instead, stretched over the slot they both sit in, so the hit box is the slot's box and never moves.

// a child, so it tilts with the card and the hit box goes with it
<Card style={{ transform }}>
  <button className="absolute inset-0" />
</Card>

// a sibling, so the hit box is the slot's box and cannot move
<Slot>
  <Card style={{ transform }} className="pointer-events-none" />
  <button className="absolute inset-0" />
</Slot>

in short

Anything that moves when you hover it can do this. Keep the thing that catches the pointer still, and let the thing you can see move.

Footnotes

  1. CSS does not say whether an element still matches :hover after it moves out from under a pointer that did not itself move. Chrome waits for the next pointer event.

  2. Measured on foil-card, the lab this came out of: a 420px card at 8.94 degrees pulls its near edge in by 8.1px.


More posts