# TooltipPopover

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

A drop-in replacement for the shadcn
[Tooltip](https://ui.shadcn.com/docs/components/tooltip): at 768px and up it
renders your Tooltip unchanged; below, the same children render inside your
[Popover](https://ui.shadcn.com/docs/components/popover). Hover does not exist
on touch, so a tooltip never opens under a finger; a Popover opens on tap. The
[breakpoint](/docs/breakpoint) and the [override prop](/docs/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](#infotips-not-action-buttons). It works on Base
UI and Radix; React Aria is not supported.


```tsx
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

<Steps>

<Step>

```bash
npx shadcn@latest add @alexandrejoly/tooltip-popover
```

</Step>

<Step>

Update the import paths to match your project setup.

</Step>

</Steps>

## 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](/docs/installation#migrating)). Leave `TooltipProvider` as
it is, imported from `@/components/ui/tooltip` at the root of your app: it has
no `TooltipPopover` counterpart.

## Usage

```tsx
import {
  TooltipPopover,
  TooltipPopoverContent,
  TooltipPopoverTrigger,
} from "@/components/ui/tooltip-popover"
```

```tsx
<TooltipPopover>
  <TooltipPopoverTrigger>Response rate</TooltipPopoverTrigger>
  <TooltipPopoverContent>
    <p>The share of messages you reply to within 24 hours.</p>
  </TooltipPopoverContent>
</TooltipPopover>
```

## Composition

```txt
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:


```tsx
<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:


```tsx
<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`, `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:


```tsx
<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

- `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](https://ui.shadcn.com/docs/components/tooltip) and
[Popover](https://ui.shadcn.com/docs/components/popover), built on
[Base UI Tooltip](https://base-ui.com/react/components/tooltip) and
[Base UI Popover](https://base-ui.com/react/components/popover), or on
[Radix Tooltip](https://www.radix-ui.com/primitives/docs/components/tooltip)
and [Radix Popover](https://www.radix-ui.com/primitives/docs/components/popover).
