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.
:hover blinks at every gapShow the math
1 rule, no dead zones: the nearest dot wins. Flip the switch and that is the whole algorithm.
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.
axis="x"strips: tabsaxis="y"lists: menus, tables, radiosaxis="xy"grids: card groupsEverything new lands here.
Unsent, saved as you type.
Delivered and archived.
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.
What it costs
1 element, 1 transform, 1 loop per move. 200 rows below: hover and watch the meter.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| axis | "y" | "x" | "xy" | "y" | Which way the list runs: y for lists, x for strips, xy for grids. |
| isItemDisabled | (el: HTMLElement) => boolean | — | Skips an item: never lit, never clicked. Runs on every move, so keep it cheap. |
| gapClick | boolean | { maxDistance?: number } | true | A click between items goes to the lit one. false turns it off. maxDistance limits it to clicks within that many px. |
Returns
| Prop | Type | Default | Description |
|---|---|---|---|
| handlers | { onMouseEnter, onMouseMove, onMouseLeave, onClick } | — | Spread onto the container. Mouse only. onClick is the gap click. |
| registerItem | (index, element | null) => void | — | Give it to each row. Indices start at 0 and must not change while the list is on screen. |
| activeIndex | number | null | — | The lit item, or null. Also set as data-fluid-hover-active on the item. |
| setActiveIndex | (index | null) => void | — | Light an item yourself, for keyboard focus. The hook never moves focus. |
| itemRects | ItemRect[] | — | Each item's box inside the container. Survives a parent scale, not a rotation. |
| isMeasured | boolean | — | True once every item has a box. Measured again on register and on resize. |
| remeasure | () => void | — | Measure again and hide the highlight until done. Call it when a popup opens. |
| sessionRef | RefObject<number> | — | Counts pointer entries. The highlight fades in fresh on each one. |
FluidHoverHighlight
| Prop | Type | Default | Description |
|---|---|---|---|
| hover | ReturnType<typeof useFluidHover> | — | The hook. The highlight reads what it needs from it. |
| hidden | boolean | false | Show nothing, keep the state. A closed popup. |
| from | ItemRect | null | — | Where a fresh entry fades in from. Dropdowns pass the checked row. |
| className | string | — | Radius and z-index. The container must be position: relative. |
| transition | Transition | false | spring.fast | The travel. false snaps in place after a reflow. |
The highlight moves on a transform. Width and height only animate between items of different sizes.