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.
At 768px and up: hover or focus the icon for a Tooltip. Resize the window across 768px to see the swap.
import { InfoIcon } from "lucide-react"
import {
TooltipPopover,
TooltipPopoverContent,
TooltipPopoverTrigger,
} from "@/components/ui/tooltip-popover"
export function ResponseRateInfo() {
return (
<div>
Response rate: 98%
<TooltipPopover>
<TooltipPopoverTrigger aria-label="About response rate">
<InfoIcon />
</TooltipPopoverTrigger>
<TooltipPopoverContent popover={{ className: "w-auto max-w-xs" }}>
The share of messages you reply to within 24 hours.
</TooltipPopoverContent>
</TooltipPopover>
</div>
)
}Installation
npx shadcn@latest add @alexandrejoly/tooltip-popoverUpdate 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
└── TooltipPopoverContentInfotips, 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 fixedw-72card with padding. - Side: with no
sideon the Content, the Tooltip opens on top and the Popover on the bottom. - Offset (Radix): with no
sideOffset, the Tooltip sits at0and the Popover at4.
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:
At 768px and up: hover or focus the icon for a Tooltip.
<TooltipPopoverContent popover={{ className: "w-auto max-w-xs", side: "top" }}>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.
<TooltipPopover popover={{ modal: true }}>
<TooltipPopoverTrigger>Info</TooltipPopoverTrigger>
<TooltipPopoverContent popover={{ side: "bottom", sideOffset: 8 }}>
…
</TooltipPopoverContent>
</TooltipPopover>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,triggerIdanddefaultTriggerIdon the root,handleandpayloadon the Trigger. A Tooltip handle cannot open a Popover, so below 768px the popover opens only from aTooltipPopoverTriggerplaced insideTooltipPopover, or from a controlledopen. - Hover and cursor props:
delay,closeDelay,closeOnClick,trackCursorAxis,disableHoverablePopupon Base UI, anddelayDuration,disableHoverableContenton 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:
At 768px and up: screen readers announce the label, then the Tooltip.
<TooltipPopoverTrigger aria-label="About response rate">
<InfoIcon />
</TooltipPopoverTrigger>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
| Prop | Type | Default |
|---|---|---|
popover | Omit<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
| Prop | Type | Default |
|---|---|---|
popover | Omit<ComponentProps<typeof PopoverTrigger>, "children" | "handle" | "payload"> | - |
Radix notes
TooltipProviderat 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:
openOnHoverdoes not exist inpopover={{...}}. disabledonTooltipPopoverTriggerdisables 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.