Picture a brass line at the right edge of an empty page. Hairline width, paper register, the same colour the alchemists kept in stoppered vials. You move a cursor toward it and, for most of the journey, nothing happens. The page is quiet. The line stays at rest. And then, at some moment you couldn't name in advance, the line catches — a brief flare with a glow around it, like a struck bell made of light. The page has decided that you meant it.
The decision wasn't a click. It wasn't a hover. It wasn't a timer running out in some corner of the state machine. It was the simultaneous agreement of three independent tests — three gates, each measuring a different dimension of what your hand was doing. Strip any one of them, and the line stays silent for the rest of the afternoon.
That's the whole subject of this post. A hover, before it can become a decision, has to pass through the intersection of aim, motion, and commitment. The math is small. The implications are not.
Intent isn't one condition. It's an intersection.
The predicate, before any of the geometry
The whole argument fits on four lines:
// The whole argument, in one line:
const fire =
inTriangle && // aim
sampleClosing >= floor && // motion
closingDistance >= commit; // commitmentAim asks whether the cursor is inside a triangle drawn from a captured starting point to the threshold. Motion asks whether this single sample of cursor movement crossed a per-sample floor — was the hand actually moving, this frame, or just resting in place? Commitment asks whether enough cumulative forward motion has accumulated since the starting point was captured — has the gesture earned its position, or is it just drifting?
A reader who's never thought about predictive intent before might assume one of these alone is enough. The first two iterations of this code thought so too.
Three iterations, told as a story
The first version of this code did only the first gate. A triangle, captured the moment the cursor entered an approach band, redrawn from a fixed apex toward the threshold. If the cursor was inside the triangle on the next sample, the line fired.
That sounded right and was almost entirely wrong. The cursor is always inside a triangle drawn around its current position. The first sample after the apex is captured has travelled, at 30 Hz, perhaps two pixels — well inside any triangle worth its name. The line fired the moment you entered the band, regardless of where you were heading. Triangulation, but no triangulation. A presence check wearing the costume of a direction check.
The second version added a cumulative threshold: the cursor had to travel at least 25 pixels of forward motion, inside the triangle, before the line could fire. The numbers were better. The behaviour was still wrong. A slow drift inside the band — the kind of unconcerned hover that happens when a hand is resting near the threshold without any aim — would gradually accumulate the 25 pixels and fire the line. The user hadn't committed to anything. The geometry had simply assigned them an intent.
The lesson worth carrying: a cumulative measurement is direction-blind to the path that built it. Twenty- five pixels accumulated in one decisive sweep is the same number as twenty-five pixels accumulated over a minute of aimless wandering. The model can't tell them apart, so it doesn't.
The third version added the per-sample floor: each sample, taken alone, has to clear at least two pixels of forward motion. If a sample stalls below that floor — a stall, a drift, a reversal, even a single sideways flicker — the apex re-anchors to the cursor's current position and the cumulative counter resets to zero. The corridor follows the user. The gate doesn't.
// On every ~30 Hz pointermove sample inside the approach band:
const sampleClosing = cursor.x - apex.lastX; // positive = forward
apex.lastX = cursor.x;
if (sampleClosing < MIN_SAMPLE_CLOSING_PX) {
// Stall, drift sideways, or reverse — re-anchor the apex.
// The corridor follows the user; the gate doesn't.
apex.x = cursor.x;
apex.y = cursor.y;
return;
}
// Genuine forward motion. Run the geometry tests.
const closingDistance = cursor.x - apex.x;
const inTriangle = pointInTriangle(
cursor,
apex,
{ x: lineX, y: 0 },
{ x: lineX, y: viewportHeight },
);
if (inTriangle && closingDistance >= MIN_APPROACH_DELTA_PX) {
fire();
}With the third gate in place, the model finally agrees with hand-feel. A drift fails because per-sample closing stays under the floor. A wander fails because any sideways segment re-anchors the apex. Only continuous, directed, committed motion accumulates enough to fire the line. The geometry stops assigning intent and starts recognizing it.
Aim, alone
Aim is geometry. The triangle, anchored at the apex, opens to meet the full vertical span of the threshold line. The test — a sign-of-cross-products point-in-triangle — runs in two multiplications and three additions per frame, so cheap that a 30 Hz sample rate is luxurious overhead.
The predicate is honest about what it knows. It says: the line connecting your starting position to the threshold passes through the wedge of space your cursor currently occupies. It does not say: you are heading to the threshold. A cursor stalled mid-triangle still passes the aim test. A cursor drifting backward still passes, until the apex re-anchors and pulls the triangle along with it. Aim only ever describes a possibility.
This is the trap that the first iteration fell into. Geometry alone is too willing to say yes. The triangle agrees with too many cursors — and worse, agrees the fastest with the cursors that haven't moved at all. A hover that's not even pretending is the easiest to fit inside any triangle you draw around it.
Motion, against the floor
Motion is biology. The per-sample floor isn't a mathematical convenience — it's a number reverse- engineered from the human hand. Two pixels per sample at 30 Hz works out to sixty pixels per second. Below that, you're looking at the kind of microscopic drift a steady hand produces without intending to: the slow settling of an idle cursor, the involuntary tracking of eye movement through fingertip, the noise floor of being a body. Above that, you're looking at a hand that's decided to go somewhere.
The lab visualizes this as an arrow that draws at the cursor on every sample, length proportional to that sample's rightward closing. Below the floor, the arrow dims. Above the floor, the arrow brightens. The discontinuity at the floor is intentional. A smooth fade would read as the arrow is getting harder to see; a discontinuity reads as I just crossed something. The pedagogy depends on the threshold being a threshold, not a gradient.
Two coexistent claims, both true: the floor is a calibration, and the floor is also a categorical distinction. The number changes if you change the sampling rate or the input device. The category — the difference between drifting and arriving — does not.
Commitment, and the gap that doesn't close
Commitment is the cumulative measure: how many pixels of forward motion have accumulated since the apex was captured. The lab draws this as a brass-coloured fuel gauge growing from the apex toward the threshold, length proportional to commitment, brightening once the gauge fills past 25 pixels.
The geometry was deliberate. The gauge stretches towardthe line, not as an abstract bar somewhere offscreen. The reader's eye follows it from apex to almost-line. The fact that the brass runs out before reachingthe threshold is the visual proof that motion alone wasn't enough — the gauge fills, the gate trips, the line catches, and there's still a small unbridged distance between the gauge's end and the threshold itself.
The fuel gauge runs out of brass before it reaches the line. That gap is the part the human crosses.
The gap matters more than the gauge. It's the visual argument that arrival isn't automatic — that a predicate of intent has to leave room for the gesture to finish under the user's own hand. The gauge is the system's work. The gap is the human's share.
The line catches
When all three gates pass on the same sample, the threshold line fires: a 380 ms pulse with a soft glow, single-shot, debounced on a 600 ms cooldown so a held gesture doesn't strobe. The pulse is the page's first sentence with a verb. Three nouns until that point — a triangle, an arrow, a target — all of them static between samples. The fire is the page choosing to do.
A receipt matters as much as the math. Without one, the reader has to take the page's word that all three gates passed. With one, the reader sees brass flare, brief and unambiguous, a piece of geometry briefly becoming kinetic. The argument lands on a single observable thing moving once. That's the entire demonstration: three independent yeses, one consequence.
The reader holds the gates
The two thresholds — two pixels per sample, twenty-five pixels of cumulative motion — are exposed as range inputs in a small panel in the bottom-right of the lab page. Drag motion floor down to zero and watch the system fire on idle drift. Drag commitment up to eighty and watch a normal-feeling forward push fail to register. Both extremes teach the same thing.
The constants aren't laws. They're findings.
A constant in code is a claim. A slider is a question. When the value lives as a literal, the reader has to trust that two and twenty-five are the right numbers. When the value lives as a slider, the reader can break the predicate on either end and discover, by feel, where the math stops working. Reset returns to two and twenty-five. The defaults earn their position by being the values the reader returns to after exploring.
The receipt that survives
The fire pulse is a CSS animation. The site honours prefers-reduced-motion: reduce globally — every animation duration set to zero, every transition silenced, no negotiation. Which means, naïvely, the brass-line pulse vanishes for the user who's asked for less motion. The receipt disappears for the person most likely to need a clear, calm yes.
That's a real accessibility regression, not a theoretical one. The fix is one rule:
@media (prefers-reduced-motion: reduce) {
.intent-fire {
animation: none !important;
opacity: 1;
filter: none;
}
}Under reduced motion, the line still catches. It just doesn't pulse — the React layer holds the element mounted for the same 380 ms, then unmounts. The receipt stays. The motion goes.
A receipt that disappears for the user who needs it most isn't a receipt at all.
The principle generalises. Any global accessibility floor — reduced motion, increased contrast, larger text, forced colours — eventually meets a feature that needs to opt itself partway out, not for ornament but because the feature's communicative job depends on something the floor flattens. The right pattern is a named exception, kept tight, justified each time. Not a loophole. A specific, defensible, accessible alternate path.
What this generalises
The predicate this lab argues isn't about sidebars. It isn't even, really, about cursors. The shape of intent — *aim, motion, commitment* — surfaces wherever a system has to recognize a deliberate gesture in a stream of ambiguous events. A swipe in a list. A long-press on a tile. A scroll-snap that decides whether the reader is browsing or departing. A dismiss gesture that has to distinguish a confident downward flick from an accidental one.
Each of those gestures has its own version of the three gates. Each one fails the same way when only one or two gates are in place — by being too willing to say yes, which is the user-facing failure mode of every predicate that cuts corners on intent recognition.
The other thing worth carrying forward: build the visualisation first. The triangle, the arrow, the gauge — none of them are user-facing chrome. They're diagnostics that became the demo. Until you can see the geometry that's supposedly running, every claim about your math is unfalsifiable. Once you can see it, the math either holds or visibly breaks under your cursor. The visualisation tells you whether the predicate is calibrated against actual hand-feel or against a spreadsheet of what you think hand-feel ought to be.
And once the math holds, the visualisation becomes the documentation. There's no prose version of three gates intersect, the brass line catches that's as legible as a reader watching it happen under their own hand.
Coda
The lab is at /lab/intent-geometry. Six interaction primitives on one viewport: a cursor, a threshold, a triangle, an arrow, a gauge, two sliders. Three predicates. One consequence. One piece of brass that catches when all three say yes.
The argument is small. The lab is small. The post is small. That's on purpose. A predicate of intent — like a sentence about what something means — earns its weight by being small enough to remember and exact enough to defend.