Tooltip
Overlays

Tooltip

Radix tooltip with a 500ms first-open delay and a 125ms origin-aware enter; skip-delay opens and exits are bare near-instant fades.

Install

npx shadcn@latest add @paragon/tooltip

tooltip.tsx

"use client";

import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";

/*
 * Tooltip physics tuned by frequency: 500ms delay before the first open
 * (never interrupt a scanning cursor), then TooltipProvider's
 * skipDelayDuration lets adjacent tooltips open instantly. The first
 * open (data-state="delayed-open" — Radix tooltips never report plain
 * "open") enters with a 125ms origin-aware scale; adjacent opens inside
 * the skip-delay window (data-state="instant-open") get a bare 50ms fade
 * — the second, third, nth tooltip of a scan should register, not
 * perform. Exit is a near-instant 50ms fade — a leaving tooltip should
 * not fight for attention.
 *
 * Wrap toolbars/regions in <TooltipProvider> so the skip-delay window
 * is shared across neighboring tooltips.
 */
const tooltipStyles = `
@keyframes pg-tooltip-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-tooltip-fade-in { from { opacity: 0; } }
@keyframes pg-tooltip-out { to { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-tooltip-in { from { opacity: 0; } }
}
`;

function TooltipProvider({
  delayDuration = 500,
  skipDelayDuration = 300,
  ...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
  return (
    <>
      {/* Register the enter keyframes at initial render — long before any
          tooltip opens. TooltipContent mounts (and portals) only once the
          tooltip is already open, so if its <style> were the sole source the
          keyframes and the animated node would land in the SAME React 19
          commit on the very first hover/focus; Chrome then creates the
          animation before the rule resolves, leaves it pending (startTime
          null) pinned on its `from` frame — opacity:0 — and the tooltip
          never becomes visible. Hoisting here breaks that race. Deduped with
          the copy in TooltipContent via the shared href + precedence. */}
      <style href="paragon-tooltip" precedence="paragon">{tooltipStyles}</style>
      <TooltipPrimitive.Provider
        delayDuration={delayDuration}
        skipDelayDuration={skipDelayDuration}
        {...props}
      />
    </>
  );
}

function Tooltip({
  delayDuration = 500,
  ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
  return (
    <TooltipPrimitive.Root
      data-slot="tooltip"
      delayDuration={delayDuration}
      {...props}
    />
  );
}

function TooltipTrigger(
  props: React.ComponentProps<typeof TooltipPrimitive.Trigger>,
) {
  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}

function TooltipContent({
  className,
  sideOffset = 6,
  ...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
  return (
    <>
      {/* Fallback registration for Tooltips used without our TooltipProvider.
          Hoisted OUTSIDE the Portal (React 19 keeps a hoistable <style> as a
          child node and the Radix Portal enforces a single child) and deduped
          with TooltipProvider's copy via the shared href + precedence. */}
      <style href="paragon-tooltip" precedence="paragon">{tooltipStyles}</style>
      <TooltipPrimitive.Portal>
        <TooltipPrimitive.Content
          data-slot="tooltip-content"
          sideOffset={sideOffset}
          className={cn(
            "z-50 w-fit max-w-72 origin-(--radix-tooltip-content-transform-origin) rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-balance text-primary-foreground",
            "data-[state=delayed-open]:animate-[pg-tooltip-in_125ms_var(--ease-out)]",
            "data-[state=instant-open]:animate-[pg-tooltip-fade-in_50ms_var(--ease-out)]",
            "data-[state=closed]:animate-[pg-tooltip-out_50ms_var(--ease-exit)_forwards]",
            className,
          )}
          {...props}
        />
      </TooltipPrimitive.Portal>
    </>
  );
}

export { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent };