useFluidHover is the geometry and pointer-tracking part of Fluid Hover. It tells you which registered item is nearest and where that item's rectangle sits inside the container. It renders nothing.
A normal list can rely on each item's CSS hover state. A highlight that travels across the whole group needs a shared view of every item's position, including the spaces between them. Keeping that work in a hook avoids rebuilding measurement, pointer tracking, and cleanup for each menu, grid, or custom list.
When to use it
Use this hook when you are building your own interactive group. Use the Foundation's existing demos and highlight when you only need the standard presentation. You do not need this hook just to color a single button on hover.
Register the actual target elements, attach the returned handlers to the positioned container, and render the highlight separately. The returned active index is transient hover state, not your selected value. Keep selection in your own state and leave keyboard navigation to semantic controls or Base UI.
The preview is deliberately made from ordinary buttons. Move between rows, click one, then Tab through the buttons: pointer feedback and actual activation remain separate.
Preview
Selected: Inbox
Installation
The starter includes hooks/use-fluid-hover.ts. Import the hook directly; no application-wide provider is required.
Usage
import { useFluidHover } from "@/hooks/use-fluid-hover";
const ref = useRef<HTMLDivElement>(null);
const hover = useFluidHover(ref, { axis: "y", gapClick: false });
// Spread hover.handlers on the container.
// Register each item with hover.registerItem(index, node).
// Render <FluidHoverHighlight hover={hover} /> separately.API Reference
| API | Type | Behavior / default |
|---|---|---|
containerRef | RefObject<HTMLElement | null> | Positioned container |
options.axis | x, y, xy | y |
options.isItemDisabled | (element) => boolean | Disabled and hidden items are skipped automatically |
options.gapClick | boolean or { maxDistance } | true |
activeIndex / setActiveIndex | number or null / setter | Current nearest item; no focus movement |
itemRects / isMeasured | Record of rects / boolean | Measured in container coordinates |
registerItem(index, node) | Callback | Register or unregister an item |
remeasure / measureItems | Functions | Scheduled or immediate measurement |
handlers | Pointer handlers and onClick | Spread onto the container |
sessionRef | RefObject<number> | New pointer entry starts a fresh highlight |
Behavior
The interactive example above is a custom list built from ordinary buttons, not a menu primitive. Use x for horizontal lists, y for menus and table rows, and xy for grids. Item containment wins; otherwise the nearest center wins. Touch input leaves native scrolling and activation alone. ResizeObserver and mutation observation keep the geometry current. Register stable indexes and use a positioned container. Keyboard focus remains the responsibility of your semantic controls or Base UI. See Fluid Hover for all three layouts.
Implementation
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { MouseEvent, PointerEvent, RefObject } from "react";
export interface ItemRect {
top: number;
left: number;
width: number;
height: number;
}
export interface UseFluidHoverOptions {
axis?: "x" | "y" | "xy";
isItemDisabled?: (element: HTMLElement) => boolean;
gapClick?: boolean | { maxDistance?: number };
}
export const useFluidHover = <T extends HTMLElement>(
containerRef: RefObject<T | null>,
{ axis = "y", isItemDisabled, gapClick = true }: UseFluidHoverOptions = {}
) => {
const items = useRef(new Map<number, HTMLElement>());
const observer = useRef<ResizeObserver | null>(null);
const frame = useRef<number | null>(null);
const point = useRef<{ x: number; y: number } | null>(null);
const sessionRef = useRef(0);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [itemRects, setItemRects] = useState<Record<number, ItemRect>>({});
const [isMeasured, setIsMeasured] = useState(false);
const disabled = useCallback(
(element: HTMLElement) =>
element.matches(":disabled,[aria-disabled=true],[data-disabled]") ||
element.getClientRects().length === 0 ||
!!isItemDisabled?.(element),
[isItemDisabled]
);
const measureItems = useCallback(() => {
const container = containerRef.current;
if (!container || !container.offsetWidth || !container.offsetHeight) {
setIsMeasured(false);
return;
}
const bounds = container.getBoundingClientRect();
const scaleX = bounds.width / container.offsetWidth || 1;
const scaleY = bounds.height / container.offsetHeight || 1;
const next: Record<number, ItemRect> = {};
let nearest: number | null = null;
let distance = Infinity;
for (const [index, element] of items.current) {
if (disabled(element)) {
continue;
}
const rect = element.getBoundingClientRect();
next[index] = {
height: rect.height / scaleY,
left:
(rect.left - bounds.left) / scaleX +
container.scrollLeft -
container.clientLeft,
top:
(rect.top - bounds.top) / scaleY +
container.scrollTop -
container.clientTop,
width: rect.width / scaleX,
};
if (!point.current) {
continue;
}
const { x, y } = point.current;
const insideX = x >= rect.left && x <= rect.right;
const insideY = y >= rect.top && y <= rect.bottom;
const contains = (axis === "y" || insideX) && (axis === "x" || insideY);
const dx = x - (rect.left + rect.width / 2);
const dy = y - (rect.top + rect.height / 2);
const distances = {
x: Math.abs(dx),
xy: Math.hypot(dx, dy),
y: Math.abs(dy),
};
const candidate = contains ? -1 : distances[axis];
if (candidate < distance) {
distance = candidate;
nearest = index;
}
}
setItemRects((previous) =>
JSON.stringify(previous) === JSON.stringify(next) ? previous : next
);
setIsMeasured(true);
if (point.current) {
setActiveIndex(nearest);
}
}, [containerRef, axis, disabled]);
const remeasure = useCallback(() => {
if (frame.current !== null) {
return;
}
frame.current = requestAnimationFrame(() => {
frame.current = null;
measureItems();
});
}, [measureItems]);
const registerItem = useCallback(
(index: number, element: HTMLElement | null) => {
const previous = items.current.get(index);
if (previous === element) {
return;
}
if (previous) {
observer.current?.unobserve(previous);
}
if (element) {
items.current.set(index, element);
observer.current?.observe(element);
} else {
items.current.delete(index);
}
remeasure();
},
[remeasure]
);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
observer.current = new ResizeObserver(remeasure);
observer.current.observe(container);
for (const element of items.current.values()) {
observer.current.observe(element);
}
const mutation = new MutationObserver(remeasure);
mutation.observe(container, {
characterData: true,
childList: true,
subtree: true,
});
container.addEventListener("scroll", remeasure, { passive: true });
remeasure();
return () => {
observer.current?.disconnect();
observer.current = null;
mutation.disconnect();
container.removeEventListener("scroll", remeasure);
if (frame.current !== null) {
cancelAnimationFrame(frame.current);
frame.current = null;
}
};
}, [containerRef, remeasure]);
useEffect(() => {
const element =
activeIndex === null ? null : items.current.get(activeIndex);
if (!element) {
return;
}
element.dataset.fluidHoverActive = "";
return () => {
delete element.dataset.fluidHoverActive;
};
}, [activeIndex]);
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== "touch") {
point.current = { x: event.clientX, y: event.clientY };
remeasure();
}
};
const onPointerLeave = () => {
point.current = null;
setActiveIndex(null);
};
const onClick = (event: MouseEvent) => {
if (
!gapClick ||
!point.current ||
event.defaultPrevented ||
!(event.target instanceof Element)
) {
return;
}
for (const element of items.current.values()) {
if (element.contains(event.target)) {
return;
}
}
if (
event.target.closest(
"button,a,input,textarea,select,[contenteditable],[role=button]"
)
) {
return;
}
const element =
activeIndex === null ? null : items.current.get(activeIndex);
if (!element || disabled(element)) {
return;
}
const rect = element.getBoundingClientRect();
const distance = Math.hypot(
Math.max(rect.left - event.clientX, 0, event.clientX - rect.right),
Math.max(rect.top - event.clientY, 0, event.clientY - rect.bottom)
);
if (
typeof gapClick === "object" &&
distance > (gapClick.maxDistance ?? Infinity)
) {
return;
}
const activator = element.matches(
"button,a,[role=tab],[role=menuitem],[role=option]"
)
? element
: element.querySelector<HTMLElement>("button,a,[role=button]");
activator?.click();
};
return {
activeIndex,
handlers: {
onClick,
onPointerCancel: onPointerLeave,
onPointerEnter: (event: PointerEvent) => {
if (event.pointerType !== "touch") {
sessionRef.current += 1;
onPointerMove(event);
}
},
onPointerLeave,
onPointerMove,
},
isMeasured,
itemRects,
measureItems,
registerItem,
remeasure,
sessionRef,
setActiveIndex,
};
};
export type UseFluidHoverReturn = ReturnType<typeof useFluidHover>;