TooltipPopover

A hover Tooltip at desktop that becomes a tap-to-open Popover below 768px.

A drop-in replacement for the shadcn Tooltip: at 768px and up it renders your Tooltip unchanged; below, the same children render inside your Popover. Hover does not exist on touch, so a tooltip never opens under a finger; a Popover opens on tap. The breakpoint and the override prop work the same for every pair, so this page covers only what is specific to this one.

Use it for infotips, where the trigger only reveals the tip. Keep the shadcn Tooltip, with an aria-label, on action buttons: see Infotips, not action buttons. It works on Base UI and Radix; React Aria is not supported.

Response rate: 98%

At 768px and up: hover or focus the icon for a Tooltip. Resize the window across 768px to see the swap.

Installation

npx shadcn@latest add @alexandrejoly/tooltip-popover

Update the import paths to match your project setup.

Migrating

Change the imports and rename the tags: Tooltip becomes TooltipPopover, TooltipTrigger becomes TooltipPopoverTrigger, TooltipContent becomes TooltipPopoverContent, with the same props and children (see Migrating). Leave TooltipProvider as it is, imported from @/components/ui/tooltip at the root of your app: it has no TooltipPopover counterpart.

Usage

import {
  TooltipPopover,
  TooltipPopoverContent,
  TooltipPopoverTrigger,
} from "@/components/ui/tooltip-popover"
<TooltipPopover>
  <TooltipPopoverTrigger>Response rate</TooltipPopoverTrigger>
  <TooltipPopoverContent>
    <p>The share of messages you reply to within 24 hours.</p>
  </TooltipPopoverContent>
</TooltipPopover>

Composition

TooltipPopover
├── TooltipPopoverTrigger
└── TooltipPopoverContent

Infotips, not action buttons

Below 768px the trigger opens the popover on tap, and its own onClick still runs. A tooltip on an action button (Delete, Copy, a button that opens a dialog or a menu) would run the action and open a popover with the same tap. Migrate only the tooltips whose trigger exists to reveal the tip. Links and form fields used as triggers also open the popover on tap.

TooltipProvider

TooltipProvider stays the app-level shadcn setup, imported from your Tooltip. On Radix it is required: the desktop Tooltip throws without it. On Base UI it is optional and groups tooltip delays. It renders no element, so it does not affect the Popover below 768px.

The trigger across the breakpoint

The trigger is a different component on each side, so it remounts when the window crosses 768px, and once after hydration on a small screen. Keep its children stateless.

The content box

A tooltip and a popover have different sizes and defaults. Each side keeps its own, as everywhere in responsivecn:

  • Width: the Tooltip fits its text (w-fit max-w-xs); the Popover is a fixed w-72 card with padding.
  • Side: with no side on the Content, the Tooltip opens on top and the Popover on the bottom.
  • Offset (Radix): with no sideOffset, the Tooltip sits at 0 and the Popover at 4.

Set the prop on the Content for both sides, or in popover={{...}} for the Popover only. For a one-line tip, size the Popover to its text:

Storage used: 4.2 GB

At 768px and up: hover or focus the icon for a Tooltip.

Overriding the Popover

Everything inside popover={{...}} is your installed Popover's own API, on the root, the Trigger and the Content:

At 768px and up: a Tooltip. The override applies below 768px only.

The override is typed on your own Popover, so it takes only what your base has. On Base UI, popover={{ openOnHover: true }} on the Trigger also opens the Popover on hover for devices that have both.

popover is also a native HTML attribute (the Popover API). The parts use the name for the override, so the native attribute cannot be passed through.

Disabled

On Base UI, disabled on TooltipPopover or on TooltipPopoverTrigger keeps the tip closed and leaves the trigger usable. Below 768px it does the same: the Popover stays closed and the trigger button stays enabled.

Trigger elements

On Base UI, a trigger rendered as another element (render={<span />}, <a>) gets button semantics below 768px without extra props. For a component that renders something other than a <button>, pass popover={{ nativeButton: false }} on the Trigger.

onOpenChange

onOpenChange on the root has your Tooltip's signature and also fires below 768px. There it receives the Popover's values, so on Base UI eventDetails.reason can be a reason only the Popover has, such as "close-press" or "focus-out". Do not switch exhaustively on the Tooltip's reasons. For logic that needs the Popover's details, use popover={{ onOpenChange }}, which runs after the shared handler.

A className or render function on a part receives your Tooltip's state at 768px and up and your Popover's state below.

Desktop-only props

These props apply at 768px and up and are dropped below:

  • Detached triggers on Base UI: handle, triggerId and defaultTriggerId on the root, handle and payload on the Trigger. A Tooltip handle cannot open a Popover, so below 768px the popover opens only from a TooltipPopoverTrigger placed inside TooltipPopover, or from a controlled open.
  • Hover and cursor props: delay, closeDelay, closeOnClick, trackCursorAxis, disableHoverablePopup on Base UI, and delayDuration, disableHoverableContent on Radix. A tap has no hover to delay or track.

Accessibility

Give an icon-only trigger an aria-label that names what the tip explains. Below 768px nothing opens until the trigger is pressed, so the label is all a screen reader gets up front:

Response rate: 98%

At 768px and up: screen readers announce the label, then the Tooltip.

The width check is not a touch check: a tablet at 768px or wider gets the desktop Tooltip.

API reference

Each part takes every prop of its shadcn Tooltip counterpart, plus a popover prop that takes the props of the matching Popover part, except children: popover on the Content takes your PopoverContent props, and so on. Only the parts below leave out more.

TooltipPopover

PropTypeDefault
popoverOmit<ComponentProps<typeof Popover>, "children" | RootState>-

RootState stands for the keys that popover leaves out because they belong to the shared root: open and defaultOpen, since both sides share one open state, and the detached-trigger props handle, triggerId and defaultTriggerId.

TooltipPopoverTrigger

PropTypeDefault
popoverOmit<ComponentProps<typeof PopoverTrigger>, "children" | "handle" | "payload">-

Radix notes

  • TooltipProvider at the app root is required.
  • A Radix tooltip does not open on touch, and a tap closes an open one. The Popover below 768px fixes that.
  • The Radix Popover trigger opens on click only: openOnHover does not exist in popover={{...}}.
  • disabled on TooltipPopoverTrigger disables the button at 768px and up. Below 768px it keeps the Popover closed and leaves the button enabled.

Learn more

The underlying components are documented upstream: shadcn Tooltip and Popover, built on Base UI Tooltip and Base UI Popover, or on Radix Tooltip and Radix Popover.

On this page