Fluid Hover

Hover that never blinks and always follows your cursor to the nearest item.

Installation

Installs the useFluidHover hook, the FluidHoverHighlight component, and the springs tokens, and adds framer-motion as a dependency. Every list, menu, strip, and grid in the library hovers with it.

Blink or glide

Watch both cursors. The left blinks off and on 9 times per pass, and each blink pulls your eye back to the list. The right glides once, and your eye stays on the task.

Inspect
Plain :hover blinks at every gap
Fluid hover glides, one pass, one target

Show the math

1 rule, no dead zones: the nearest dot wins. Flip the switch and that is the whole algorithm.

Inspect
Show the math

3 axes

Menus, tabs, and card grids all feel the same under the cursor. Pass y, x, or xy and the highlight follows you down the list, across the strip, or to the closest card.

Inspect
axis="x"strips: tabs
axis="y"lists: menus, tables, radios
axis="xy"grids: card groups
Inbox

Everything new lands here.

Drafts

Unsent, saved as you type.

Sent

Delivered and archived.

Trash

Emptied after 30 days.

When to split a list

Split at the divider. Rows that are alternatives to each other share 1 list, children included, and the highlight never crosses into the next one.

Workspace
Account

What it costs

1 element, 1 transform, 1 loop per move. 200 rows below: hover and watch the meter.

Inspect
0movescounts as you hover
0.000 mspick per move0.00% of a 16.7 ms frame
0.0 mslongest frameeverything included, budget 16.7 ms
0layout writesa top/left animation writes 1 every frame

Use when

  • Everything in the list can be clicked: a menu, a list, tabs, a grid of links.
  • The items sit close together.
  • The items stay where they are while you look at them.

Skip when

  • A wrong click would hurt.
  • Only some of the cards can be clicked.
  • There is a lot of empty space around the items.
  • Rows change place as you scroll.

Reduced motion

The highlight respects the OS setting on its own. Turn on reduced motion and the travel drops out: the highlight still fades in on the nearest row, it just stops sliding between rows. No <MotionConfig> needed, so a copied component behaves the same in your app.

Reference

useFluidHover(containerRef, options) picks, the highlight draws. Focus, roles, and focus rings stay with your rows.

Options

PropTypeDefaultDescription
axis"y" | "x" | "xy""y"Which way the list runs: y for lists, x for strips, xy for grids.
isItemDisabled(el: HTMLElement) => booleanSkips an item: never lit, never clicked. Runs on every move, so keep it cheap.
gapClickboolean | { maxDistance?: number }trueA click between items goes to the lit one. false turns it off. maxDistance limits it to clicks within that many px.

Returns

PropTypeDefaultDescription
handlers{ onMouseEnter, onMouseMove, onMouseLeave, onClick }Spread onto the container. Mouse only. onClick is the gap click.
registerItem(index, element | null) => voidGive it to each row. Indices start at 0 and must not change while the list is on screen.
activeIndexnumber | nullThe lit item, or null. Also set as data-fluid-hover-active on the item.
setActiveIndex(index | null) => voidLight an item yourself, for keyboard focus. The hook never moves focus.
itemRectsItemRect[]Each item's box inside the container. Survives a parent scale, not a rotation.
isMeasuredbooleanTrue once every item has a box. Measured again on register and on resize.
remeasure() => voidMeasure again and hide the highlight until done. Call it when a popup opens.
sessionRefRefObject<number>Counts pointer entries. The highlight fades in fresh on each one.

FluidHoverHighlight

PropTypeDefaultDescription
hoverReturnType<typeof useFluidHover>The hook. The highlight reads what it needs from it.
hiddenbooleanfalseShow nothing, keep the state. A closed popup.
fromItemRect | nullWhere a fresh entry fades in from. Dropdowns pass the checked row.
classNamestringRadius and z-index. The container must be position: relative.
transitionTransition | falsespring.fastThe travel. false snaps in place after a reflow.

The highlight moves on a transform. Width and height only animate between items of different sizes.