Skip to content

feat(tooltip): modernize Tooltip to Material Design 3#4994

Open
burczu wants to merge 14 commits into
callstack:mainfrom
burczu:feat/tooltip-md3-modernization
Open

feat(tooltip): modernize Tooltip to Material Design 3#4994
burczu wants to merge 14 commits into
callstack:mainfrom
burczu:feat/tooltip-md3-modernization

Conversation

@burczu

@burczu burczu commented Jun 10, 2026

Copy link
Copy Markdown

Motivation

The Tooltip component 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:

  • Plain tooltip used the wrong color roles (onSurface/surface) and type style (labelLarge), and had no show/hide transition.
  • Rich tooltips (title + supporting text + actions) weren't possible at all — the title: string prop can't express them.

Related issue

Closes #4980.

What changed

  • Plain tooltip — spec fixes: container onSurfaceinverseSurface, text surfaceinverseOnSurface, type style labelLargebodySmall (12sp). Public API unchanged.
  • Fade animation: plain (and rich) tooltips now fade in/out via Reanimated, driven by the MD3 motion tokens (short3/standardDecelerate in, short2/standardAccelerate out), and honor reduce-motion.
  • New Tooltip.Rich (compound component): an optional title subhead, content (string → bodyMedium, or a custom element for inline links), and actions (button row). Rendered on a surfaceContainer Surface at 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.
  • Tokens extracted to Tooltip/tokens.ts; shared fade lifecycle extracted to a useTooltipFade hook used by both variants.
  • Example + docs: new "Rich tooltips" section in the example app; RichTooltip registered in the docs component map; JSDoc usage for both variants.

Test plan

  • yarn test — 743 passing / 170 snapshots (extended Tooltip.test.tsx with MD3 color/typography, fade mount-through-exit, and a Tooltip.Rich suite: toggle, custom content, color roles, backdrop/action dismiss, web hover open + gap-bridge).
  • yarn typescript, yarn lint — clean.
  • Verified on the iOS simulator (iPhone 17 Pro):
    • Plain: long-press an icon button → dark inverseSurface container, smaller bodySmall text, fades in/out.
    • Rich: tap + on the new "Rich tooltips" row → surfaceContainer card 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

burczu added 5 commits June 11, 2026 09:32
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.
@burczu burczu force-pushed the feat/tooltip-md3-modernization branch from cc19756 to bdcf0e5 Compare June 11, 2026 07:34
burczu added 2 commits June 15, 2026 17:15
…dernization

# Conflicts:
#	src/components/Tooltip/Tooltip.tsx
#	src/components/__tests__/Tooltip.test.tsx
@burczu burczu requested a review from satya164 June 16, 2026 08:07
Comment thread src/components/Tooltip/tokens.ts Outdated
Comment on lines +14 to +61
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;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Comment thread src/components/Tooltip/RichTooltip.tsx Outdated
Comment on lines +291 to +294
{React.cloneElement(children, {
...rest,
...(isWeb ? webPressProps : mobilePressProps),
})}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread src/components/Tooltip/RichTooltip.tsx Outdated
// wrapper, so selecting any action dismisses the tooltip.
<View
style={styles.actions}
onTouchEnd={hide}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does onTouchEnd trigger for mouse actions on web? we also need to consider keyboard on web

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/components/Tooltip/tokens.ts Outdated
container: 'surfaceContainer',
title: 'onSurface',
content: 'onSurfaceVariant',
action: 'primary',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this used?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts and useTooltipFade.

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.

Comment thread src/components/Tooltip/RichTooltip.tsx Outdated
Comment on lines +165 to +174
const handlePress = React.useCallback(() => {
if (visible) {
hide();
} else {
show();
}
if (isValidChild) {
(children.props as TooltipChildProps).onPress?.();
}
}, [visible, hide, show, isValidChild, children.props]);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +270 to +279
{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>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with an explicit dismiss() passed to the actions render function, so dismissal fires for click and keyboard, not just touch.

Comment on lines +215 to +221
<Pressable
accessibilityRole="button"
onPress={hide}
pointerEvents={visible ? 'auto' : 'none'}
style={StyleSheet.absoluteFill}
testID="tooltip-rich-backdrop"
/>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

burczu added 3 commits June 17, 2026 13:58
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
@burczu burczu requested a review from satya164 June 17, 2026 17:04
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: modernize Tooltip to the latest Material Design specs

4 participants