Tooltip
A small label that names the control under your finger.

A small label naming the control under your finger.
A Popover is a panel you open and deal with; a tooltip is a whisper. It carries a word or two, appears without taking the screen, and goes away on its own — which is why it is inverted rather than surface-coloured, is not dismissed with a scrim, and hides itself after a beat.
On touch there is no hover, so the gesture is a long press by default — the platform's own "tell me more" — with openOn="press" where a tap should reveal it instead.
Placement is a preference, not a promise: placement="top" means above, if above fits, and a trigger near the top edge shows its tooltip below.
Installation
Tooltip ships with the library — no separate install.
import { Tooltip, Button, Text, InfoIcon } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add tooltipUsage
<Tooltip label="Copy link">
<Tooltip.Trigger>
<Button variant="outline">Press and hold</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Arrow />
Copy link
</Tooltip.Content>
</Tooltip>Composition
<Tooltip>
<Tooltip.Trigger>…</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Arrow />
<Tooltip.Title>…</Tooltip.Title>
<Tooltip.Description>…</Tooltip.Description>
<Tooltip.Text>…</Tooltip.Text>
</Tooltip.Content>
</Tooltip>Tooltip.Trigger— Wraps a single child and reveals the label on it — a long press by default, a press when the root asks for one. It is also what gets measured, so the label knows where to sit; the wrapper shrinks to the child rather than filling the row, so the label is anchored to the control.Tooltip.Content— The label. Portaled above everything else, positioned against the trigger, and flipped or slid to stay inside the safe area. Text written directly inside it is wrapped inTooltip.Textfor you, so an arrow followed by a line of text needs no ceremony.Tooltip.Arrow— Optional point towards the trigger. Follows the resolved side, so it stays correct after a flip.Tooltip.Title— A heading, for a tooltip carrying more than a label.Tooltip.Description— The sentence under aTooltip.Title, in the panel's secondary colour.Tooltip.Text— The label's default text, coloured to whatever the panel is made of. Compose it yourself when the label holds more than a single run of text.
Examples
Naming an icon-only control
An icon button has no text for a screen reader to read. Pass label on the root and it becomes the trigger's accessibility label, so the control is announced without anyone having to open the tooltip. openOn="press" reveals it on a tap here, since the icon has no other press to protect.
<Tooltip openOn="press" label="More information">
<Tooltip.Trigger>
<Button variant="ghost" size="icon" accessibilityLabel="Info">
<InfoIcon size={20} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Arrow />
Syncs every 15 minutes
</Tooltip.Content>
</Tooltip>Choosing a side
placement picks the preferred side; it is flipped only when that side genuinely has less room than its opposite. align and alignOffset move the label along the other axis, and offset changes the gap to the trigger.
<Tooltip.Content placement="right" align="start">
<Tooltip.Arrow />
Opens to the right
</Tooltip.Content>Staying up until dismissed
The label hides itself after duration milliseconds. Pass duration={0} to keep it up until a tap outside — or the trigger again — dismisses it, which is what you want when it carries something worth reading twice.
<Tooltip openOn="press" duration={0}>
<Tooltip.Trigger>
<Button variant="secondary">Keyboard shortcut</Button>
</Tooltip.Trigger>
<Tooltip.Content className="flex-row items-center gap-2">
<Tooltip.Arrow />
<Tooltip.Text>Save</Tooltip.Text>
<View className="rounded bg-background/20 px-1.5 py-0.5">
<Text size="xs" weight="semibold" className="text-background">⌘S</Text>
</View>
</Tooltip.Content>
</Tooltip>Controlled
Drive it yourself with open and onOpenChange — to reveal a hint from somewhere other than its trigger, or to coordinate it with the rest of a screen.
const [open, setOpen] = useState(false);
<Tooltip open={open} onOpenChange={setOpen}>
<Tooltip.Trigger>
<Button variant="outline">Save</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Arrow />
Saved just now
</Tooltip.Content>
</Tooltip>A panel, not a label
Past a line of text the inversion stops reading as a whisper and starts reading as a panel with the wrong colours — so variant="surface" makes it a real one, on the same tokens the popover uses. Give it a width too: without one the panel runs to whatever width the sentence happens to want.
<Tooltip openOn="press" duration={0}>
<Tooltip.Trigger>
<Button variant="outline">What is streaming?</Button>
</Tooltip.Trigger>
<Tooltip.Content variant="surface" width={264} className="gap-1 p-3">
<Tooltip.Arrow />
<Tooltip.Title>Streaming</Tooltip.Title>
<Tooltip.Description>
Tokens are rendered as they arrive rather than waiting for the whole
reply, so the first words appear in a few hundred milliseconds.
</Tooltip.Description>
</Tooltip.Content>
</Tooltip>Scrolled, when it has to be
maxHeight caps the panel and scrollable makes the overflow reachable — the same pair the popover has, and worth reaching for only when the content is genuinely longer than a tooltip should be. The cap is always clamped to the room inside the safe area as well.
<Tooltip openOn="press" duration={0}>
<Tooltip.Trigger>
<Button variant="outline">Release notes</Button>
</Tooltip.Trigger>
<Tooltip.Content
variant="surface"
width={280}
maxHeight={220}
scrollable
className="p-3"
>
<View className="gap-2">
<Tooltip.Title>What changed</Tooltip.Title>
{notes.map((line) => (
<Tooltip.Description key={line}>{line}</Tooltip.Description>
))}
</View>
</Tooltip.Content>
</Tooltip>Variants
variant
inverted(default)surface
{/* The default. A whisper over the page, reading as a different layer. */}
<Tooltip.Content variant="inverted">Copy link</Tooltip.Content>
{/* On the popover's own tokens, for a panel's worth of content. */}
<Tooltip.Content variant="surface" width={240}>
<Tooltip.Title>Streaming</Tooltip.Title>
<Tooltip.Description>Tokens arrive as they are generated.</Tooltip.Description>
</Tooltip.Content>API Reference
Tooltip
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. |
onOpenChange | (open: boolean) => void | — | |
defaultOpen | boolean | false | Initial state when uncontrolled. |
openOn | TooltipOpenOn | 'longPress' | Whether a long press or a plain press reveals the label. Long press is the default because it does not steal a tappable control's own press. |
duration | number | 1500 | How long the label stays up before hiding itself, in milliseconds. 0 keeps it up until it is dismissed by a tap outside or the trigger again. |
label | string | — | The label's text, mirrored onto the trigger as its accessibility label so a screen reader announces what the tooltip says without opening it. Set it whenever the trigger has no text of its own — an icon-only button. |
Tooltip.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Classes on the wrapper the child is measured through. It shrinks to the child by default; widen it only if the label should be anchored to something bigger than the control. |
onPress | (...args: unknown[]) => void | — | |
onLongPress | (...args: unknown[]) => void | — | |
accessibilityLabel | string | — | |
accessibilityHint | string | — |
Tooltip.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
placement | TooltipPlacement | 'top' | Preferred side of the trigger. Flipped when that side does not fit. |
align | TooltipAlign | 'center' | Where the label sits along the trigger's other axis. |
offset | number | 6 | Gap between the trigger and the label, in pixels. |
alignOffset | number | 0 | Nudge along the alignment axis, in pixels. |
variant | TooltipVariant | 'inverted' | Which set of colours the panel, its arrow and its text draw from. inverted is the default and right for a label: a whisper over the page should read as a different layer rather than as another panel of it. surface matches the popover — reach for it once the tooltip carries a heading and a sentence, where the inversion stops reading as a whisper. |
width | number | 'trigger' | 'full' | 'content-fit' | 'content-fit' | content-fit sizes to the content, trigger matches the trigger's width, full spans the safe area, and a number is that many pixels. Worth setting for anything longer than a label, which would otherwise run to whatever width the sentence happens to want. |
minWidth | number | — | Floor for the panel's width, in pixels. |
maxHeight | number | — | Ceiling for the panel's height, in pixels. Always clamped to the room inside the safe area, which is also the default. |
scrollable | boolean | false | Scroll the body when it is taller than maxHeight. Off by default — a label has nothing to scroll, and a scroller around one only adds a bounce. |
Tooltip.Title
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Tooltip.Description
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Tooltip.Arrow
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
Placement is resolved, not obeyed. The side is flipped only when the preferred one genuinely has less room than its opposite; after that the label is slid along the other axis to stay inside the safe area. That order matters — sliding first would let a badly placed label look like it fits.
The label is measured before it is shown. Its size is not known until it has laid out once, so the first frame is laid out off-screen and the entrance starts from the frame after. That entrance is driven by hand rather than by a preset, because it has to start from whichever side was resolved.
The arrow points at the trigger, not the label. When align shifts the label off-centre, or a clamp slides it back on screen, the arrow tracks the trigger's centre rather than the label's middle.
The trigger wrapper shrinks to its child. A view fills its parent by default, and a wrapper that filled the row would be measured as the whole row — putting a centred label over the middle of the screen instead of over the control. Pass className to Tooltip.Trigger if you want it anchored to something wider.
The trigger is measured on every open, not once on layout — a trigger inside a scroll view has moved since it was laid out, and a stale rectangle anchors the label to where it used to be.
Long press is the default gesture. It does not steal a tappable control's own press, so a button under a tooltip still works as a button. Use openOn="press" only when the trigger has no press of its own to protect, such as an icon that exists to be explained.
Colours
Every colour the tooltip has — the panel, the arrow and the default text — comes from one variant on Tooltip.Content. It used to be three literals at three call sites, which meant retheming a tooltip took three separate className overrides that each had to be kept in step with the others.
inverted is the default: --color-foreground behind --color-background text. It is deliberately not a surface colour, because a one-line label over the page should read as a different layer rather than as another panel of it. Note what that means in a dark theme — --color-foreground is near-white there, so the label is a light slab with dark text. That is the intent, not a bug.
surface is the other reading, on --color-popover with a border, matching the popover exactly. Reach for it as soon as the tooltip carries a heading and a sentence.
The chosen variant is published on the tooltip's context, so Tooltip.Arrow, Tooltip.Title, Tooltip.Description and Tooltip.Text all follow it without being told again.
Sizing
width, minWidth, maxHeight and scrollable behave exactly as they do on Popover: content-fit is the default, trigger matches the control that opened it, full spans the safe area, and a number is pixels. The height is always clamped to the room inside the safe area whether or not you cap it, because a panel taller than the screen has nowhere to slide to.
A label needs none of them. They exist for the surface case, where a sentence with no width runs to whatever the text wants and a paragraph with no cap runs off the screen.
Reach for a Popover instead when the content is interactive — a menu, a form, anything with its own controls. A tooltip is a label, not a surface, and it dismisses itself out from under a tap.
Public exports
Values: Tooltip
Types: TooltipProps, TooltipTriggerProps, TooltipContentProps, TooltipArrowProps, TooltipTitleProps, TooltipDescriptionProps, TooltipPlacement, TooltipAlign, TooltipOpenOn, TooltipVariant