Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
24 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
98a700c
refactor(tooltip): replace cloneElement with render prop
burczu Jun 25, 2026
0b0c077
fix(tooltip): add collapsable={false} to trigger wrapper
burczu Jun 25, 2026
db39505
fix(docs): rename TooltipRich page key to RichTooltip
burczu Jun 25, 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
26 changes: 26 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,31 @@ 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={({ dismiss }) => (
<>
<Button compact onPress={dismiss}>
Learn more
</Button>
<Button compact mode="contained" onPress={dismiss}>
Add
</Button>
</>
)}
>
{(props) => <IconButton {...props} icon="plus" size={24} />}
</Tooltip.Rich>
<Tooltip.Rich content="A rich tooltip with body text only — no title or actions.">
{(props) => (
<IconButton {...props} icon="information" size={24} />
)}
</Tooltip.Rich>
</View>
</List.Section>
</ScreenWrapper>
<View style={styles.fabContainer}>
<Tooltip title="Press Me">
Expand Down
326 changes: 326 additions & 0 deletions src/components/Tooltip/RichTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
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 } 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';

/**
* Props passed to the `children` render function. Spread them onto the trigger
* element (and merge with your own handlers when you have them).
*/
export type TooltipRichTriggerProps = {
onPress?: () => void;
onHoverIn?: () => void;
onHoverOut?: () => void;
onFocus?: () => void;
onBlur?: () => void;
};

export type Props = {
/**
* Render function returning the trigger element. The provided props wire the
* tooltip's show/hide behavior and must be spread onto the returned element:
*
* ```js
* <Tooltip.Rich content="...">
* {(props) => <IconButton {...props} icon="plus" />}
* </Tooltip.Rich>
* ```
*/
children: (props: TooltipRichTriggerProps) => 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;
/**
* Render function for the action buttons (and/or links) shown in a row below
* the content. Call `dismiss` from an action to hide the tooltip:
*
* ```js
* actions={({ dismiss }) => (
* <Button onPress={() => { doThing(); dismiss(); }}>Learn more</Button>
* )}
* ```
*/
actions?: (props: { dismiss: () => void }) => 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. On web they open on hover and on keyboard focus.
*
* ## 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={({ dismiss }) => (
* <Button compact onPress={dismiss}>
* Learn more
* </Button>
* )}
* >
* {(props) => <IconButton {...props} icon="plus" />}
* </Tooltip.Rich>
* );
*
* export default MyComponent;
* ```
*/
const RichTooltip = ({
children,
title,
content,
actions,
enterTouchDelay = 100,
leaveTouchDelay = 500,
titleMaxFontSizeMultiplier,
contentMaxFontSizeMultiplier,
theme: themeOverrides,
}: 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 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]);

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.

this seems unnecessarily complex. a simpler design would clear the hide timer when the tooltip is shown again instead of tracking multiple timers.


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.
const handlePress = React.useCallback(() => {
setVisible((v) => !v);
clearShowTimers();
clearHideTimers();
}, [clearShowTimers, clearHideTimers]);

// Web: open on hover (with a short enter delay) and on keyboard focus.
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);
}, [clearHideTimers, enterTouchDelay]);

// Trigger props handed to the consumer's render function.
const triggerProps: TooltipRichTriggerProps = isWeb
? {
onHoverIn: handleHoverIn,
onHoverOut: scheduleHide,
onFocus: show,
onBlur: scheduleHide,
}
: { onPress: handlePress };

// 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"
accessibilityLabel="Close"
accessibilityHint="Dismisses the tooltip"
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),

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

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 ? (
<View style={styles.actions} testID="tooltip-rich-actions">
{actions({ dismiss: hide })}
</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}
testID="tooltip-rich-trigger"
Comment on lines +283 to +287
// On web the wrapper carries the hover/focus handlers because the
// trigger element (e.g. `IconButton`) doesn't reliably forward them.
// On mobile the press handler stays on the trigger itself (via
// `triggerProps` below) so the wrapper doesn't double-fire the toggle.
{...(isWeb ? triggerProps : null)}
>
{children(triggerProps)}
</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: {
alignSelf: 'flex-start',
...(Platform.OS === 'web' && { cursor: 'default' }),
} as ViewStyle,
});

export default RichTooltip;
Loading
Loading