Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
744cef9
feat(fab): modernize FloatingActionButton to MD3
adrcotfas May 22, 2026
eeadb87
feat(fab): add focus ring
adrcotfas May 27, 2026
6a3310f
fix: review findings
adrcotfas Jun 3, 2026
dd1297d
feat(tooltip): align plain tooltip colors and typography with MD3
burczu Jun 10, 2026
9079c49
feat(tooltip): add fade enter/exit animation
burczu Jun 10, 2026
793d397
feat(tooltip): add rich tooltip variant
burczu Jun 10, 2026
d52cbcc
docs(tooltip): showcase rich tooltip and document both variants
burczu Jun 10, 2026
bdcf0e5
refactor(tooltip): extract shared useTooltipFade hook
burczu Jun 10, 2026
d9a0ab9
Merge remote-tracking branch 'origin/main' into feat/tooltip-md3-mode…
burczu Jun 15, 2026
4da1478
Merge remote-tracking branch 'upstream/main' into feat/tooltip-md3-mo…
burczu Jun 15, 2026
c1fe84c
refactor(tooltip): drop redundant token type annotations
burczu Jun 17, 2026
29fb3d1
refactor(tooltip): render-prop API for Tooltip.Rich
burczu Jun 17, 2026
a1aeb7f
fix(tooltip): label the rich tooltip dismiss backdrop
burczu Jun 17, 2026
525c09b
fix(tooltip): open Rich tooltip on web regardless of trigger
burczu Jun 19, 2026
0ad21ed
Merge branch 'main' into feat/tooltip-md3-modernization
burczu Jun 23, 2026
a7e1e31
refactor(tooltip): derive mount during render, not in an effect
burczu Jun 23, 2026
2f8af9e
refactor(tooltip): measure the trigger in useLayoutEffect
burczu Jun 23, 2026
fb44370
feat(tooltip): replace Reanimated shared values with CSS transitions
burczu Jun 24, 2026
c58fc38
refactor(tooltip): replace isWeb with inline Platform.OS checks
burczu Jun 24, 2026
32b2042
refactor(tooltip): replace timer arrays with single nullable refs
burczu Jun 24, 2026
1b6ba12
refactor(tooltip): remove as type casts
burczu Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ const config = {
},
Tooltip: {
Tooltip: 'Tooltip/Tooltip',
TooltipRich: 'Tooltip/RichTooltip',
},
Comment on lines 181 to 184
TouchableRipple: {
TouchableRipple: 'TouchableRipple/TouchableRipple',
Expand Down
24 changes: 24 additions & 0 deletions example/src/Examples/TooltipExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Appbar,
Avatar,
Banner,
Button,
Chip,
FAB,
IconButton,
Expand Down Expand Up @@ -146,6 +147,29 @@ const TooltipExample = () => {
</Card>
</Tooltip>
</List.Section>
<List.Section title="Rich tooltips">
<View style={styles.iconButtonContainer}>
<Tooltip.Rich
title="Add to library"
content="Save this item to read it later from any of your devices."
actions={
<>
<Button compact onPress={() => {}}>
Learn more
</Button>
<Button compact mode="contained" onPress={() => {}}>
Add
</Button>
</>
}
>
<IconButton icon="plus" size={24} onPress={() => {}} />
</Tooltip.Rich>
<Tooltip.Rich content="A rich tooltip with body text only — no title or actions.">
<IconButton icon="information" size={24} onPress={() => {}} />
</Tooltip.Rich>
</View>
</List.Section>
</ScreenWrapper>
<View style={styles.fabContainer}>
<Tooltip title="Press Me">
Expand Down
321 changes: 321 additions & 0 deletions src/components/Tooltip/RichTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
import * as React from 'react';
import {
Dimensions,
View,
StyleSheet,
Platform,
Pressable,
} from 'react-native';
import type { ViewStyle } from 'react-native';

import Animated from 'react-native-reanimated';

import { useTooltipFade } from './hooks';
import { Tokens } from './tokens';
import { getTooltipPosition } from './utils';
import type { Measurement, TooltipChildProps } from './utils';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp } from '../../types';
import { addEventListener } from '../../utils/addEventListener';
import Portal from '../Portal/Portal';
import Surface from '../Surface';
import Text from '../Typography/Text';

export type Props = {
/**
* Tooltip reference element. Needs to be able to hold a ref.
*/
children: React.ReactElement;
/**
* Optional subhead shown above the content.
*/
title?: string;
/**
* Supporting body text. A string is rendered with the `bodyMedium` type
* style; pass an element to compose inline links or custom content.
*/
content: string | React.ReactElement;
/**
* Action buttons (and/or links) rendered in a row below the content.
* Pressing one dismisses the tooltip.
*/
actions?: React.ReactNode;
/**
* The number of milliseconds a user must hover the element before showing
* the tooltip (web only).
*/
enterTouchDelay?: number;
/**
* The number of milliseconds after the pointer leaves both the trigger and
* the tooltip before hiding it (web only).
*/
leaveTouchDelay?: number;
/**
* Specifies the largest possible scale the title font can reach.
*/
titleMaxFontSizeMultiplier?: number;
/**
* Specifies the largest possible scale the content font can reach.
*/
contentMaxFontSizeMultiplier?: number;
/**
* @optional
*/
theme?: ThemeProp;
};

/**
* Rich tooltips display informative text along with an optional subhead and
* action buttons. Unlike plain tooltips they are persistent and interactive:
* tap the element to toggle the tooltip, then tap outside or an action to
* dismiss it.
*
* ## Usage
* ```js
* import * as React from 'react';
* import { Button, IconButton, Tooltip } from 'react-native-paper';
*
* const MyComponent = () => (
* <Tooltip.Rich
* title="Add to library"
* content="Save this item to read it later."
* actions={<Button compact>Learn more</Button>}
* >
* <IconButton icon="plus" onPress={() => {}} />
* </Tooltip.Rich>
* );
*
* export default MyComponent;
* ```
*/
const RichTooltip = ({
children,
title,
content,
actions,
enterTouchDelay = 100,
leaveTouchDelay = 500,
titleMaxFontSizeMultiplier,
contentMaxFontSizeMultiplier,
theme: themeOverrides,
...rest
}: Props) => {
const isWeb = Platform.OS === 'web';

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.

don't use isWeb. inline Platform.OS checks so the bundler can tree-shake unused platform code.


const theme = useInternalTheme(themeOverrides);
// `visible` is the show/hide intent; the fade hook keeps the tooltip mounted
// through the exit animation and owns the measurement + opacity.
const [visible, setVisible] = React.useState(false);
const { rendered, measurement, animatedStyle, onLayout, childrenWrapperRef } =
useTooltipFade(theme, visible);

const showTooltipTimer = React.useRef<NodeJS.Timeout[]>([]);
const hideTooltipTimer = React.useRef<NodeJS.Timeout[]>([]);

const isValidChild = React.useMemo(
() => React.isValidElement<TooltipChildProps>(children),
[children]
);

const clearShowTimers = React.useCallback(() => {
showTooltipTimer.current.forEach((t) => clearTimeout(t));
showTooltipTimer.current = [];
}, []);

const clearHideTimers = React.useCallback(() => {
hideTooltipTimer.current.forEach((t) => clearTimeout(t));
hideTooltipTimer.current = [];
}, []);

React.useEffect(() => {
return () => {
clearShowTimers();
clearHideTimers();
};
}, [clearShowTimers, clearHideTimers]);

React.useEffect(() => {
const subscription = addEventListener(Dimensions, 'change', () =>
setVisible(false)
);

return () => subscription.remove();
}, []);

const show = React.useCallback(() => {
clearHideTimers();
setVisible(true);
}, [clearHideTimers]);

const hide = React.useCallback(() => {
clearShowTimers();
setVisible(false);
}, [clearShowTimers]);

const scheduleHide = React.useCallback(() => {
clearShowTimers();
const id = setTimeout(
() => setVisible(false),
leaveTouchDelay
) as unknown as NodeJS.Timeout;

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.

don't use as. use proper types for the ref

hideTooltipTimer.current.push(id);
}, [clearShowTimers, leaveTouchDelay]);

// Mobile: a tap toggles the tooltip and still forwards the child's onPress.
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.


// Web: open on hover, with a short enter delay.
const handleHoverIn = React.useCallback(() => {
clearHideTimers();
const id = setTimeout(
() => setVisible(true),
enterTouchDelay
) as unknown as NodeJS.Timeout;

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.

don't use as. use proper types for the ref

showTooltipTimer.current.push(id);
if (isValidChild) {
(children.props as TooltipChildProps).onHoverIn?.();
}
}, [clearHideTimers, enterTouchDelay, isValidChild, children.props]);

const handleHoverOut = React.useCallback(() => {
scheduleHide();
if (isValidChild) {
(children.props as TooltipChildProps).onHoverOut?.();
}
}, [scheduleHide, isValidChild, children.props]);

const mobilePressProps = {
onPress: handlePress,
};

const webPressProps = {
onHoverIn: handleHoverIn,
onHoverOut: handleHoverOut,
};

// Web only: keep the tooltip open while the pointer travels from the trigger
// into the tooltip (and re-schedule the hide once it leaves the tooltip).
const tooltipHoverProps = isWeb
? { onHoverIn: clearHideTimers, onHoverOut: scheduleHide }
: {};

return (
<>
{rendered && (
<Portal>
<Pressable
accessibilityRole="button"
onPress={hide}
pointerEvents={visible ? 'auto' : 'none'}
style={StyleSheet.absoluteFill}
testID="tooltip-rich-backdrop"
/>
Comment on lines +219 to +227

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.

<Animated.View
onLayout={onLayout}
style={[
styles.container,
getTooltipPosition(
measurement as Measurement,
children as React.ReactElement<TooltipChildProps>
),
animatedStyle,
]}
testID="tooltip-rich-container"
>
<Pressable {...tooltipHoverProps} testID="tooltip-rich-surface">
<Surface
elevation={Tokens.rich.elevation}
testID="tooltip-rich-surface-container"
style={[
styles.surface,
{
backgroundColor: theme.colors[Tokens.rich.container],
borderRadius: theme.shapes.corner[Tokens.rich.shape],
},
]}
>
{title ? (
<Text
accessibilityLiveRegion="polite"
selectable={false}
variant={Tokens.rich.titleTypescale}
style={{ color: theme.colors[Tokens.rich.title] }}
maxFontSizeMultiplier={titleMaxFontSizeMultiplier}
>
{title}
</Text>
) : null}
{typeof content === 'string' ? (
<Text
accessibilityLiveRegion="polite"
selectable={false}
variant={Tokens.rich.contentTypescale}
style={{ color: theme.colors[Tokens.rich.content] }}
maxFontSizeMultiplier={contentMaxFontSizeMultiplier}
>
{content}
</Text>
) : (
content
)}
{actions ? (
// `onTouchEnd` bubbles from the pressed action up to this
// 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.

testID="tooltip-rich-actions"
>
{actions}
</View>
Comment on lines +273 to +276

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.

) : null}
</Surface>
</Pressable>
</Animated.View>
</Portal>
)}
<Pressable
ref={childrenWrapperRef}
style={styles.pressContainer}
{...(isWeb ? webPressProps : mobilePressProps)}
>
{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?

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.

I scoped this to Tooltip.Rich and left the plain Tooltip on cloneElement since you mentioned that's being revisited separately

if you're refactoring the component already, then update it here. by "existing components" i mean other components that you're not working on.

</Pressable>
</>
);
};

RichTooltip.displayName = 'Tooltip.Rich';

const styles = StyleSheet.create({
container: {
alignSelf: 'flex-start',
maxWidth: Tokens.rich.maxWidth,
},
surface: {
paddingHorizontal: Tokens.rich.paddingHorizontal,
paddingVertical: Tokens.rich.paddingVertical,
rowGap: Tokens.rich.gap,
},
actions: {
flexDirection: 'row',
flexWrap: 'wrap',
},
pressContainer: {
...(Platform.OS === 'web' && { cursor: 'default' }),
} as ViewStyle,
});

export default RichTooltip;
Loading
Loading