Fluid hover
One highlight that glides to the item under the pointer in lists, menus, tabs and nav.
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
isItemQuietso 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
bg-accent by default, Well Gray tints in practice). Indigo is spent on selection and focus, so a hover is never indigo.Motion grammar
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#
- Container.
relative isolate, takesrefand{...handlers}. The hook marks itdata-fluid-containerso nested lists keep their own items. - Highlight.
FluidHoverHighlight, rendered as the container's first child: absolute,-z-10behind the items,rounded-lg bg-accent,aria-hidden. - Item. Any element marked
data-fluid-item, usually a button or a link. Disabled items are skipped. - Lit item. The item under the pointer gets
data-fluid-activewhile lit, so its text can darken withdata-fluid-active:text-foreground.
Examples#
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.
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.
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#
| State | Treatment |
|---|---|
| Rest | Nothing is lit. The highlight stays mounted at the last position with opacity 0. |
| Entering | Each 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. |
| Gliding | Moving between items animates position and size on spring.fast, or on the transition you pass. |
| Focus visible | Keyboard focus lights the focused item. Pointer focus doesn't, so a click doesn't leave a stray highlight. |
| Yielding | When isItemQuiet returns true for the item (for example the current page), the highlight fades out instead of painting over its fill. |
| Disabled item | Skipped when finding the nearest item. By default an item is disabled with disabled, data-disabled or aria-disabled="true". |
| Leaving | Pointer leave fades the highlight out on exit.fast (60ms) in place. |
| Hidden | hidden fades it out while something else owns the list, such as an open row menu. |
| Reduced motion | No 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:
yuses vertical distance only, so the pointer in the side gutter still lights the row;xuses horizontal distance;xyuses 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.
isItemQuietis re-checked when anydata-activeattribute in the container changes, so mark the current item withdata-activeand test for it.setActiveIndex(index)lights an item from code (for example the row under a keyboard cursor) andremeasure()refreshes rects after a layout change the observers can't see.
Do and don't#
data-active and pass isItemQuiet so the hover yields to it.Content#
- The highlight carries no meaning on its own. Selection needs its own fill and
aria-currentoraria-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 ordata-fluid-activeinstead.
Accessibility#
- The highlight is
aria-hiddenandpointer-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-visiblering. The highlight follows keyboard focus but isn't a focus indicator by itself. - Express the current item with
aria-current(navigation) oraria-selected(tabs and listboxes), not with the highlight. - Reduced motion is handled: the highlight jumps instead of travelling.
| Keys | Action |
|---|---|
| Tab | Moves focus to the next item. The highlight follows focus-visible. |
| Enter | Activates the focused item. |
| Space | Activates the focused item when it's a button. |
Design tokens#
| Token | Used for |
|---|---|
--accent | Default highlight fill (bg-accent) |
--radius-lg | Default highlight corners (rounded-lg) |
spring.fast | Glide and fade-in, 80ms with no bounce |
exit.fast | Fade-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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
itemSelector | string | "[data-fluid-item]" | How items are found inside the container. |
isItemDisabled | (el: HTMLElement) => boolean | disabled, data-disabled or aria-disabled | Items that return true are never lit. |
isItemQuiet | (el: HTMLElement) => boolean | No default | Items that already carry a stronger highlight. The hover yields on them; gap clicks still reach them. |
gapClick | boolean | { maxDistance?: number } | true | A click in the gap between items goes to the nearest item, optionally only within maxDistance px. |
Returns
What useFluidHover returns.
| Prop | Type | Default | Description |
|---|---|---|---|
ref | (node: T | null) => (() => void) | undefined | No default | Callback ref for the container. Sets up the observers and cleans them up. |
handlers | { onPointerEnter, onPointerMove, onPointerLeave, onFocus, onBlur, onClick } | No default | Spread on the container. |
activeIndex | number | null | No default | Index of the lit item, or null. |
activeRect | ItemRect | null | No default | { top, left, width, height } of the lit item, relative to the container. |
session | number | No default | Increments on each pointer entry, so the highlight knows to appear in place rather than travel. |
remeasure | () => void | No default | Re-reads item rects. |
setActiveIndex | (index: number | null) => void | No default | Measures, 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.
| Prop | Type | Default | Description |
|---|---|---|---|
hoverRequired | Pick<FluidHover, "activeRect" | "session"> | No default | The hook's return value. |
hidden | boolean | false | Fades the highlight out while something else owns the list. |
className | string | No default | Merged after rounded-lg bg-accent, to change the fill or corners. |
transition | Transition | spring.fast | Motion 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.