diff --git a/src/App.native.tsx b/src/App.native.tsx
index 81d4a870e9..c42b11746f 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -33,6 +33,7 @@ import {
ensureGeolocationResolved,
Provider as GeolocationProvider,
} from '#/state/geolocation'
+import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
@@ -154,11 +155,13 @@ function InnerApp() {
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 79ec41679a..440ac16ac9 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -983,6 +983,10 @@ export const atoms = {
transition_none: web({
transitionProperty: 'none',
}),
+ transition_timing_default: web({
+ transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
+ transitionDuration: '100ms',
+ }),
transition_all: web({
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
diff --git a/src/components/Tooltip/const.ts b/src/components/Tooltip/const.ts
new file mode 100644
index 0000000000..cad7e0106c
--- /dev/null
+++ b/src/components/Tooltip/const.ts
@@ -0,0 +1,6 @@
+import {atoms as a} from '#/alf'
+
+export const BUBBLE_MAX_WIDTH = 240
+export const ARROW_SIZE = 12
+export const ARROW_HALF_SIZE = ARROW_SIZE / 2
+export const MIN_EDGE_SPACE = a.px_lg.paddingLeft
diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx
new file mode 100644
index 0000000000..446cf18fc6
--- /dev/null
+++ b/src/components/Tooltip/index.tsx
@@ -0,0 +1,411 @@
+import {
+ Children,
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import {useWindowDimensions, View} from 'react-native'
+import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+
+import {atoms as a, select, useTheme} from '#/alf'
+import {useOnGesture} from '#/components/hooks/useOnGesture'
+import {Portal} from '#/components/Portal'
+import {
+ ARROW_HALF_SIZE,
+ ARROW_SIZE,
+ BUBBLE_MAX_WIDTH,
+ MIN_EDGE_SPACE,
+} from '#/components/Tooltip/const'
+import {Text} from '#/components/Typography'
+
+/**
+ * These are native specific values, not shared with web
+ */
+const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target
+const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip
+
+type TooltipContextType = {
+ position: 'top' | 'bottom'
+ ready: boolean
+ onVisibleChange: (visible: boolean) => void
+}
+
+type TargetContextType = {
+ targetMeasurements:
+ | {
+ x: number
+ y: number
+ width: number
+ height: number
+ }
+ | undefined
+ targetRef: React.RefObject
+}
+
+const TooltipContext = createContext({
+ position: 'bottom',
+ ready: false,
+ onVisibleChange: () => {},
+})
+
+const TargetContext = createContext({
+ targetMeasurements: undefined,
+ targetRef: {current: null},
+})
+
+export function Outer({
+ children,
+ position = 'bottom',
+ visible: requestVisible,
+ onVisibleChange,
+}: {
+ children: React.ReactNode
+ position?: 'top' | 'bottom'
+ visible: boolean
+ onVisibleChange: (visible: boolean) => void
+}) {
+ /**
+ * Whether we have measured the target and are ready to show the tooltip.
+ */
+ const [ready, setReady] = useState(false)
+ /**
+ * Lagging state to track the externally-controlled visibility of the
+ * tooltip.
+ */
+ const [prevRequestVisible, setPrevRequestVisible] = useState<
+ boolean | undefined
+ >()
+ /**
+ * Needs to reference the element this Tooltip is attached to.
+ */
+ const targetRef = useRef(null)
+ const [targetMeasurements, setTargetMeasurements] = useState<
+ | {
+ x: number
+ y: number
+ width: number
+ height: number
+ }
+ | undefined
+ >(undefined)
+
+ if (requestVisible && !prevRequestVisible) {
+ setPrevRequestVisible(true)
+
+ if (targetRef.current) {
+ /*
+ * Once opened, measure the dimensions and position of the target
+ */
+ targetRef.current.measure((_x, _y, width, height, pageX, pageY) => {
+ if (pageX !== undefined && pageY !== undefined && width && height) {
+ setTargetMeasurements({x: pageX, y: pageY, width, height})
+ setReady(true)
+ }
+ })
+ }
+ } else if (!requestVisible && prevRequestVisible) {
+ setPrevRequestVisible(false)
+ setTargetMeasurements(undefined)
+ setReady(false)
+ }
+
+ const ctx = useMemo(
+ () => ({position, ready, onVisibleChange}),
+ [position, ready, onVisibleChange],
+ )
+ const targetCtx = useMemo(
+ () => ({targetMeasurements, targetRef}),
+ [targetMeasurements, targetRef],
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function Target({children}: {children: React.ReactNode}) {
+ const {targetRef} = useContext(TargetContext)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function Content({
+ children,
+ label,
+}: {
+ children: React.ReactNode
+ label: string
+}) {
+ const {position, ready, onVisibleChange} = useContext(TooltipContext)
+ const {targetMeasurements} = useContext(TargetContext)
+ const requestClose = useCallback(() => {
+ onVisibleChange(false)
+ }, [onVisibleChange])
+
+ if (!ready || !targetMeasurements) return null
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function Bubble({
+ children,
+ label,
+ position,
+ requestClose,
+ targetMeasurements,
+}: {
+ children: React.ReactNode
+ label: string
+ position: TooltipContextType['position']
+ requestClose: () => void
+ targetMeasurements: Exclude<
+ TargetContextType['targetMeasurements'],
+ undefined
+ >
+}) {
+ const t = useTheme()
+ const insets = useSafeAreaInsets()
+ const dimensions = useWindowDimensions()
+ const [bubbleMeasurements, setBubbleMeasurements] = useState<
+ | {
+ width: number
+ height: number
+ }
+ | undefined
+ >(undefined)
+ const coords = useMemo(() => {
+ if (!bubbleMeasurements)
+ return {
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ tipTop: 0,
+ tipLeft: 0,
+ }
+
+ const {width: ww, height: wh} = dimensions
+ const maxTop = insets.top
+ const maxBottom = wh - insets.bottom
+ const {width: cw, height: ch} = bubbleMeasurements
+ const minLeft = MIN_EDGE_SPACE
+ const maxLeft = ww - minLeft
+
+ let computedPosition: 'top' | 'bottom' = position
+ let top = targetMeasurements.y + targetMeasurements.height
+ let left = Math.max(
+ minLeft,
+ targetMeasurements.x + targetMeasurements.width / 2 - cw / 2,
+ )
+ const tipTranslate = ARROW_HALF_SIZE * -1
+ let tipTop = tipTranslate
+
+ if (left + cw > maxLeft) {
+ left -= left + cw - maxLeft
+ }
+
+ let tipLeft =
+ targetMeasurements.x -
+ left +
+ targetMeasurements.width / 2 -
+ ARROW_HALF_SIZE
+
+ let bottom = top + ch
+
+ function positionTop() {
+ top = top - ch - targetMeasurements.height
+ bottom = top + ch
+ tipTop = tipTop + ch
+ computedPosition = 'top'
+ }
+
+ function positionBottom() {
+ top = targetMeasurements.y + targetMeasurements.height
+ bottom = top + ch
+ tipTop = tipTranslate
+ computedPosition = 'bottom'
+ }
+
+ if (position === 'top') {
+ positionTop()
+ if (top < maxTop) {
+ positionBottom()
+ }
+ } else {
+ if (bottom > maxBottom) {
+ positionTop()
+ }
+ }
+
+ if (computedPosition === 'bottom') {
+ top += ARROW_VISUAL_OFFSET
+ bottom += ARROW_VISUAL_OFFSET
+ } else {
+ top -= ARROW_VISUAL_OFFSET
+ bottom -= ARROW_VISUAL_OFFSET
+ }
+
+ return {
+ computedPosition,
+ top,
+ bottom,
+ left,
+ right: left + cw,
+ tipTop,
+ tipLeft,
+ }
+ }, [position, targetMeasurements, bubbleMeasurements, insets, dimensions])
+
+ const requestCloseWrapped = useCallback(() => {
+ setBubbleMeasurements(undefined)
+ requestClose()
+ }, [requestClose])
+
+ useOnGesture(
+ useCallback(
+ e => {
+ const {x, y} = e
+ const isInside =
+ x > coords.left &&
+ x < coords.right &&
+ y > coords.top &&
+ y < coords.bottom
+
+ if (!isInside) {
+ requestCloseWrapped()
+ }
+ },
+ [coords, requestCloseWrapped],
+ ),
+ )
+
+ return (
+
+
+
+ {
+ setBubbleMeasurements({
+ width: e.nativeEvent.layout.width,
+ height: e.nativeEvent.layout.height,
+ })
+ }}>
+ {children}
+
+
+
+ )
+}
+
+function oppposite(position: 'top' | 'bottom') {
+ switch (position) {
+ case 'top':
+ return 'center bottom'
+ case 'bottom':
+ return 'center top'
+ default:
+ return 'center'
+ }
+}
+
+export function TextBubble({children}: {children: React.ReactNode}) {
+ const c = Children.toArray(children)
+ return (
+
+
+ {c.map((child, i) => (
+
+ {child}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/Tooltip/index.web.tsx b/src/components/Tooltip/index.web.tsx
new file mode 100644
index 0000000000..739a714cd4
--- /dev/null
+++ b/src/components/Tooltip/index.web.tsx
@@ -0,0 +1,112 @@
+import {Children, createContext, useContext, useMemo} from 'react'
+import {View} from 'react-native'
+import {Popover} from 'radix-ui'
+
+import {atoms as a, flatten, select, useTheme} from '#/alf'
+import {transparentifyColor} from '#/alf/util/colorGeneration'
+import {
+ ARROW_SIZE,
+ BUBBLE_MAX_WIDTH,
+ MIN_EDGE_SPACE,
+} from '#/components/Tooltip/const'
+import {Text} from '#/components/Typography'
+
+type TooltipContextType = {
+ position: 'top' | 'bottom'
+}
+
+const TooltipContext = createContext({
+ position: 'bottom',
+})
+
+export function Outer({
+ children,
+ position = 'bottom',
+ visible,
+ onVisibleChange,
+}: {
+ children: React.ReactNode
+ position?: 'top' | 'bottom'
+ visible: boolean
+ onVisibleChange: (visible: boolean) => void
+}) {
+ const ctx = useMemo(() => ({position}), [position])
+ return (
+
+ {children}
+
+ )
+}
+
+export function Target({children}: {children: React.ReactNode}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function Content({
+ children,
+ label,
+}: {
+ children: React.ReactNode
+ label: string
+}) {
+ const t = useTheme()
+ const {position} = useContext(TooltipContext)
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+export function TextBubble({children}: {children: React.ReactNode}) {
+ const c = Children.toArray(children)
+ return (
+
+
+ {c.map((child, i) => (
+
+ {child}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/hooks/useOnGesture/index.ts b/src/components/hooks/useOnGesture/index.ts
new file mode 100644
index 0000000000..6f05606612
--- /dev/null
+++ b/src/components/hooks/useOnGesture/index.ts
@@ -0,0 +1,24 @@
+import {useEffect} from 'react'
+
+import {
+ type GlobalGestureEvents,
+ useGlobalGestureEvents,
+} from '#/state/global-gesture-events'
+
+/**
+ * Listen for global gesture events. Callback should be wrapped with
+ * `useCallback` or otherwise memoized to avoid unnecessary re-renders.
+ */
+export function useOnGesture(
+ onGestureCallback: (e: GlobalGestureEvents['begin']) => void,
+) {
+ const ctx = useGlobalGestureEvents()
+ useEffect(() => {
+ ctx.register()
+ ctx.events.on('begin', onGestureCallback)
+ return () => {
+ ctx.unregister()
+ ctx.events.off('begin', onGestureCallback)
+ }
+ }, [ctx, onGestureCallback])
+}
diff --git a/src/components/hooks/useOnGesture/index.web.ts b/src/components/hooks/useOnGesture/index.web.ts
new file mode 100644
index 0000000000..6129fde106
--- /dev/null
+++ b/src/components/hooks/useOnGesture/index.web.ts
@@ -0,0 +1 @@
+export function useOnGesture() {}
diff --git a/src/state/global-gesture-events/index.tsx b/src/state/global-gesture-events/index.tsx
new file mode 100644
index 0000000000..f8d87a8f93
--- /dev/null
+++ b/src/state/global-gesture-events/index.tsx
@@ -0,0 +1,83 @@
+import {createContext, useContext, useMemo, useRef, useState} from 'react'
+import {View} from 'react-native'
+import {
+ Gesture,
+ GestureDetector,
+ type GestureStateChangeEvent,
+ type GestureUpdateEvent,
+ type PanGestureHandlerEventPayload,
+} from 'react-native-gesture-handler'
+import EventEmitter from 'eventemitter3'
+
+export type GlobalGestureEvents = {
+ begin: GestureStateChangeEvent
+ update: GestureUpdateEvent
+ end: GestureStateChangeEvent
+ finalize: GestureStateChangeEvent
+}
+
+const Context = createContext<{
+ events: EventEmitter
+ register: () => void
+ unregister: () => void
+}>({
+ events: new EventEmitter(),
+ register: () => {},
+ unregister: () => {},
+})
+
+export function GlobalGestureEventsProvider({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ const refCount = useRef(0)
+ const events = useMemo(() => new EventEmitter(), [])
+ const [enabled, setEnabled] = useState(false)
+ const ctx = useMemo(
+ () => ({
+ events,
+ register() {
+ refCount.current += 1
+ if (refCount.current === 1) {
+ setEnabled(true)
+ }
+ },
+ unregister() {
+ refCount.current -= 1
+ if (refCount.current === 0) {
+ setEnabled(false)
+ }
+ },
+ }),
+ [events, setEnabled],
+ )
+ const gesture = Gesture.Pan()
+ .runOnJS(true)
+ .enabled(enabled)
+ .simultaneousWithExternalGesture()
+ .onBegin(e => {
+ events.emit('begin', e)
+ })
+ .onUpdate(e => {
+ events.emit('update', e)
+ })
+ .onEnd(e => {
+ events.emit('end', e)
+ })
+ .onFinalize(e => {
+ events.emit('finalize', e)
+ })
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function useGlobalGestureEvents() {
+ return useContext(Context)
+}
diff --git a/src/state/global-gesture-events/index.web.tsx b/src/state/global-gesture-events/index.web.tsx
new file mode 100644
index 0000000000..5d6d53369c
--- /dev/null
+++ b/src/state/global-gesture-events/index.web.tsx
@@ -0,0 +1,9 @@
+export function GlobalGestureEventsProvider(_props: {
+ children: React.ReactNode
+}) {
+ throw new Error('GlobalGestureEventsProvider is not supported on web.')
+}
+
+export function useGlobalGestureEvents() {
+ throw new Error('useGlobalGestureEvents is not supported on web.')
+}
diff --git a/src/style.css b/src/style.css
index a4a2c455f7..35ffe0d3a2 100644
--- a/src/style.css
+++ b/src/style.css
@@ -334,3 +334,38 @@ input[type='range'][orient='vertical']::-moz-range-thumb {
min-width: var(--radix-select-trigger-width);
max-height: var(--radix-select-content-available-height);
}
+
+/*
+ * #/components/Tooltip/index.web.tsx
+ */
+.radix-popover-content {
+ animation-duration: 300ms;
+ animation-timing-function: cubic-bezier(0.17, 0.73, 0.14, 1);
+ will-change: transform, opacity;
+}
+.radix-popover-content[data-state='open'][data-side='top'] {
+ animation-name: radixPopoverSlideUpAndFade;
+}
+.radix-popover-content[data-state='open'][data-side='bottom'] {
+ animation-name: radixPopoverSlideDownAndFade;
+}
+@keyframes radixPopoverSlideUpAndFade {
+ from {
+ opacity: 0;
+ transform: translateY(2px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+@keyframes radixPopoverSlideDownAndFade {
+ from {
+ opacity: 0;
+ transform: translateY(-2px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}