feat(tooltip): modernize Tooltip to Material Design 3#4994
Conversation
Container now uses inverseSurface and text uses inverseOnSurface per the MD3 tooltip spec (previously onSurface/surface). Text variant changes from labelLarge to bodySmall (12sp). Tokens are extracted into a new tokens.ts following the FAB pattern; the rich and motion token sets land here too and are consumed in later commits.
The plain tooltip now fades in on show and out on hide using Reanimated, per the MD3 motion spec (enter short3/standardDecelerate, exit short2/standardAccelerate). Show/hide intent (visible) is split from mount state (rendered) so the tooltip stays mounted through the exit fade before unmounting. Honors reduce-motion via useReduceMotion.
Adds Tooltip.Rich, a persistent, interactive rich tooltip per the MD3 spec: an optional subhead title, supporting body text (string or element) and a row of action buttons on a surfaceContainer surface at elevation level 2 with a 12dp corner. Exposed as a compound component (Object.assign) so the plain Tooltip stays untouched. Uncontrolled tap-to-toggle: tapping the trigger toggles it, tapping the Portal backdrop or selecting an action dismisses it. On web it opens on hover and bridges the trigger-to-tooltip gap before hiding. Reuses the plain tooltip's Reanimated fade and reduce-motion handling.
Adds a 'Rich tooltips' section to the example app (full title/content/actions variant plus a body-only one), registers the RichTooltip page in the docs component map so the generated docs cover Tooltip.Rich, and cross-references the rich variant from the plain Tooltip JSDoc.
The fade lifecycle (mount-through-exit, opacity, measurement, motion configs, reduce-motion) was duplicated between Tooltip and Tooltip.Rich. Extract it into a useTooltipFade hook so both variants share one implementation. No behavior change.
cc19756 to
bdcf0e5
Compare
…dernization # Conflicts: # src/components/Tooltip/Tooltip.tsx # src/components/__tests__/Tooltip.test.tsx
| const plain = { | ||
| container: 'inverseSurface', | ||
| content: 'inverseOnSurface', | ||
| shape: 'extraSmall', | ||
| height: 32, | ||
| paddingHorizontal: 16, | ||
| typescale: 'bodySmall', | ||
| } as const satisfies { | ||
| container: ColorRole; | ||
| content: ColorRole; | ||
| shape: ShapeKey; | ||
| height: number; | ||
| paddingHorizontal: number; | ||
| typescale: TypescaleKey; | ||
| }; | ||
|
|
||
| /** | ||
| * Rich tooltip — an optional subhead, supporting text and action buttons on a | ||
| * surface-container container at elevation level 2. | ||
| * https://m3.material.io/components/tooltips/specs#8e6cf915 | ||
| */ | ||
| const rich = { | ||
| container: 'surfaceContainer', | ||
| title: 'onSurface', | ||
| content: 'onSurfaceVariant', | ||
| action: 'primary', | ||
| shape: 'medium', | ||
| elevation: 2, | ||
| maxWidth: 312, | ||
| paddingHorizontal: 16, | ||
| paddingVertical: 12, | ||
| titleTypescale: 'titleSmall', | ||
| contentTypescale: 'bodyMedium', | ||
| gap: 4, | ||
| } as const satisfies { | ||
| container: ColorRole; | ||
| title: ColorRole; | ||
| content: ColorRole; | ||
| action: ColorRole; | ||
| shape: ShapeKey; | ||
| elevation: Elevation; | ||
| maxWidth: number; | ||
| paddingHorizontal: number; | ||
| paddingVertical: number; | ||
| titleTypescale: TypescaleKey; | ||
| contentTypescale: TypescaleKey; | ||
| gap: number; | ||
| }; |
There was a problem hiding this comment.
Doesn't seem like these satisfies are necessary. As these types aren't used anywhere else, it's essentially duplicating the structure of the object for not much. If the goal is to typecheck surfaceContainer, onSurface etc., I assume they are already checked at usage site.
| {React.cloneElement(children, { | ||
| ...rest, | ||
| ...(isWeb ? webPressProps : mobilePressProps), | ||
| })} |
There was a problem hiding this comment.
The API/implementation needs to change to avoid React.cloneElement usage (or reading children directly, as they are not type safe and hurt composition.
It is used in existing components, but that's something we're revisiting.
There can be various approaches:
- split the component into parts that the user should render themselves and compose together
- accept render prop instead of children prop
- accept objects instead of react elements
Consider them based on how the final API may look like and what's most ergonomic as well as flexible.
There was a problem hiding this comment.
Went with the render-prop approach — most ergonomic and flexible of the options, and the only one that actually removes the cloneElement/children.props reading rather than relocating it (a Tooltip.Trigger part would still need to clone its child internally). The trigger handlers and the actions' dismiss are handed to the consumer to compose, so it's type-safe and ref-free.
I scoped this to Tooltip.Rich and left the plain Tooltip on cloneElement since you mentioned that's being revisited separately — do you want me to revisit it in this PR too, or keep it separate?
| // wrapper, so selecting any action dismisses the tooltip. | ||
| <View | ||
| style={styles.actions} | ||
| onTouchEnd={hide} |
There was a problem hiding this comment.
does onTouchEnd trigger for mouse actions on web? we also need to consider keyboard on web
There was a problem hiding this comment.
Reworked: actions now dismiss via an explicit dismiss() callback instead of onTouchEnd, so it works for press/click/keyboard everywhere. Web also wires onFocus/onBlur now, so the tooltip opens on keyboard focus too.
| container: 'surfaceContainer', | ||
| title: 'onSurface', | ||
| content: 'onSurfaceVariant', | ||
| action: 'primary', |
There was a problem hiding this comment.
Pull request overview
This PR modernizes the Tooltip component to align with the Material Design 3 tooltip spec, adds the missing “rich” tooltip variant, and introduces an MD3 motion-token-driven fade in/out transition shared by both variants.
Changes:
- Updated plain tooltip styling to MD3 roles/typescale and replaced instantaneous show/hide with a fade transition.
- Added
Tooltip.Rich(compound component) supporting optional title, rich content, and actions, with web hover behavior. - Extracted shared constants and behavior into
Tooltip/tokens.tsanduseTooltipFade.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/index.tsx | Re-exports Tooltip from the new components/Tooltip entry and exposes TooltipRichProps. |
| src/components/Tooltip/Tooltip.tsx | Plain tooltip updated to MD3 tokens + shared fade lifecycle via useTooltipFade. |
| src/components/Tooltip/tokens.ts | Adds MD3 color/shape/typescale/motion token definitions for plain + rich tooltips. |
| src/components/Tooltip/RichTooltip.tsx | Implements the new rich tooltip variant, including backdrop dismissal and web hover behavior. |
| src/components/Tooltip/index.tsx | Creates the compound component export (Tooltip.Rich). |
| src/components/Tooltip/hooks.ts | Adds useTooltipFade to keep tooltips mounted through exit fade and avoid “flash at wrong position”. |
| src/components/tests/Tooltip.test.tsx | Extends tests for MD3 styling, fade lifecycle, and Tooltip.Rich behaviors. |
| example/src/Examples/TooltipExample.tsx | Adds a “Rich tooltips” example section demonstrating title/content/actions variants. |
| docs/docusaurus.config.js | Registers TooltipRich for docs component mapping. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const handlePress = React.useCallback(() => { | ||
| if (visible) { | ||
| hide(); | ||
| } else { | ||
| show(); | ||
| } | ||
| if (isValidChild) { | ||
| (children.props as TooltipChildProps).onPress?.(); | ||
| } | ||
| }, [visible, hide, show, isValidChild, children.props]); |
There was a problem hiding this comment.
No longer read or forward the child's onPress — the consumer owns the trigger element and spreads the provided handlers, so a disabled trigger doesn't fire. No more divergence from the plain tooltip.
| {actions ? ( | ||
| // `onTouchEnd` bubbles from the pressed action up to this | ||
| // wrapper, so selecting any action dismisses the tooltip. | ||
| <View | ||
| style={styles.actions} | ||
| onTouchEnd={hide} | ||
| testID="tooltip-rich-actions" | ||
| > | ||
| {actions} | ||
| </View> |
There was a problem hiding this comment.
Replaced with an explicit dismiss() passed to the actions render function, so dismissal fires for click and keyboard, not just touch.
| <Pressable | ||
| accessibilityRole="button" | ||
| onPress={hide} | ||
| pointerEvents={visible ? 'auto' : 'none'} | ||
| style={StyleSheet.absoluteFill} | ||
| testID="tooltip-rich-backdrop" | ||
| /> |
Remove the `satisfies` blocks duplicating the token object shapes (the color roles are already type-checked at the usage sites) and the unused `action` color token. Re callstack#4994
Replace React.cloneElement and children.props reading with a render-prop trigger and a render-prop `actions` that receives a `dismiss` callback. This is type-safe and composable, lets the consumer own the trigger element (so disabled state isn't forced), wires web hover + keyboard focus, and removes the onTouchEnd-based action dismissal that didn't fire for click/keyboard. Re callstack#4994
Give the full-screen backdrop an accessibilityLabel and hint so screen reader users understand it dismisses the tooltip. Re callstack#4994
The render-prop handed hover/focus handlers to the trigger element, but triggers like IconButton don't forward onHoverIn/onHoverOut on web, so rich tooltips never opened on hover. Carry the handlers on the wrapper (web only) like the plain Tooltip does; keep press on the trigger for mobile so it doesn't double-fire. Add a regression test with a non-forwarding trigger.
Motivation
The
Tooltipcomponent had drifted from the current Material Design 3 tooltip spec, and only supported the plain (text-only) variant. As part of the v6 MD3 modernization effort (alongside TextInput, Switch, Checkbox, FAB), this brings the tooltip up to spec and adds the missing rich variant:onSurface/surface) and type style (labelLarge), and had no show/hide transition.title: stringprop can't express them.Related issue
Closes #4980.
What changed
onSurface→inverseSurface, textsurface→inverseOnSurface, type stylelabelLarge→bodySmall(12sp). Public API unchanged.short3/standardDeceleratein,short2/standardAccelerateout), and honor reduce-motion.Tooltip.Rich(compound component): an optionaltitlesubhead,content(string →bodyMedium, or a custom element for inline links), andactions(button row). Rendered on asurfaceContainerSurfaceat elevation level 2 with a 12dp corner and 312dp max-width. Uncontrolled tap-to-toggle; dismisses on outside tap, action press, or re-tap; on web it opens on hover and bridges the trigger→tooltip gap.Tooltip/tokens.ts; shared fade lifecycle extracted to auseTooltipFadehook used by both variants.RichTooltipregistered in the docs component map; JSDoc usage for both variants.Test plan
yarn test— 743 passing / 170 snapshots (extendedTooltip.test.tsxwith MD3 color/typography, fade mount-through-exit, and aTooltip.Richsuite: toggle, custom content, color roles, backdrop/action dismiss, web hover open + gap-bridge).yarn typescript,yarn lint— clean.inverseSurfacecontainer, smallerbodySmalltext, fades in/out.+on the new "Rich tooltips" row →surfaceContainercard with title/body/actions; dismisses on outside-tap, action press, or re-tap; info icon shows a body-only variant.Videos
tooltip_ios.mp4
tooltip_android.mp4
tooltip_web.mp4