Skip to content

Fluid hover

One highlight that glides to the item under the pointer in lists, menus, tabs and nav.

Status
Stable
Level
Utility
Category
Utilities
Adoption
Not used yet
import { useFluidHover } from "@oration/canon/hooks/use-fluid-hover";
packages/canon/src/hooks/use-fluid-hover.ts
import { FluidHoverHighlight } from "@oration/canon/components/fluid-hover";import { toast } from "@oration/canon/components/toast";import { useFluidHover } from "@oration/canon/hooks/use-fluid-hover";export function Hero() {    const hover = useFluidHover<HTMLUListElement>({ axis: "y" });    const invoices = [        {            id: "INV-20931",            supplier: "Northwind Freight",            amount: "$18,420.00",            due: "Due Oct 2",        },        {            id: "INV-20944",            supplier: "Halcyon Packaging",            amount: "$6,215.50",            due: "Due Oct 2",        },        {            id: "INV-20952",            supplier: "Orchard Street Supply",            amount: "$2,980.00",            due: "Due Oct 5",        },        {            id: "INV-20967",            supplier: "Bellweather Logistics",            amount: "$11,040.75",            due: "Due Oct 9",        },    ];    return (        <div className="w-full max-w-md rounded-xl bg-card p-1.5 shadow-border">            <ul                ref={hover.ref}                {...hover.handlers}                aria-label="Invoices in the Friday payment run"                className="relative isolate flex flex-col gap-px"            >                <FluidHoverHighlight hover={hover} />                {invoices.map((invoice) => (                    <li key={invoice.id}>                        <button                            type="button"                            data-fluid-item=""                            onClick={() =>                                toast.add({                                    title: `Opened ${invoice.id}`,                                    description: `${invoice.supplier}, ${invoice.amount}`,                                })                            }                            className="flex h-11 w-full items-center gap-3 rounded-lg px-2.5 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/40"                        >                            <span className="flex min-w-0 flex-1 flex-col">                                <span className="truncate text-13 font-medium text-foreground">                                    {invoice.supplier}                                </span>                                <span className="font-mono text-xs text-muted-foreground">                                    {invoice.id}                                </span>                            </span>                            <span className="flex flex-col items-end">                                <span className="text-13 text-foreground tabular-nums">                                    {invoice.amount}                                </span>                                <span className="text-xs text-muted-foreground">                                    {invoice.due}                                </span>                            </span>                        </button>                    </li>                ))}            </ul>        </div>    );}

Usage#

Fluid hover draws one highlight per list that glides to the item under the pointer, instead of every row blinking its own hover fill on and off. useFluidHover measures the items and tracks the nearest one; FluidHoverHighlight paints it. It runs the settings nav, ticket and queue lists, copilot history, menus and tab strips. Most bugs come from skipping the container contract: the container is relative isolate, takes ref and the spread handlers, marks items with data-fluid-item, and renders the highlight as its first child.

When to use

  • Vertical lists and navigation (axis: "y"): settings nav, record lists, inbox and queue rows, copilot history.
  • Horizontal strips (axis: "x"): view tabs, filter pills, a row of toolbar toggles.
  • Small grids of tiles (axis: "xy"): an integrations picker, a quick-actions grid, template cards.
  • Lists that already mark a current item, with isItemQuiet so the hover yields to the stronger fill.
  • Anywhere the gaps between items should still be clickable: a click in the gap goes to the nearest item.

When not to use

  • Reorderable lists. Item rects change mid-drag, so the highlight chases rows that are moving. Use the sortable list and its own drag affordance. Use Sortable list
  • Menus that are mostly destructive or irreversible actions. A glide makes sweeping across rows feel light; keep the plain menu highlight and confirm the action. Use Confirm dialog
  • Dense data grids and tables. Rows fill Row Mist on hover one at a time, and a highlight gliding across 36px rows reads as noise. Use Data grid
  • A single control. Buttons and icon actions have their own hover fill. Use Button
  • Dropdown, context and command menus. They already highlight the active item with Base UI's keyboard-aware state. Use Dropdown menu

The Quiet Indigo Rule

The highlight is a neutral fill (bg-accent by default, Well Gray tints in practice). Indigo is spent on selection and focus, so a hover is never indigo.

Motion grammar

The glide uses spring.fast (80ms, no bounce) and the fade-out uses exit.fast (60ms), so leaving is quicker than arriving. Under reduced motion the highlight jumps and only fades.

Anatomy#

Northwind FreightHalcyon PackagingOrchard Street Supply
  1. Container. relative isolate, takes ref and {...handlers}. The hook marks it data-fluid-container so nested lists keep their own items.
  2. Highlight. FluidHoverHighlight, rendered as the container's first child: absolute, -z-10 behind the items, rounded-lg bg-accent, aria-hidden.
  3. Item. Any element marked data-fluid-item, usually a button or a link. Disabled items are skipped.
  4. Lit item. The item under the pointer gets data-fluid-active while lit, so its text can darken with data-fluid-active:text-foreground.

Examples#

Vertical list with a current item

axis: "y" for lists and nav. The current item is marked data-active and isItemQuiet makes the hover yield to it, the way the settings nav works.

import { FluidHoverHighlight } from "@oration/canon/components/fluid-hover";import { useFluidHover } from "@oration/canon/hooks/use-fluid-hover";import { cn } from "@oration/canon/lib/utils";import {  Building2Icon,  CalendarClockIcon,  FileTextIcon,  ReceiptTextIcon,  SendIcon,} from "lucide-react";import * as React from "react";export function VerticalNav() {    const [current, setCurrent] = React.useState("Payment runs");    const hover = useFluidHover<HTMLUListElement>({        axis: "y",        isItemQuiet: (el) => el.hasAttribute("data-active"),    });    const items = [        { label: "Suppliers", icon: Building2Icon },        { label: "Invoices", icon: FileTextIcon },        { label: "Payment runs", icon: CalendarClockIcon },        { label: "Remittances", icon: SendIcon },        { label: "Tax forms", icon: ReceiptTextIcon },    ];    return (        <nav aria-label="Payables" className="w-56">            <ul                ref={hover.ref}                {...hover.handlers}                className="relative isolate flex flex-col gap-px"            >                <FluidHoverHighlight                    hover={hover}                    className="rounded-md bg-accent/60"                />                {items.map((item) => {                    const active = item.label === current;                    return (                        <li key={item.label}>                            <button                                type="button"                                data-fluid-item=""                                data-active={active ? "" : undefined}                                aria-current={active ? "page" : undefined}                                onClick={() => setCurrent(item.label)}                                className={cn(                                    "flex h-7 w-full items-center gap-2.5 rounded-md px-2 text-13 outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40",                                    active                                        ? "bg-accent font-medium text-foreground"                                        : "text-muted-foreground hover:text-foreground",                                )}                            >                                <item.icon                                    aria-hidden="true"                                    className={cn(                                        "size-4 shrink-0",                                        active                                            ? "text-foreground"                                            : "text-subtle-foreground",                                    )}                                />                                {item.label}                            </button>                        </li>                    );                })}            </ul>        </nav>    );}

Horizontal strip

axis: "x" for view pills, tabs and toolbar toggles. Distance is measured sideways only, so the pointer above or below the row still lights the nearest pill.

Invoice views
import { FluidHoverHighlight } from "@oration/canon/components/fluid-hover";import { useFluidHover } from "@oration/canon/hooks/use-fluid-hover";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function HorizontalStrip() {    const views = [        "All invoices",        "Awaiting approval",        "Scheduled",        "Paid",        "Exceptions",    ];    const [view, setView] = React.useState("Awaiting approval");    const hover = useFluidHover<HTMLFieldSetElement>({        axis: "x",        isItemQuiet: (el) => el.hasAttribute("data-active"),    });    return (        <fieldset            ref={hover.ref}            {...hover.handlers}            className="relative isolate flex min-w-0 flex-wrap items-center gap-0.5"        >            <FluidHoverHighlight                hover={hover}                className="rounded-md bg-muted/60"            />            <legend className="sr-only">Invoice views</legend>            {views.map((label) => {                const active = label === view;                return (                    <button                        key={label}                        type="button"                        data-fluid-item=""                        data-active={active ? "" : undefined}                        aria-pressed={active}                        onClick={() => setView(label)}                        className={cn(                            "h-7 rounded-md px-2.5 text-13 outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40",                            active                                ? "bg-muted font-medium text-foreground"                                : "text-muted-foreground hover:text-foreground",                        )}                    >                        {label}                    </button>                );            })}        </fieldset>    );}

Tile grid

axis: "xy" for small grids. The disabled Sage Intacct tile is skipped because the default isItemDisabled reads its disabled attribute.

Accounting integrations
import { FluidHoverHighlight } from "@oration/canon/components/fluid-hover";import { toast } from "@oration/canon/components/toast";import { useFluidHover } from "@oration/canon/hooks/use-fluid-hover";import * as React from "react";export function Grid() {    const [connected, setConnected] = React.useState<string[]>(["NetSuite"]);    const hover = useFluidHover<HTMLFieldSetElement>({ axis: "xy" });    const apps = [        { name: "NetSuite", description: "ERP and general ledger" },        { name: "QuickBooks Online", description: "Small business ledger" },        {            name: "Sage Intacct",            description: "Needs the Scale plan",            disabled: true,        },        { name: "Xero", description: "Cloud accounting" },        { name: "Ramp", description: "Cards and bill pay" },        { name: "Brex", description: "Cards and expenses" },    ];    return (        <fieldset            ref={hover.ref}            {...hover.handlers}            className="relative isolate grid w-full min-w-0 max-w-lg grid-cols-2 gap-1 sm:grid-cols-3"        >            <FluidHoverHighlight hover={hover} className="rounded-[10px]" />            <legend className="sr-only">Accounting integrations</legend>            {apps.map((app) => {                const on = connected.includes(app.name);                return (                    <button                        key={app.name}                        type="button"                        data-fluid-item=""                        disabled={app.disabled}                        aria-pressed={on}                        onClick={() => {                            setConnected((list) =>                                on                                    ? list.filter((name) => name !== app.name)                                    : [...list, app.name],                            );                            toast.add({                                type: on ? "info" : "success",                                title: on                                    ? `${app.name} disconnected`                                    : `${app.name} connected`,                            });                        }}                        className="flex flex-col items-start gap-0.5 rounded-[10px] p-3 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/40 disabled:opacity-50"                    >                        <span className="text-13 font-medium text-foreground">                            {app.name}                        </span>                        <span className="text-xs text-muted-foreground">                            {on ? "Connected" : app.description}                        </span>                    </button>                );            })}        </fieldset>    );}

A slower glide

Pass transition to change the glide tier. spring.moderate suits taller rows with more travel; the fade still uses the fast tier.

import { FluidHoverHighlight } from "@oration/canon/components/fluid-hover";import { toast } from "@oration/canon/components/toast";import { useFluidHover } from "@oration/canon/hooks/use-fluid-hover";import { spring } from "@oration/canon/lib/springs";export function SlowerGlide() {    const hover = useFluidHover<HTMLUListElement>({ axis: "y" });    const people = [        { name: "Maya Okafor", role: "VP of Revenue" },        { name: "Priya Raman", role: "AP manager" },        { name: "Tomás Ferreira", role: "Controller" },        { name: "Jordan Lee", role: "AP specialist" },    ];    return (        <ul            ref={hover.ref}            {...hover.handlers}            aria-label="Approvers"            className="relative isolate flex w-full max-w-xs flex-col gap-px"        >            <FluidHoverHighlight hover={hover} transition={spring.moderate} />            {people.map((person) => (                <li key={person.name}>                    <button                        type="button"                        data-fluid-item=""                        onClick={() =>                            toast.add({                                title: `${person.name} added as approver`,                                description:                                    "They approve invoices over $10,000.",                            })                        }                        className="flex h-10 w-full items-center justify-between gap-3 rounded-lg px-2.5 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/40"                    >                        <span className="text-13 font-medium text-foreground">                            {person.name}                        </span>                        <span className="text-xs text-muted-foreground">                            {person.role}                        </span>                    </button>                </li>            ))}        </ul>    );}

States#

States
StateTreatment
RestNothing is lit. The highlight stays mounted at the last position with opacity 0.
EnteringEach pointer entry starts a new session: the highlight appears at the nearest item without travelling from where the last visit ended, fading in on spring.fast.
GlidingMoving between items animates position and size on spring.fast, or on the transition you pass.
Focus visibleKeyboard focus lights the focused item. Pointer focus doesn't, so a click doesn't leave a stray highlight.
YieldingWhen isItemQuiet returns true for the item (for example the current page), the highlight fades out instead of painting over its fill.
Disabled itemSkipped when finding the nearest item. By default an item is disabled with disabled, data-disabled or aria-disabled="true".
LeavingPointer leave fades the highlight out on exit.fast (60ms) in place.
Hiddenhidden fades it out while something else owns the list, such as an open row menu.
Reduced motionNo travel: the highlight jumps between items and fades.

Behavior#

  • Items are found by itemSelector ([data-fluid-item]), so rows need no index bookkeeping. Only items whose closest [data-fluid-container] is this container count, so nested lists don't steal each other's rows.
  • Rects are measured on pointer entry, on resize (ResizeObserver) and on DOM changes inside the container (MutationObserver), and include the container's scroll offset, so a scrolling container works.
  • The nearest item is found by axis: y uses vertical distance only, so the pointer in the side gutter still lights the row; x uses horizontal distance; xy uses straight-line distance.
  • gapClick (on by default) sends a click in the gap between items to the nearest item. Pass { maxDistance } to stop clicks far from any item from counting.
  • Touch pointers are ignored. A tap activates the item and never leaves a highlight behind.
  • isItemQuiet is re-checked when any data-active attribute in the container changes, so mark the current item with data-active and test for it.
  • setActiveIndex(index) lights an item from code (for example the row under a keyboard cursor) and remeasure() refreshes rects after a layout change the observers can't see.

Do and don't#

SuppliersInvoicesPayment runs
Do. Keep the highlight neutral. It says where the pointer is, nothing more.
SuppliersInvoicesPayment runs
Don't. Tint the highlight indigo. It reads as a selection and competes with the real one.
Do. Mark the current item data-active and pass isItemQuiet so the hover yields to it.
Don't. Let the hover paint over the current item, so two fills fight on the same row.
Do. Use it for calm, scannable lists: navigation, records, views and tiles.
Don't. Put it on a menu of Delete, Void and Remove actions, where sweeping across rows should feel deliberate.

Content#

  • The highlight carries no meaning on its own. Selection needs its own fill and aria-current or aria-selected.
  • Keep each item to one line of primary text with optional meta below, so items are similar heights and the glide stays smooth.
  • Don't add a second hover fill to the items (hover:bg-muted). Change text color on hover or data-fluid-active instead.

Accessibility#

  • The highlight is aria-hidden and pointer-events-none; it never takes focus or clicks.
  • Items must be real buttons or links so they are focusable and activate with Enter or Space.
  • Keep each item's own focus-visible ring. The highlight follows keyboard focus but isn't a focus indicator by itself.
  • Express the current item with aria-current (navigation) or aria-selected (tabs and listboxes), not with the highlight.
  • Reduced motion is handled: the highlight jumps instead of travelling.
Keyboard interactions
KeysAction
TabMoves focus to the next item. The highlight follows focus-visible.
EnterActivates the focused item.
SpaceActivates the focused item when it's a button.

Design tokens#

Design tokens
TokenUsed for
--accentDefault highlight fill (bg-accent)
--radius-lgDefault highlight corners (rounded-lg)
spring.fastGlide and fade-in, 80ms with no bounce
exit.fastFade-out, a 60ms tween on the house ease-out

API reference#

useFluidHover

useFluidHover<T extends HTMLElement = HTMLElement>(options?: FluidHoverOptions), from @oration/canon/hooks/use-fluid-hover. Also exports the types FluidAxis, ItemRect, FluidHoverOptions and FluidHover.

Props of useFluidHover
PropTypeDefaultDescription
axis"x" | "y" | "xy""y"How distance to an item is measured: y for lists and menus, x for strips and tabs, xy for grids.
itemSelectorstring"[data-fluid-item]"How items are found inside the container.
isItemDisabled(el: HTMLElement) => booleandisabled, data-disabled or aria-disabledItems that return true are never lit.
isItemQuiet(el: HTMLElement) => booleanNo defaultItems that already carry a stronger highlight. The hover yields on them; gap clicks still reach them.
gapClickboolean | { maxDistance?: number }trueA click in the gap between items goes to the nearest item, optionally only within maxDistance px.

Returns

What useFluidHover returns.

Props of Returns
PropTypeDefaultDescription
ref(node: T | null) => (() => void) | undefinedNo defaultCallback ref for the container. Sets up the observers and cleans them up.
handlers{ onPointerEnter, onPointerMove, onPointerLeave, onFocus, onBlur, onClick }No defaultSpread on the container.
activeIndexnumber | nullNo defaultIndex of the lit item, or null.
activeRectItemRect | nullNo default{ top, left, width, height } of the lit item, relative to the container.
sessionnumberNo defaultIncrements on each pointer entry, so the highlight knows to appear in place rather than travel.
remeasure() => voidNo defaultRe-reads item rects.
setActiveIndex(index: number | null) => voidNo defaultMeasures, then lights the item at index (or clears).

FluidHoverHighlight

The gliding fill, from @oration/canon/components/fluid-hover. Render it as the container's first child.

Other props spread onto Nothing; only these props.

Props of FluidHoverHighlight
PropTypeDefaultDescription
hoverRequiredPick<FluidHover, "activeRect" | "session">No defaultThe hook's return value.
hiddenbooleanfalseFades the highlight out while something else owns the list.
classNamestringNo defaultMerged after rounded-lg bg-accent, to change the fill or corners.
transitionTransitionspring.fastMotion transition for the glide. Opacity always uses the fast tier.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Product call sites restyle the highlight at least six ways (rounded-md bg-accent, rounded-lg bg-muted, rounded-lg bg-muted/70, bg-muted/70, rounded-md, and the settings nav's rounded-md bg-accent/60), so the default rounded-lg bg-accent isn't the de facto look. Match the corners of the items and pick one fill per surface.

isItemQuiet is only re-checked when a data-active attribute changes. Quiet logic that reads another attribute, such as aria-selected, updates on the next pointer move instead.

setActiveIndex is a new function on every render, so it can't sit in an effect's dependency list without re-running the effect each render.