Raycaster-Free Hover Picking
A ring of plates spins entirely on the compositor, so the main thread has no idea where anything is. Instead of measuring the DOM, the component recomputes the same transform in JavaScript — rotate, tilt, perspective divide — and hit-tests the resulting screen points against the pointer. A crosshair marks the computed position so you can see the mirror agreeing with the pixels.
Raycaster-Free Hover Picking
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/cpu-mirror-picking.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
5 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| count | number | = 14 | Plates around the ring. |
| period | number | = 26 | Seconds per revolution. The mirror divides the animation clock by this. |
| showMirror | boolean | = true | Draw the computed crosshair and the pick-radius halo. |
| className | string | — | Merged onto the perspective root via cn. |
How it works
The technique in order, not the code in order.
The spin is one
Element.animatekeyframe pair withiterations: Infinity. It runs off the main thread, which means it keeps time even while JS is busy, and means the main thread must reconstruct the angle rather than remember it:animation.currentTimedivided by the period gives the exact phase the compositor is drawing.The mirror is four lines that shadow the CSS one for one. Two nested
rotateY(ring spin, plate angle) collapse into a single angle sum, so a plate centre sits at(r·sin a, 0, r·cos a). The stagerotateXturns that depth into vertical offset. The perspective divided / (d − z)reproduces what the browser does with theperspectiveproperty.Picking runs once per frame, not per
pointermove. The geometry moves under a stationary cursor, so the correct answer changes on frames where no input arrived at all. An event-driven hit test would leave the hover state stuck on whatever plate happened to be there when the pointer last moved.Plates with
z <= 0are rejected before the distance compare. They are on the far side of the ring, drawn behind the near plates, and picking them would let a click pass through the front of the object.The winner is the nearest projected point within a squared pick radius. Squared, because a comparison against
r²gives the same ordering as a comparison againstrwithout the square root.This is the only correct approach when a vertex shader, a CSS transform, or GPU skinning moves the geometry after it leaves JavaScript. The CPU-side geometry never changed, so
Raycasterintersects the undeformed mesh and reports hits on a shape that is not on screen. Mirroring the vertex transform on the CPU is the fix — the same maths, applied to one point instead of every vertex.Everything inside the perspective root is
pointer-events: none. There is nothing for the browser to hit-test; the container takes the pointer and the mirror decides what was under it.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| PERSPECTIVE | = 900 | Shared by the CSS perspective property and the JS divide. If the two ever disagree the crosshair drifts away from the plate it is meant to mark — which makes this the single most important value to keep in one place. |
| TILT | = 16 | Degrees of stage lean. Enough that the ring reads as a ring rather than a row; past ~30° the far plates hide behind the near ones and picking gets ambiguous even with the depth rejection. |
| PICK_RADIUS | = 44 | The pointer must land within 44 px of a projected centre. Matches the 44 px minimum touch target, and is wide enough to forgive the plate scaling down as it recedes. |
| PICK_RADIUS_SQ | = PICK_RADIUS * PICK_RADIUS | Compared against squared distance so the loop never calls Math.sqrt. Fourteen square roots a frame is not a real cost, but the squared compare is free and is the habit worth having in a loop that might hold a thousand points. |
| RADIUS_RATIO | = 0.36 | Ring radius as a fraction of container width. Keeps the ring inside the box at its widest and leaves room for the pick-radius halo at the edges. |
| SPIN_PERIOD | = 26 | Seconds per revolution. Slow enough that a plate stays under the cursor long enough to click, fast enough that the per-frame re-pick is visibly necessary. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The spin animation is never created, so the ring holds still at phase zero. Picking is unaffected — the mirror simply reads a spin of 0 — which is the point: the technique is about where things are, not about them moving.
The animation is cancelled on unmount or period change, which releases the compositor animation and nulls the ref. The frame loop unsubscribes with the hook; the shared RAF parks itself once no subscriber is left. No listeners are attached outside React.
Hold the pointer still over a plate: the crosshair tracks the plate as it rotates away and hover releases at the pick radius, with no pointer events involved. Move to the middle of the ring and nothing on the far side lights up. Click and the plate under the crosshair — not the one under a stale rect — is the one that selects. With reduced motion the ring is static and picking still works.
Pitfalls
getBoundingClientRect()per plate per pointermove. That is N forced layouts per event, and the rects are wrong anyway: a compositor-driven transform does not update layout boxes, so every rect reports the plate's unanimated position.- Hit-testing only on
pointermove. The ring rotates under a still cursor, so hover has to be re-evaluated every frame or it freezes on a stale plate. - Letting the CSS and the mirror drift apart. A tweak to
perspective, the tilt, or the transform order in one place and not the other produces a crosshair that is subtly, maddeningly offset. Keep both reading the same constants. - Putting
overflow: hiddenon atransform-style: preserve-3delement. Overflow is a grouping property: it silently forces the subtree back to flat and the ring collapses into a line. - Trusting
Raycasteragainst a shader-deformed mesh. The vertex shader runs on the GPU; theBufferGeometryin memory is the rest pose. The ray intersects the rest pose and hits nothing you can see. - Reading
animation.currentTimeas a number without narrowing. It is typedCSSNumberish | null— null before the animation starts, and permitted by spec to be aCSSNumericValue. - Forgetting the far-half rejection. Without it the ring picks through itself, and a click near the centre selects a plate on the back.
Notes
- The depth fade rides along on the projection pass that was going to run anyway — one extra style write per plate, no second traversal and no extra maths.
- The same mirror generalises: for a three.js scene, project the object's world position with
vector.project(camera)and compare in NDC; for a shader-deformed mesh, re-run the deformation for that one vertex on the CPU. The principle is identical — hit-test the transform, not the source geometry.