A quiet helper chip that fades in after the pointer dwells in a zone without clicking, withdraws the moment the user acts, and never arms on touch devices.
npx shadcn@latest add @paragon/proximity-prompt"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CircleHelp } from "lucide-react";
import { cn } from "@/lib/utils";
const positionClass = {
"top-right": "top-2 right-2",
"top-left": "top-2 left-2",
"bottom-right": "bottom-2 right-2",
"bottom-left": "bottom-2 left-2",
"bottom-center": "bottom-2 left-1/2 -translate-x-1/2",
} as const;
export interface ProximityPromptProps
extends Omit<React.ComponentProps<"div">, "onDrag" | "children"> {
/** The zone being watched. */
children: React.ReactNode;
/** Chip copy. */
label?: string;
/** ms the pointer must dwell in the zone, without clicking, before the chip shows. */
dwell?: number;
/** Where the chip appears within the zone. */
position?: keyof typeof positionClass;
/** Icon inside the chip. Pass `null` to hide it. */
icon?: React.ReactNode;
/** Makes the chip clickable; runs when the user takes the offer. */
onAction?: () => void;
/** Show at most once per mount. */
once?: boolean;
}
/**
* A quiet helper chip for hesitation: if the pointer dwells in the zone for
* `dwell` ms without a click, the chip fades in; the moment the user acts
* (clicks anywhere) or leaves, it withdraws with a plain fade — hints never
* fight for attention. Fine-pointer only: touch devices have no hover
* hesitation to read, so the chip simply never arms there. The dwell timer
* also clears when the tab hides.
*/
export function ProximityPrompt({
children,
label = "Need help?",
dwell = 600,
position = "bottom-right",
icon,
onAction,
once = false,
className,
...props
}: ProximityPromptProps) {
const reducedMotion = useReducedMotion();
const [finePointer, setFinePointer] = React.useState(false);
const [visible, setVisible] = React.useState(false);
const shownOnce = React.useRef(false);
const chipRef = React.useRef<HTMLDivElement>(null);
const timer = React.useRef<ReturnType<typeof setTimeout>>(null);
const clear = React.useCallback(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
}, []);
React.useEffect(() => {
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
setFinePointer(query.matches);
const onChange = (event: MediaQueryListEvent) =>
setFinePointer(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
React.useEffect(() => {
const onHidden = () => {
if (document.hidden) {
clear();
setVisible(false);
}
};
document.addEventListener("visibilitychange", onHidden);
return () => {
document.removeEventListener("visibilitychange", onHidden);
clear();
};
}, [clear]);
const arm = () => {
if (!finePointer || (once && shownOnce.current)) return;
clear();
timer.current = setTimeout(() => {
shownOnce.current = true;
setVisible(true);
}, dwell);
};
const disarm = () => {
clear();
setVisible(false);
};
const chipContent = (
<>
{icon !== null && (
<span aria-hidden className="shrink-0 text-muted-foreground">
{icon ?? <CircleHelp className="size-3.5" />}
</span>
)}
{label}
</>
);
return (
<div
data-slot="proximity-prompt"
className={cn("relative", className)}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") arm();
}}
onPointerLeave={disarm}
// A click means the user found their way — withdraw quietly. Clicks on
// the chip itself are exempt so its own action can run first.
onPointerDownCapture={(event) => {
if (chipRef.current?.contains(event.target as Node)) return;
clear();
setVisible(false);
}}
{...props}
>
{children}
<AnimatePresence>
{visible && (
<motion.div
ref={chipRef}
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, y: 6, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{
opacity: 0,
transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
}}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className={cn("absolute z-10", positionClass[position])}
>
{onAction ? (
<button
type="button"
onClick={() => {
onAction();
disarm();
}}
className="pressable relative flex h-8 items-center gap-1.5 rounded-full bg-popover px-3 text-xs font-medium text-popover-foreground shadow-overlay transition-colors duration-150 outline-none after:absolute after:top-1/2 after:left-1/2 after:h-10 after:w-full after:min-w-10 after:-translate-x-1/2 after:-translate-y-1/2 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
{chipContent}
</button>
) : (
<div
role="status"
className="flex h-8 items-center gap-1.5 rounded-full bg-popover px-3 text-xs font-medium text-popover-foreground shadow-overlay"
>
{chipContent}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}