---
title: "hit boxes, and the card that flinches"
url: "https://sanyam.sh/blogs/the-card-flinches-when-you-reach-its-edge"
date: "2026-09-20"
readTime: "4 min read"
description: "Reach the edge of a card that tilts and it buzzes. The hit target is inside the transform, so hovering it moves it out from under you. Hold still and it does not buzz, it dies."
---

# hit boxes, and the card that flinches

Every card that tilts under your pointer has this at its edges. You reach for a
corner, it buzzes, and you back off toward the middle. It looks like a rendering
fault. Every hover in there is real and every leave is correct.

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

_An interactive demo runs here on the page._

## it stops when you look at it

Park the pointer at the edge and hold it still. The buzzing stops. The card
tilts once, drops the hover, levels, and then nothing. A browser is not required
to re-run hit testing for a pointer that has not moved,[^spec] so the edge comes
back under you and nothing fires.

| t | `rotateY` | under the pointer |
| --- | --- | --- |
| 16ms | -7.14° | the stage |
| 214ms | -0.24° | the button, nothing fired |
| 1512ms | 0.00° | the button, nothing fired |

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

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.

The browser tests hover against the box it painted, which is 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[^eight] and leaves you
standing off the target that moved it.

> The region that decides the state is moved by that state. That is the whole
> bug.

## the timer, the deadband, the hitbox

Three answers, in the order people reach for them.

**A timer on the leave**

Fixes nothing. It slows the buzz to the length of the timer, and against a
still pointer it does nothing at all, because no second event is coming.

**A deadband**

Works. Make the region you leave larger than the one you enter and the
oscillation has nowhere to sit.[^schmitt] Still the weaker of the two, since
it damps the loop rather than removing it.

**A hit target outside the transform**

Removes the loop. The card keeps the transform, and the target sits beside
it on the layout box where tilting cannot reach it.

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

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

## what to call it

Games solved this decades ago by authoring collision geometry separately from
render geometry. It is why a character's hitbox does not animate with every
frame of their animation.

Control theory names the failure rather than the fix. A binary controller whose
output moves its own input is **chattering**, and the oscillation it settles
into is a limit cycle.

The web has no word for it, so it keeps getting shipped.

_An interactive demo runs here on the page._

[^spec]: 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.

[^eight]: 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.

[^schmitt]: Control theory calls that hysteresis. A Schmitt trigger is the same
idea in a chip: one threshold going up, a lower one coming down.
