Spectrum UI
componentexpandable-action-barsource: ok · hit
Expandable Action Bar
Compact icon actions that expand into labeled controls on hover or focus with shared layout motion.
npx shadcn add "https://ui.spectrumhq.in/r/expandable-action-bar.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files5 · source cached 10/15/2026, 7:52:19 AM
components/motion/expandable-action-bar.tsx
"use client";
// beui.dev/components/blocks/expandable-action-bar
import { LayoutGroup, motion, type Transition, useReducedMotion } from "motion/react";
import {
type FocusEvent,
type MouseEvent,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { cn } from "@/lib/utils";
export type ExpandableActionBarSize = "sm" | "md";
export type ExpandableActionBarItem = {
id: string;
label: ReactNode;
icon: ReactNode;
onClick?: () => void;
disabled?: boolean;
active?: boolean;
badge?: ReactNode;
shortcut?: ReactNode;
};
export type ExpandableActionBarClassNames = {
root?: string;
track?: string;
item?: string;
activeItem?: string;
icon?: string;
label?: string;
badge?: string;
shortcut?: string;
};
export interface ExpandableActionBarProps {
items: ExpandableActionBarItem[];
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
activeId?: string;
onAction?: (item: ExpandableActionBarItem) => void;
size?: ExpandableActionBarSize;
/**
* Expand when a pointer that hovers rests on the bar. Default true. It also
* governs the touch equivalent: with no hover to reveal the labels, the
* first tap expands the bar and runs no action, and the second one acts.
* Set false to make every tap and click act immediately.
*/
expandOnHover?: boolean;
expandOnFocus?: boolean;
collapseDelay?: number;
className?: string;
classNames?: ExpandableActionBarClassNames;
renderItem?: (item: ExpandableActionBarItem, state: { expanded: boolean; active: boolean }) => ReactNode;
}
const ITEM_TRANSITION: Transition = {
type: "spring",
stiffness: 460,
damping: 34,
mass: 0.62,
};
const LABEL_TRANSITION: Transition = {
type: "spring",
stiffness: 380,
damping: 32,
mass: 0.7,
};
const SIZE_CLASS: Record<ExpandableActionBarSize, string> = {
sm: "min-h-9 gap-1 p-1 text-xs",
md: "min-h-11 gap-1.5 p-1.5 text-sm",
};
const ITEM_SIZE_CLASS: Record<ExpandableActionBarSize, string> = {
sm: "h-7 min-w-7 px-1.5",
md: "h-8 min-w-8 px-2",
};
const ICON_SIZE_CLASS: Record<ExpandableActionBarSize, string> = {
sm: "h-3.5 w-3.5",
md: "h-4 w-4",
};
function useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
}: {
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
}) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded ?? false);
const isControlled = expanded !== undefined;
const value = expanded ?? internalExpanded;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalExpanded(next);
onExpandedChange?.(next);
},
[isControlled, onExpandedChange],
);
return [value, setValue] as const;
}
export function ExpandableActionBar({
items,
expanded,
defaultExpanded = false,
onExpandedChange,
activeId,
onAction,
size = "md",
expandOnHover = true,
expandOnFocus = true,
collapseDelay = 90,
className,
classNames,
renderItem,
}: ExpandableActionBarProps) {
const reduce = useReducedMotion();
const layoutId = useId();
const [isExpanded, setIsExpanded] = useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
});
const [hoveredId, setHoveredId] = useState<string | null>(null);
// Set by the tap that expands the bar, and the reason the outside-tap
// dismisser exists at all — a hovering pointer has its own way out.
const [tapExpanded, setTapExpanded] = useState(false);
const collapseTimer = useRef<number | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
// What the last gesture on an action was, and whether the bar was already
// expanded when it started. A click reports neither.
const tap = useTapGesture<boolean>();
const hover = useHoverGesture();
const clearCollapseTimer = useCallback(() => {
if (collapseTimer.current) window.clearTimeout(collapseTimer.current);
collapseTimer.current = null;
}, []);
const open = useCallback(() => {
clearCollapseTimer();
setIsExpanded(true);
}, [clearCollapseTimer, setIsExpanded]);
const close = useCallback(() => {
clearCollapseTimer();
const timer = window.setTimeout(() => {
setIsExpanded(false);
setHoveredId(null);
setTapExpanded(false);
}, collapseDelay);
collapseTimer.current = timer;
}, [clearCollapseTimer, collapseDelay, setIsExpanded]);
useEffect(() => clearCollapseTimer, [clearCollapseTimer]);
// A collapse from outside takes the labels with it, so the arm the tap that
// expanded the bar left behind has to go too — otherwise the next tap runs
// an action whose label nobody can read. Only on the way down from expanded:
// a controlled bar that declined to expand at all keeps its arm, which is
// what lets its second tap act.
const wasExpanded = useRef(isExpanded);
useEffect(() => {
if (wasExpanded.current && !isExpanded) setTapExpanded(false);
wasExpanded.current = isExpanded;
}, [isExpanded]);
// A finger never hovers and Safari does not focus a button on tap, so a bar a
// tap expanded would have nothing to close it. The tap that lands elsewhere
// stands in for the pointer leaving — and it is consumed rather than passed
// through, because the labelled bar is exactly the kind of surface people
// dismiss by tapping just past it, over whatever control is there.
useDismiss(tapExpanded && isExpanded, close, trackRef, {
behavior: "consume",
});
const onRootPointerEnter = (event: PointerEvent<HTMLDivElement>) => {
// The gesture is told about every enter, `expandOnHover` or not: it is
// what the matching leave is read against.
if (hover.enter(event) && expandOnHover) open();
};
const onRootPointerLeave = (event: PointerEvent<HTMLDivElement>) => {
if (!hover.leave(event)) return;
setHoveredId(null);
if (expandOnHover) close();
};
const onRootFocus = () => {
if (expandOnFocus) open();
};
const onRootBlur = (event: FocusEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node) && expandOnFocus) {
close();
}
};
const activeItemId = activeId ?? items.find((item) => item.active)?.id;
const highlightId = hoveredId ?? activeItemId;
return (
<LayoutGroup id={layoutId}>
<motion.div
layout="size"
// Pointer events, not the mouse pair: a tap fires compatibility
// mouseenter/mouseleave that carry no pointerType, and the bar growing
// under a stationary finger fired the leave before the click ever
// landed — so one tap expanded, collapsed and ran nothing.
onPointerEnter={onRootPointerEnter}
onPointerLeave={onRootPointerLeave}
onFocus={onRootFocus}
onBlur={onRootBlur}
transition={ITEM_TRANSITION}
className={cn("inline-flex max-w-full", classNames?.root, className)}
>
<motion.div
ref={trackRef}
layout="size"
className={cn(
// Labelled actions can outgrow the space the bar sits in — the pill
// stays inside it and scrolls its rail rather than running off the
// edge, where the last action is unreachable.
"scrollbar-hide relative inline-flex max-w-full items-center overflow-x-auto overflow-y-hidden rounded-full border border-border bg-card/90 shadow-2xl backdrop-blur-xl",
SIZE_CLASS[size],
classNames?.track,
)}
transition={ITEM_TRANSITION}
>
{items.map((item) => {
const isActive = item.active || activeId === item.id;
const isHighlighted = highlightId === item.id;
return (
<motion.button
key={item.id}
layout="position"
type="button"
disabled={item.disabled}
title={typeof item.label === "string" ? item.label : undefined}
onPointerEnter={(event: PointerEvent<HTMLButtonElement>) => {
if (!hover.enter(event)) return;
clearCollapseTimer();
setHoveredId(item.id);
}}
onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {
tap.start(event, isExpanded);
}}
// A gesture the platform takes away sends no click, and a key
// press starts an activation that never had a pointer behind
// it: either one would otherwise leave the finger in place for
// the next click to spend.
onPointerCancel={tap.drop}
onKeyDown={tap.drop}
onClick={(event: MouseEvent<HTMLButtonElement>) => {
event.currentTarget.blur();
const gesture = tap.take();
// Nothing reveals the labels to a finger, so the first tap
// expands the bar and the next one runs the action. The bar
// state is read from the gesture's start: a browser that
// focuses the button on contact expands it mid-tap, and that
// first tap would otherwise fire the action it was meant to
// reveal. `tapExpanded` arms the second tap, so a controlled
// bar that declines to expand still runs the action rather
// than swallowing every tap.
const firstTap =
gesture !== null &&
gesture.pointerType !== "mouse" &&
!gesture.state &&
!tapExpanded;
if (firstTap && expandOnHover) {
setTapExpanded(true);
open();
setHoveredId(item.id);
return;
}
item.onClick?.();
onAction?.(item);
}}
whileTap={reduce || item.disabled ? undefined : { scale: 0.96 }}
transition={ITEM_TRANSITION}
className={cn(
"relative isolate inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-medium text-muted-foreground outline-none transition-[color,background-color] duration-150 ease-out",
"focus-visible:text-foreground disabled:pointer-events-none disabled:opacity-40",
isHighlighted && "text-foreground",
ITEM_SIZE_CLASS[size],
classNames?.item,
isActive && classNames?.activeItem,
)}
>
{isHighlighted ? (
<motion.span
layoutId="action-bar-highlight"
className="absolute inset-0 -z-10 rounded-full bg-primary/[0.07]"
transition={ITEM_TRANSITION}
/>
) : null}
{renderItem ? (
renderItem(item, { expanded: isExpanded, active: isActive })
) : (
<>
<span
className={cn(
"inline-flex shrink-0 items-center justify-center",
ICON_SIZE_CLASS[size],
classNames?.icon,
)}
>
{item.icon}
</span>
<motion.span
aria-hidden={!isExpanded}
animate={
reduce
? {
width: isExpanded ? "auto" : 0,
opacity: isExpanded ? 1 : 0,
marginLeft: isExpanded ? 8 : 0,
x: 0,
filter: "blur(0px)",
}
: {
width: isExpanded ? "auto" : 0,
opacity: isExpanded ? 1 : 0,
x: isExpanded ? 0 : -4,
marginLeft: isExpanded ? 8 : 0,
filter: isExpanded ? "blur(0px)" : "blur(3px)",
}
}
transition={reduce ? { duration: 0 } : LABEL_TRANSITION}
className={cn(
"inline-block overflow-hidden whitespace-nowrap",
classNames?.label,
)}
>
{item.label}
</motion.span>
{item.shortcut ? (
<motion.span
aria-hidden={!isExpanded}
animate={{
width: isExpanded ? "auto" : 0,
opacity: isExpanded ? 1 : 0,
marginLeft: isExpanded ? 4 : 0,
}}
transition={LABEL_TRANSITION}
className={cn(
"hidden overflow-hidden whitespace-nowrap text-[10px] text-muted-foreground sm:inline-block",
classNames?.shortcut,
)}
>
{item.shortcut}
</motion.span>
) : null}
{item.badge ? (
<span
className={cn(
"ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] leading-none text-primary-foreground",
!isExpanded && "absolute right-0.5 top-0.5",
classNames?.badge,
)}
>
{item.badge}
</span>
) : null}
</>
)}
</motion.button>
);
})}
</motion.div>
</motion.div>
</LayoutGroup>
);
}
export function useExpandableActionBar(items: ExpandableActionBarItem[]) {
const [expanded, setExpanded] = useState(false);
const [activeId, setActiveId] = useState(items[0]?.id);
const activeItem = useMemo(
() => items.find((item) => item.id === activeId),
[activeId, items],
);
return useMemo(
() => ({ expanded, setExpanded, activeId, setActiveId, activeItem }),
[activeId, activeItem, expanded],
);
}
lib/hooks/use-dismiss.ts
"use client";
import { type RefObject, useEffect } from "react";
/**
* What the dismissing gesture does to the control it landed on.
*
* `"pass-through"` is the platform norm (native popover light-dismiss): the
* tap closes the overlay *and* activates whatever was under it. Use
* `"consume"` where the open overlay sits over or beside controls that would
* be costly to trigger by accident — the dismissal then swallows the
* activation too, so the gesture only closes.
*/
export type DismissBehavior = "pass-through" | "consume";
export interface DismissOptions {
/** Default `"pass-through"`. */
behavior?: DismissBehavior;
/** Dismiss on Escape as well. Default true. */
escape?: boolean;
/** Return true for an outside target that should *not* dismiss. Must be stable. */
ignore?: (target: Element) => boolean;
}
/**
* What every currently open dismiss scope counts as inside itself. A consumed
* dismissal reads this to tell a stray gesture from one that belongs to an
* overlay in front of it: overlays have no shared z-order to consult, but the
* one the gesture landed in has said as much by registering it.
*/
const openScopes = new Set<(target: Element) => boolean>();
function claimedByAnotherScope(
self: (target: Element) => boolean,
target: Element,
) {
for (const scope of openScopes) {
if (scope !== self && scope(target)) return true;
}
return false;
}
// preventDefault on pointerdown does not suppress the click that follows, so
// consuming a gesture means swallowing that click itself. The swallower
// deliberately outlives the effect that installed it — the dismissal it
// belongs to has already unmounted or re-rendered by the time the click lands.
// It releases on that click, or on the next gesture if the pointer is dragged
// away and no click ever arrives, so it can never eat a later one. A keydown
// releases it too: a gesture that ends with neither a click nor a cancel would
// otherwise leave it armed, and the click Enter synthesizes on some focused
// control is not the one this dismissal was owed.
function consumeActivation(source: Event) {
const swallow = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
release();
};
const restart = (event: Event) => {
if (event !== source) release();
};
const release = () => {
window.removeEventListener("click", swallow, true);
window.removeEventListener("pointerdown", restart, true);
window.removeEventListener("pointercancel", restart, true);
window.removeEventListener("keydown", release, true);
};
window.addEventListener("click", swallow, true);
window.addEventListener("pointerdown", restart, true);
window.addEventListener("pointercancel", restart, true);
window.addEventListener("keydown", release, true);
}
/**
* Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`
* for `ref` when what counts as inside isn't one element, and say so with
* `ignore` instead.
*
* The pointerdown listener is capture-phase: a bubble-phase one is blinded by
* any handler in between that stops propagation, and an overlay cannot know
* what it is layered over. `onDismiss` and `ignore` must be stable (wrap in
* useCallback) so the listeners aren't re-bound every render while open.
*/
export function useDismiss(
open: boolean,
onDismiss: () => void,
ref: RefObject<HTMLElement | null> | null,
{
behavior = "pass-through",
escape: dismissOnEscape = true,
ignore,
}: DismissOptions = {},
) {
useEffect(() => {
if (!open) return;
const inside = (target: Element) =>
Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));
const onKey = (event: KeyboardEvent) => {
if (dismissOnEscape && event.key === "Escape") onDismiss();
};
const onPointer = (event: PointerEvent) => {
const target = event.target as Element | null;
if (!target || inside(target)) return;
// Outside this overlay, but inside one that is also open: the gesture is
// that overlay's to answer, and swallowing its click from behind would
// cost the user the control they actually aimed at.
if (behavior === "consume" && !claimedByAnotherScope(inside, target)) {
consumeActivation(event);
}
onDismiss();
};
openScopes.add(inside);
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer, true);
return () => {
openScopes.delete(inside);
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer, true);
};
}, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);
}
lib/hooks/use-hover-gesture.ts
"use client";
import { useMemo, useRef } from "react";
import { isHoveringPointer } from "@/lib/touch";
interface BoundaryEvent {
pointerId: number;
pointerType: string;
buttons: number;
}
export interface HoverGesture {
/** True when this enter starts a hover: the pointer arrived resting, not pressing. */
enter: (event: BoundaryEvent) => boolean;
/** True when this leave ends a hover that entered as one. */
leave: (event: BoundaryEvent) => boolean;
}
/**
* Pairs a surface's enter with its leave, per pointer.
*
* `isHoveringPointer` answers the question the *enter* asks — is this pointer
* resting on the surface or pressing it — and both boundary cases go wrong if
* the leave is asked the same question again:
*
* - A pen with no hover never rests. It arrives in contact, taps, and the spec
* then requires its boundary events after `pointerup`, so the leave carries
* `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid
* the tap — the panel the pen had just opened closed under it.
* - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.
* Skipping teardown there strands the surface open: the release happens
* outside, and no second leave ever comes.
*
* So the state a hover holds is released by the pointer that took it, whatever
* the buttons say at the boundary, and a pointer that arrived in contact never
* took it in the first place. Contact is the exception tracked here, not
* hover: a leave from a pointer this surface never saw enter — mounted under
* the cursor, say — still counts, since the alternative is state with no way
* out.
*/
export function useHoverGesture(): HoverGesture {
const contact = useRef(new Set<number>());
return useMemo(
() => ({
enter: (event) => {
if (isHoveringPointer(event)) {
contact.current.delete(event.pointerId);
return true;
}
contact.current.add(event.pointerId);
return false;
},
leave: (event) => {
const arrivedInContact = contact.current.delete(event.pointerId);
return !arrivedInContact && event.pointerType !== "touch";
},
}),
[],
);
}
lib/hooks/use-tap-gesture.ts
"use client";
import { useMemo, useRef } from "react";
/** What a pointerdown recorded, read back by the click that ends its gesture. */
export interface TapRecord<S> {
/** Which input started the gesture. */
pointerType: string;
/** What the surface was showing when it started. */
state: S;
}
export interface TapGesture<S> {
/** Record the gesture a pointerdown starts, with the state it starts in. */
start: (event: { pointerType: string }, state: S) => void;
/** Read the record and clear it. `null` when no pointer is behind this click. */
take: () => TapRecord<S> | null;
/** Drop the record: this gesture will never spend it on a click. */
drop: () => void;
}
/**
* The pointer gesture behind a click, recorded where the click cannot report
* it. A `click` carries no `pointerType` in the engines that matter, so the
* `pointerdown` before it is the only thing that says which input activated
* the control — and whether one did at all, since keyboard activation
* synthesizes a click with no pointer behind it.
*
* State goes in with the record because a click reports that no better: a
* browser that focuses a control on contact can open the very panel the tap
* was meant to open, and reading "is it open" at click time then undoes it.
* What the gesture started against is what it acts on.
*
* The record is spent by one click and dropped by everything else, because a
* record that outlives its gesture is worse than none:
*
* - A scroll or an OS gesture takes the touch away — `pointercancel`, no click
* ever — and the finger would sit in the record until some later click.
* - That later click is often `Enter` on a keyboard, which arrives with no
* pointerdown of its own and would inherit the abandoned finger. A keydown
* is the start of a keyboard activation and never part of a tap, so it drops
* the record too.
*
* Both ends have to be wired by the surface: `drop` on `onPointerCancel` and
* on `onKeyDown`.
*/
export function useTapGesture<S>(): TapGesture<S> {
const record = useRef<TapRecord<S> | null>(null);
return useMemo(
() => ({
start: (event, state) => {
record.current = { pointerType: event.pointerType, state };
},
take: () => {
const spent = record.current;
record.current = null;
return spent;
},
drop: () => {
record.current = null;
},
}),
[],
);
}
lib/touch.ts
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;