Skip to main content

Tooltip

Tooltips describe the control they are attached to, on a temporary surface anchored to it. Follows the Material Design 3 Tooltip specification.

Tooltip renders through Portal, so your app must be wrapped in a PortalHost once at the root. It claims the PORTAL_LAYERS.tooltip layer — the topmost one, because a tooltip can describe a control in any layer below it.

There are two variants. Plain is a line of text that appears while the control is hovered or long-pressed and takes itself down after 1.5s. Rich adds a subhead and actions, and stays up until it is dismissed.

Usage​

Pass the control as anchor and the text as children.

import { IconButton, PortalHost, Tooltip } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { View } from 'react-native'

function Screen() {
return (
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<Tooltip anchor={<IconButton icon="heart-outline" accessibilityLabel="Favourite" />}>
Add to favourites
</Tooltip>
<Tooltip anchor={<IconButton icon="share-variant" accessibilityLabel="Share" />}>
Share
</Tooltip>
<Tooltip
side="bottom"
anchor={<IconButton icon="archive-outline" accessibilityLabel="Archive" />}
>
Move to archive
</Tooltip>
</View>
)
}

export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider iconResolver={mdiResolver}>
<PortalHost>
<Screen />
</PortalHost>
</ThemeProvider>
</SafeAreaProvider>
)
}

The anchor renders where the <Tooltip> sits in your tree, wrapped in a measuring view that watches for hover. Strings and numbers passed as children, also text with {expressions}, are wrapped in the variant's supporting-text styling. Anything else renders as given, so text beside an element needs its own Text.

Triggers​

InputBehaviour
Hover inShows the tooltip. Hover out hides a plain tooltip. A rich tooltip stays up, so the pointer can reach its actions
FocusShows the tooltip on web, so a Tab stop reads the same as a hover. Blur hides a plain tooltip. A rich tooltip stays up, so Tab can reach its actions
EscapeHides the tooltip while the anchor has focus. The key stops there, so an enclosing dialog does not close on the same press
Long pressShows the tooltip. This is how touch devices reach it
PressHides the tooltip — a tap is the control doing its job, not a request for help text
TimeoutHides a plain tooltip after duration (1500ms). Rich tooltips never time out
Outside press / Android backHides a rich tooltip. A plain one has no dismiss region to press

The long press and the focus pair are injected into the anchor itself, so the anchor must accept onLongPress, onFocus and onBlur — every RootNative pressable does, and its own handlers still fire. Hover is caught on the wrapper instead, because every RootNative pressable drives its state layer from onHoverIn/onHoverOut and would overwrite an injected pair.

MD3 takes a plain tooltip down 1.5s after it appears, whether or not the pointer is still on the anchor. Pass duration={0} to keep it up until a hover out or a press instead.

Rich​

variant="rich" adds a subhead above the text and an actions row below it, and makes the tooltip persistent — it waits for an outside press, an action, or the Android back button. Its surface takes touches, unlike a plain tooltip.

import { Button, IconButton, PortalHost, Tooltip } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { useState } from 'react'
import { Text, View } from 'react-native'

function Screen() {
const [open, setOpen] = useState(false)
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<Tooltip
variant="rich"
subhead="Rich tooltip"
actions={<Button variant="text">Learn more</Button>}
anchor={<IconButton icon="information-outline" accessibilityLabel="About sync" />}
>
Rich tooltips bring attention to a feature that warrants a sentence of explanation.
</Tooltip>

<Tooltip
visible={open}
onDismiss={() => setOpen(false)}
variant="rich"
subhead="Controlled"
actions={<Button variant="text" onPress={() => setOpen(false)}>Got it</Button>}
anchor={<Button variant="tonal" onPress={() => setOpen(true)}>Show help</Button>}
>
visible + onDismiss put you in charge — the anchor stops opening it by itself.
</Tooltip>
</View>
)
}

export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider iconResolver={mdiResolver}>
<PortalHost>
<Screen />
</PortalHost>
</ThemeProvider>
</SafeAreaProvider>
)
}

subhead and actions are rich-only. Passing them to a plain tooltip logs a development error and renders nothing — a plain tooltip is a single line of text.

Controlled​

Pass visible and onDismiss to drive visibility yourself. The anchor then stops opening the tooltip: hover and long press are yours to wire, which is how a tooltip gets attached to something that isn't a press at all — a form error, a first-run hint.

Dismissals that don't come from the anchor still report themselves, so a controlled tooltip behaves like every other overlay in the library:

  • A rich tooltip's outside press and Android back call onDismiss.
  • A plain tooltip's timeout calls onDismiss after duration. Pass duration={0} if you want the tooltip to stay up until you take it down.

Placement​

side and align are preferences, not commands.

PropValuesDefaultMeaning
side'top' | 'bottom''top'Which side of the anchor the tooltip prefers
align'start' | 'center' | 'end''center'Cross-axis alignment against the anchor
offsetnumber4Gap between the anchor edge and the tooltip, in dp
screenMarginnumber8Minimum distance the tooltip keeps from every screen edge

align is logical: 'start' is the anchor's left edge in LTR and its right edge in RTL.

A tooltip that would not fit on its preferred side flips to the other side — but only when that side is genuinely roomier. Horizontally it shifts back inside the margin rather than flipping. It is measured from the anchor's window coordinates when it opens, and does not follow the anchor while a scroll view moves underneath it.

Like a menu, a tooltip is confined to the overlay layer it renders into, which is why the root of your app is the right place for PortalHost — see the note on menu placement. Keep tooltips short: one caps at 200dp wide, the other at 320dp, and neither exceeds the space available on the side it resolved to.

Overrides​

PropTarget
containerColorThe tooltip surface background
contentColorThe supporting text, plus the subhead on a rich tooltip
styleThe tooltip surface
textStyleThe supporting-text Text only
anchorStyleThe view wrapping the anchor

The tooltip surface draws no state layer of its own, so there is nothing to re-derive from containerColor — it just recolors the surface. Nodes you pass as children or actions are yours to color.

Tokens​

TokenPlainRich
ContainerinverseSurface, corner 4dp (cornerExtraSmall)surfaceContainer, corner 12dp (cornerMedium), elevation level 2
Subhead—titleSmall / onSurfaceVariant
Supporting textbodySmall / inverseOnSurfacebodyMedium / onSurfaceVariant
Padding8dp horizontal, 4dp vertical16dp horizontal, 12dp vertical
Sizemin 40×24dp, max 200dp widemin 40×24dp, max 320dp wide
Gap to anchor4dp4dp
Duration1500msPersistent

Plain tooltips carry no elevation in MD3 — the inverse surface is what separates them from the content behind. Actions are start-aligned, the opposite of a dialog's.

Motion​

The tooltip fades in and out on springFastEffects — no scale, unlike a menu. It collapses to a hard cut under reduced motion; see Motion.

The surface stays invisible for the frame between mounting and being measured: its own size is what decides which side it lands on, so it has to be laid out before it can be placed.

Accessibility​

  • The surface reports role="tooltip" and announces itself as a polite live region when it appears.
  • A plain tooltip's surface never takes touches, so it cannot stand between the user and the control it describes. A rich tooltip's does, and the region that catches outside presses is announced as a button labelled "Close tooltip" — override with dismissAccessibilityLabel.
  • On web, the anchor carries aria-describedby pointing at the tooltip while it is shown, which is the only thing that connects the two — the surface renders in a portal, far from the anchor in the DOM. React Native has no equivalent, so this is web-only.
  • The wrapper around the anchor stays out of the accessibility tree, so the anchor's own role and label are the only thing a screen reader sees. A tooltip is supplementary: an icon-only button still needs its accessibilityLabel, and the tooltip text should add to it rather than repeat it.
  • On web, keyboard focus on the anchor shows the tooltip and Escape hides it, so a keyboard user reaches the same text a pointer does. Focus inside a rich tooltip's own actions does not close it.
  • Hover, focus and long press are supplementary. Anything a user must read to operate the control belongs in the UI or in the anchor's label, not only in a tooltip.

Props​

PropTypeDefaultRequiredDescription
anchorReactNode-YesThe control the tooltip describes. Rendered where the `<Tooltip>` sits in the tree, wrapped in a measuring view that watches for hover. Touch devices open the tooltip on a long press, which needs the anchor to accept `onLongPress` — every RootNative pressable does. Its own `onLongPress` still fires.
childrenReactNode-NoSupporting text, or arbitrary nodes when a plain string isn't enough. Strings and numbers, also text with `{expressions}`, are wrapped in the variant's supporting-text styling. Text beside an element needs its own `Text`.
variantenumplainNoTooltip anatomy.
subheadstring-NoSubhead above the supporting text. Rich tooltips only.
actionsReactNode-NoAction buttons below the supporting text — text `Button`s per MD3. Rich tooltips only.
visibleboolean-NoControlled visibility. Omit to let the tooltip show itself on hover or a long press, and hide itself on hover out, on an anchor press, after `duration` (plain), or on an outside press / Android back (rich).
onDismiss() => void-NoCalled when the tooltip hides — hover out, anchor press, the plain variant's timeout, or a rich tooltip's outside press / Android back. Required to close a controlled tooltip; optional otherwise.
sideenumtopNoSide of the anchor the tooltip prefers. It flips to the other side when it does not fit and that side is roomier.
alignenumcenterNoCross-axis alignment against the anchor.
offsetnumber4NoGap between the anchor edge and the tooltip, in dp.
screenMarginnumber8NoMinimum distance the tooltip keeps from every screen edge, in dp.
durationnumber1500NoHow long a plain tooltip stays up, in milliseconds. `0` keeps it up until something else hides it. Ignored by the rich variant, which is persistent by definition.
containerColorstringinverseSurface (plain) / surfaceContainer (rich)NoOverride the container (surface) color.
contentColorstringinverseOnSurface (plain) / onSurfaceVariant (rich)NoOverride the text color — the supporting text, plus the subhead on a rich tooltip. Nodes passed as `children` or `actions` are yours to color.
hostNamestring-NoName of the `PortalHost` to render into. Defaults to the root host, which is what puts the tooltip above every other layer.
styleStyleProp<ViewStyle>-NoStyle applied to the tooltip surface.
textStyleStyleProp<TextStyle>-NoStyle applied to the supporting-text `Text` only.
anchorStyleStyleProp<ViewStyle>-NoStyle applied to the view wrapping the anchor.
dismissAccessibilityLabelstringClose tooltipNoScreen-reader label for the region that closes a rich tooltip on an outside press. Plain tooltips never render one.
testIDstring-NoTest id applied to the tooltip surface.
hrefAttrs{ target?: string; rel?: string; download?: string | boolean; }-NoWeb only. The `target`, `rel` and `download` of the `<a>` that a view with an `href` renders, for example under Expo Router's `<Link asChild>`. `target: '_blank'` opens the link in a new tab. Native ignores it.
onKeyDown(event: { nativeEvent: { key?: string; }; }) => void-No-