August 12, 2026 4 min read
A hover state is a promise
Most lists light up one row at a time. Moving a single block between them costs forty lines and changes how the whole page feels.
The default way to build a hoverable list is to give every row its own background and let the browser sort it out. Row enters, row lights up, row leaves, row goes dark. It works. Nobody files a bug about it.
But it tells the reader something slightly untrue: that these rows are separate things which happen to be stacked. A list of your own work is not that. It is one object with divisions in it, and the pointer is moving through it, not between unrelated boxes.
One block, not eleven
So: build one highlight and move it. When the pointer enters a row, measure that row, and translate a single absolutely-positioned block to its position and size. The block never appears from nothing and never disappears into nothing — it fades in when you enter the list and out when you leave, and in between it just travels.
var r = item.getBoundingClientRect();
var t = track.getBoundingClientRect();
block.style.setProperty('--glide-x', Math.round(r.left - t.left) + 'px');
block.style.setProperty('--glide-y', Math.round(r.top - t.top) + 'px');
Four custom properties, one CSS transition, about forty lines with the edge cases. The edge cases are the interesting part.
The edge cases are the design
A resize invalidates every measurement you took, so keep a reference to the row currently under the pointer and re-measure it rather than leaving the block stranded at its old width. Listen on the container, not on each row, so rows added later are covered for free. And follow focusin as well as pointerover — otherwise the person tabbing through your list gets a completely different page from the person with a mouse, which is a strange thing to ship on purpose.
Then there is motion. The travel is the whole point, so prefers-reduced-motion can't just disable the effect and leave a dead list. Keep the block, drop the transition on translate, keep the opacity fade. It jumps instead of gliding. Nothing is hidden, nothing is broken, the hierarchy is intact.
A motion preference is a request to remove the movement, not the feedback.
What it costs
Forty lines of JavaScript and one element per list. No dependency, no observer, no layout thrash — getBoundingClientRect runs once per pointer entry, which is at most a few dozen times in a session.
What you get back is harder to measure and easy to feel. The list becomes a surface you are moving along, and the page stops being a document with hover states and starts being an object that responds. That is the whole promise a hover state makes. Most of them don't keep it.