For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Sponsor

useFluidHover

Headless proximity tracking for custom lists and grids.

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

APITypeBehavior / default
containerRefRefObject<HTMLElement | null>Positioned container
options.axisx, y, xyy
options.isItemDisabled(element) => booleanDisabled and hidden items are skipped automatically
options.gapClickboolean or { maxDistance }true
activeIndex / setActiveIndexnumber or null / setterCurrent nearest item; no focus movement
itemRects / isMeasuredRecord of rects / booleanMeasured in container coordinates
registerItem(index, node)CallbackRegister or unregister an item
remeasure / measureItemsFunctionsScheduled or immediate measurement
handlersPointer handlers and onClickSpread onto the container
sessionRefRefObject<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>;