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 { 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>
<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 are wrapped in the variant's supporting-text styling; anything else renders as given.

Triggers

InputBehaviour
Hover inShows the tooltip. Hover out hides it
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 has to be injected into the anchor itself, so the anchor must accept onLongPress — every RootNative pressable does, and its own onLongPress still fires. 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 { 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>
<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
SubheadtitleSmall / 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.
  • Hover and long press are both pointer gestures. 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 are wrapped in the variant's supporting-text styling.
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.
onKeyDown(event: { nativeEvent: { key?: string; }; }) => void-No-