Proximity Prompt
Overlays

Proximity Prompt

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.

Install

npx shadcn@latest add @paragon/proximity-prompt

proximity-prompt.tsx

"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>
  );
}