Kinetik UI

Tip Bubble

.md

A short tip that grows out of its anchor from the arrow's point and closes by itself after a few seconds.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add tip-bubble

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/tip-bubble.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/tip-bubble.tsx
    View source files

Usage

import { TipBubble } from '@/components/kinetik/tip-bubble';

<TipBubble
  content="Covers payment processing and support around the clock."
  accessibilityLabel="About the service fee"
>
  <InfoIcon />
</TipBubble>;

The children are the anchor. Pressing it opens the tip; it closes after duration (4 s by default) or on a second press.

When to use

  • A sentence of help beside a term, a fee or a setting that not everyone needs explained.
  • Explaining an icon-only control the first time someone meets it.

When not to use

  • Anything people must read to continue. Put it on the screen.
  • Long help or links. Use a sheet or a help page.

Notes

  • The bubble is drawn outside the anchor's box, so leave room above (or below, with placement="bottom") and avoid overflow: 'hidden' on its parents. The anchor's root has zIndex: 10 so the bubble draws over the rows that follow it.
  • align lines up the bubble's start, centre or end with the anchor, which keeps it on screen near an edge.
  • On Android, touches only reach a view inside its parent's box, so the bubble does not take presses. It closes from the anchor or by itself.
  • With reduced motion the bubble appears without scaling.

Props

TipBubbleProps

PropTypeDefaultDescription
contentrequiredstring—The tip itself. Keep it to a sentence or two.
childrenrequiredReactNode—The anchor the tip points at, e.g. an info icon or a short label.
accessibilityLabelrequiredstring—Names the anchor for screen readers, e.g. "About fees".
placement'top' | 'bottom''top'Above or below the anchor. Defaults to `top`.
align'start' | 'center' | 'end''center'Which edge of the bubble lines up with the anchor. Defaults to `center`.
openboolean—Controlled state. Leave unset to toggle on press.
onOpenChange(open: boolean) => void—
durationnumber4000Closes by itself after this many ms; 0 keeps it open. Defaults to 4000.
widthnumber220Bubble width. Defaults to 220.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
One shared value; the bubble is mounted only while open or fading. Nothing runs at rest.
Measured

Pixel 9 emulator, API 35, release build · interaction median 0.85× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 140 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
The bubble appears and disappears without scaling.
Screen readers
The anchor is a button with an expanded state and the hint "Shows a tip"; the tip is announced when it opens, so focus stays on the anchor.
Touch target
The anchor's box is grown by 14 pt on every side, to at least 44 pt, without moving the layout.

Try it on a device

Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.

Source files

components/kinetik/tip-bubble.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
  AccessibilityInfo,
  Pressable,
  StyleSheet,
  Text,
  View,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from 'react-native-reanimated';

import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';

export type TipBubbleProps = {
  /** The tip itself. Keep it to a sentence or two. */
  content: string;
  /** The anchor the tip points at, e.g. an info icon or a short label. */
  children: ReactNode;
  /** Names the anchor for screen readers, e.g. "About fees". */
  accessibilityLabel: string;
  /** Above or below the anchor. Defaults to `top`. */
  placement?: 'top' | 'bottom';
  /** Which edge of the bubble lines up with the anchor. Defaults to `center`. */
  align?: 'start' | 'center' | 'end';
  /** Controlled state. Leave unset to toggle on press. */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Closes by itself after this many ms; 0 keeps it open. Defaults to 4000. */
  duration?: number;
  /** Bubble width. Defaults to 220. */
  width?: number;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const ARROW = 7;
/** Extra room around the anchor for the finger. */
const HIT = 14;
const OFFSET = 6;

/**
 * A tip that grows out of its anchor. Tap the anchor and a small bubble
 * scales up from the arrow's point, then closes by itself after a few
 * seconds or on a second tap. The tip is announced, not focused, so a screen
 * reader stays on the anchor.
 */
export function TipBubble({
  content,
  children,
  accessibilityLabel,
  placement = 'top',
  align = 'center',
  open,
  onOpenChange,
  duration = 4000,
  width = 220,
  testID,
  style,
}: TipBubbleProps) {
  const { colors, font, radius, shadow } = useKinetikTheme();
  const reduced = useReduceMotion();
  const [inner, setInner] = useState(false);
  const isOpen = open ?? inner;
  const [mounted, setMounted] = useState(isOpen);
  const [anchorW, setAnchorW] = useState(0);
  const t = useSharedValue(0);

  const controlled = open !== undefined;
  const setOpen = (next: boolean) => {
    if (!controlled) setInner(next);
    onOpenChange?.(next);
  };
  // The timer that closes the tip calls whichever onOpenChange is current by then.
  const changed = useRef(onOpenChange);
  useEffect(() => {
    changed.current = onOpenChange;
  });

  // Mounted from the moment it opens; unmounted only once it has faded.
  if (isOpen && !mounted) setMounted(true);

  useEffect(() => {
    if (isOpen) {
      t.set(withSpring(1, spring('bouncy', reduced)));
      AccessibilityInfo.announceForAccessibility(content);
      if (duration <= 0) return;
      const id = setTimeout(() => {
        if (!controlled) setInner(false);
        changed.current?.(false);
      }, duration);
      return () => clearTimeout(id);
    }
    t.set(withTiming(0, { duration: reduced ? 0 : durations.quick }));
    // Unmount once the bubble has faded, so it never catches touches.
    const id = setTimeout(() => setMounted(false), reduced ? 0 : durations.quick);
    return () => clearTimeout(id);
  }, [isOpen, reduced, duration, content, controlled, t]);

  const top = placement === 'top';
  // Where the arrow sits along the bubble, and so where the bubble grows from.
  const arrowX =
    align === 'start' ? anchorW / 2 : align === 'end' ? width - anchorW / 2 : width / 2;
  const left = align === 'start' ? 0 : align === 'end' ? anchorW - width : (anchorW - width) / 2;

  const bubble = useAnimatedStyle(() => ({
    opacity: Math.min(1, t.get() * 1.5),
    transform: [{ translateY: (1 - t.get()) * (top ? 6 : -6) }, { scale: 0.6 + t.get() * 0.4 }],
  }));

  return (
    <View testID={testID} style={[styles.root, style]}>
      <Pressable
        onPress={() => setOpen(!isOpen)}
        onLayout={(e) => setAnchorW(e.nativeEvent.layout.width)}
        accessibilityRole="button"
        accessibilityLabel={accessibilityLabel}
        accessibilityHint="Shows a tip"
        accessibilityState={{ expanded: isOpen }}
        style={styles.anchor}
      >
        {children}
      </Pressable>
      {mounted ? (
        <Animated.View
          pointerEvents="none"
          accessibilityElementsHidden
          importantForAccessibility="no-hide-descendants"
          style={[
            styles.bubble,
            {
              width,
              left,
              ...(top
                ? { bottom: '100%', marginBottom: OFFSET + ARROW - HIT }
                : { top: '100%', marginTop: OFFSET + ARROW - HIT }),
              transformOrigin: `${arrowX}px ${top ? '100%' : '0%'}`,
            },
            bubble,
          ]}
        >
          <View
            style={[
              styles.arrow,
              { left: arrowX - ARROW, backgroundColor: colors.ember },
              top ? { bottom: -ARROW + 2 } : { top: -ARROW + 2 },
            ]}
          />
          <View
            style={[
              styles.body,
              { backgroundColor: colors.ember, borderRadius: radius.md, boxShadow: shadow.md },
            ]}
          >
            <Text style={{ color: colors.onEmber, fontSize: font.size.sm, lineHeight: 19 }}>
              {content}
            </Text>
          </View>
        </Animated.View>
      ) : null}
    </View>
  );
}

const styles = StyleSheet.create({
  // The negative margin on the root keeps the layout as it was, while the anchor's padding
  // grows its box (and so its accessibility bounds) to at least 44 pt.
  root: { alignSelf: 'flex-start', zIndex: 10, margin: -HIT },
  anchor: {
    padding: HIT,
    minWidth: 44,
    minHeight: 44,
    alignItems: 'center',
    justifyContent: 'center',
  },
  bubble: { position: 'absolute', zIndex: 10 },
  body: { paddingHorizontal: 12, paddingVertical: 9 },
  // A square turned 45° behind the body: half of it shows as the arrow.
  arrow: {
    position: 'absolute',
    width: ARROW * 2,
    height: ARROW * 2,
    borderRadius: 2,
    transform: [{ rotate: '45deg' }],
  },
});