{"slug":"message-bubble","name":"Message Bubble","description":"Six surfaces that pop from their corner, and a show more.","group":"AI agents","detail_url":"https://beui.dev/native/r/message-bubble","page_url":"https://beui.dev/react-native/message-bubble","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/message-bubble?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/message-bubble","libraries":["react","react-native","react-native-gesture-handler","react-native-reanimated","react-native-svg","react-native-worklets","uniwind"],"install":["npx expo install react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets","npm install uniwind tailwindcss"],"setup":["Quickest: run `npx beui-native add <slug>` from the app's root. It writes the files below and installs the libraries.","Copy the files into the app and keep their folder layout: components import the shared files by relative path.","Add the theme tokens once, imported after Tailwind and Uniwind in the CSS file Uniwind reads.","Wrap the app in GestureHandlerRootView if it uses react-native-gesture-handler and isn't already.","Works in Expo and in bare React Native. Every component honors the system Reduce Motion setting."],"files":[{"path":"src/components/message-bubble/index.ts","content":"export type {\n  MessageBubbleAlign,\n  MessageBubbleCollapsibleProps,\n  MessageBubbleGroupProps,\n  MessageBubblePosition,\n  MessageBubbleProps,\n  MessageBubbleTextProps,\n  MessageBubbleVariant,\n} from \"./message-bubble\";\nexport {\n  MessageBubble,\n  MessageBubbleCollapsible,\n  MessageBubbleGroup,\n  MessageBubbleText,\n  MessageSideContext,\n} from \"./message-bubble\";\n","type":"component"},{"path":"src/components/message-bubble/message-bubble.tsx","content":"import {\n  Children,\n  createContext,\n  isValidElement,\n  type ReactNode,\n  type Ref,\n  useContext,\n  useEffect,\n  useState,\n} from \"react\";\nimport {\n  Pressable,\n  type PressableProps,\n  StyleSheet,\n  Text,\n  type TextProps,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\";\nimport Animated, {\n  ReduceMotion,\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withSequence,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { scheduleOnRN } from \"react-native-worklets\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { ChevronDownGlyph, ReplyGlyph } from \"../../primitives/agent-icons\";\nimport { FadeEdge } from \"../../primitives/fade-edge\";\nimport {\n  EASE_OUT,\n  SPRING_LAYOUT,\n  SPRING_PRESS,\n  SPRING_SWAP,\n} from \"../../styles/motion\";\n\nexport type MessageBubbleVariant =\n  | \"solid\"\n  | \"soft\"\n  | \"tint\"\n  | \"outline\"\n  | \"ghost\"\n  | \"danger\";\nexport type MessageBubbleAlign = \"start\" | \"end\";\n/** Where a bubble falls in a run of bubbles from one side. */\nexport type MessageBubblePosition = \"single\" | \"first\" | \"middle\" | \"last\";\n\nconst ROUND = 18;\nconst TIGHT = 6;\n\n/**\n * Chat corners: the bottom corner on the sender's side tightens into a tail\n * hint, and bubbles after the first in a run tighten their top corner too, so\n * a run reads as one voice.\n */\nfunction corners(align: MessageBubbleAlign, position: MessageBubblePosition) {\n  const top = position === \"middle\" || position === \"last\" ? TIGHT : ROUND;\n  return align === \"end\"\n    ? {\n        borderTopLeftRadius: ROUND,\n        borderBottomLeftRadius: ROUND,\n        borderTopRightRadius: top,\n        borderBottomRightRadius: TIGHT,\n      }\n    : {\n        borderTopRightRadius: ROUND,\n        borderBottomRightRadius: ROUND,\n        borderTopLeftRadius: top,\n        borderBottomLeftRadius: TIGHT,\n      };\n}\n\nconst PositionContext = createContext<MessageBubblePosition | undefined>(\n  undefined,\n);\n\n/** Which side a message sits on. Message provides it; bubbles default to it. */\nexport const MessageSideContext = createContext<MessageBubbleAlign | undefined>(\n  undefined,\n);\n\ntype BubbleContextValue = { variant: MessageBubbleVariant };\nconst BubbleContext = createContext<BubbleContextValue>({ variant: \"soft\" });\n\n// Full class strings, so Uniwind can see every token at build time.\nconst SURFACE_CLASS: Record<MessageBubbleVariant, string> = {\n  solid: \"bg-primary\",\n  soft: \"bg-surface\",\n  tint: \"bg-primary/10\",\n  outline: \"border border-border-strong bg-background\",\n  ghost: \"\",\n  danger: \"bg-destructive/10\",\n};\nconst TEXT_CLASS: Record<MessageBubbleVariant, string> = {\n  solid: \"text-on-primary\",\n  soft: \"text-foreground\",\n  tint: \"text-foreground\",\n  outline: \"text-foreground\",\n  ghost: \"text-foreground\",\n  danger: \"text-destructive\",\n};\n\n// One restrained overshoot as the surface pops out of its corner.\nconst BUBBLE_POP = {\n  stiffness: 520,\n  damping: 27,\n  mass: 0.52,\n  reduceMotion: ReduceMotion.System,\n};\nconst POP_FADE = { duration: 120, easing: EASE_OUT };\nconst CONTENT_DELAY = 40;\nconst LINE_HEIGHT = 24;\n// How far to drag before letting go replies.\nconst REPLY_AT = 56;\n\nexport type MessageBubbleTextProps = TextProps & { className?: string };\n\n/** Body text in the bubble's color. Plain strings are wrapped in it for you. */\nexport function MessageBubbleText({\n  className = \"\",\n  ...props\n}: MessageBubbleTextProps) {\n  const { variant } = useContext(BubbleContext);\n  return (\n    <Text\n      className={`text-sm leading-6 ${TEXT_CLASS[variant]} ${className}`}\n      {...props}\n    />\n  );\n}\n\nfunction wrapText(children: ReactNode) {\n  return Children.map(children, (child) =>\n    typeof child === \"string\" || typeof child === \"number\" ? (\n      <MessageBubbleText>{child}</MessageBubbleText>\n    ) : (\n      child\n    ),\n  );\n}\n\nexport type MessageBubbleProps = Omit<ViewProps, \"children\"> & {\n  /** Defaults to \"soft\". */\n  variant?: MessageBubbleVariant;\n  /** Defaults to the surrounding Message's side, then \"start\". */\n  align?: MessageBubbleAlign;\n  /** Pops the surface out of its corner once, on mount. Defaults to false. */\n  animateIn?: boolean;\n  /** Its place in a run, for the corners. MessageBubbleGroup sets it for you. */\n  position?: MessageBubblePosition;\n  /** Makes the whole bubble tappable, with press feedback. */\n  onPress?: PressableProps[\"onPress\"];\n  /** Lifts the bubble before firing, like a context menu opening. */\n  onLongPress?: PressableProps[\"onLongPress\"];\n  /**\n   * Swipe the bubble toward the middle of the screen to reply. A reply arrow\n   * grows as you drag; letting go past it calls this.\n   */\n  onReply?: () => void;\n  /** Classes for the bubble itself, such as padding or a max width. */\n  contentClassName?: string;\n  /** Pops in without motion, independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  children?: ReactNode;\n  ref?: Ref<View>;\n};\n\n/**\n * A chat bubble in one of six surfaces. Its surface pops out of the corner on\n * its side, and plain text inside takes the bubble's color.\n */\nexport function MessageBubble({\n  variant = \"soft\",\n  align: alignProp,\n  animateIn = false,\n  position: positionProp,\n  onPress,\n  onLongPress,\n  onReply,\n  contentClassName = \"\",\n  disableAnimation = false,\n  className = \"\",\n  children,\n  accessibilityLabel,\n  accessibilityHint,\n  ref,\n  ...props\n}: MessageBubbleProps) {\n  const side = useContext(MessageSideContext);\n  const align = alignProp ?? side ?? \"start\";\n  const groupPosition = useContext(PositionContext);\n  const radii = corners(align, positionProp ?? groupPosition ?? \"single\");\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const pop = animateIn && !reduce;\n  const scale = useSharedValue(pop ? 0.92 : 1);\n  const surfaceOpacity = useSharedValue(pop ? 0 : 1);\n  const contentOpacity = useSharedValue(pop ? 0 : 1);\n  const pressed = useSharedValue(1);\n  const drag = useSharedValue(0);\n  const armed = useSharedValue(0);\n  const ghost = variant === \"ghost\";\n  // Toward the middle of the screen: right for start bubbles, left for end.\n  const direction = align === \"end\" ? -1 : 1;\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const interactive = Boolean(onPress || onLongPress);\n\n  // Plays once, on mount.\n  useEffect(() => {\n    if (!pop) return;\n    scale.value = withSpring(1, BUBBLE_POP);\n    surfaceOpacity.value = withTiming(1, POP_FADE);\n    contentOpacity.value = withDelay(CONTENT_DELAY, withTiming(1, POP_FADE));\n  }, []);\n\n  const surfaceStyle = useAnimatedStyle(() => ({\n    opacity: surfaceOpacity.value,\n    transform: [{ scale: scale.value * pressed.value }],\n  }));\n  const contentStyle = useAnimatedStyle(() => ({\n    opacity: contentOpacity.value,\n    transform: [{ scale: pressed.value }],\n  }));\n\n  const reply = () => onReply?.();\n  const swipe = Gesture.Pan()\n    .enabled(Boolean(onReply))\n    .activeOffsetX(direction > 0 ? [-999, 12] : [-12, 999])\n    .failOffsetY([-10, 10])\n    .onUpdate((event) => {\n      const pull = Math.max(0, event.translationX * direction);\n      // Follows the finger up to the threshold, then resists.\n      const travel =\n        pull < REPLY_AT ? pull : REPLY_AT + (pull - REPLY_AT) * 0.25;\n      drag.value = travel * direction;\n      const nowArmed = pull >= REPLY_AT ? 1 : 0;\n      if (nowArmed !== armed.value) {\n        armed.value = nowArmed;\n      }\n    })\n    .onEnd(() => {\n      if (armed.value === 1) scheduleOnRN(reply);\n      armed.value = 0;\n      drag.value = withSpring(0, SPRING_SWAP);\n    });\n\n  const dragStyle = useAnimatedStyle(() => ({\n    transform: [{ translateX: drag.value }],\n  }));\n  const arrowStyle = useAnimatedStyle(() => {\n    const progress = Math.min(1, Math.abs(drag.value) / REPLY_AT);\n    return {\n      opacity: progress,\n      transform: [\n        {\n          scale: withSpring(\n            armed.value ? 1.15 : 0.6 + progress * 0.4,\n            SPRING_SWAP,\n          ),\n        },\n      ],\n    };\n  });\n\n  const bubble = (\n    <View\n      style={[\n        ghost ? styles.ghost : [styles.bubble, radii],\n        align === \"end\" ? styles.end : styles.start,\n      ]}\n      className={ghost ? contentClassName : `px-3.5 py-2.5 ${contentClassName}`}\n    >\n      {ghost ? null : (\n        <Animated.View\n          pointerEvents=\"none\"\n          style={[\n            StyleSheet.absoluteFill,\n            radii,\n            { transformOrigin: [align === \"end\" ? \"100%\" : \"0%\", \"100%\", 0] },\n            surfaceStyle,\n          ]}\n          className={SURFACE_CLASS[variant]}\n        />\n      )}\n      <Animated.View style={contentStyle}>{wrapText(children)}</Animated.View>\n    </View>\n  );\n\n  return (\n    <BubbleContext.Provider value={{ variant }}>\n      <View\n        ref={ref}\n        style={[\n          styles.row,\n          { alignItems: align === \"end\" ? \"flex-end\" : \"flex-start\" },\n        ]}\n        className={className}\n        accessibilityActions={\n          onReply ? [{ name: \"reply\", label: \"Reply\" }] : undefined\n        }\n        onAccessibilityAction={(event) => {\n          if (event.nativeEvent.actionName === \"reply\") onReply?.();\n        }}\n        {...props}\n      >\n        {onReply ? (\n          <Animated.View\n            pointerEvents=\"none\"\n            style={[\n              styles.arrow,\n              align === \"end\" ? styles.arrowEnd : styles.arrowStart,\n              arrowStyle,\n            ]}\n          >\n            <ReplyGlyph size={18} color={muted} />\n          </Animated.View>\n        ) : null}\n        <GestureDetector gesture={swipe}>\n          <Animated.View\n            style={[\n              ghost ? styles.ghost : styles.swipe,\n              align === \"end\" ? styles.end : styles.start,\n              dragStyle,\n            ]}\n          >\n            {interactive ? (\n              <Pressable\n                accessibilityRole=\"button\"\n                accessibilityLabel={accessibilityLabel}\n                accessibilityHint={accessibilityHint}\n                onPress={onPress}\n                onLongPress={\n                  onLongPress\n                    ? (event) => {\n                        if (!reduce) {\n                          pressed.value = withSequence(\n                            withSpring(1.04, SPRING_PRESS),\n                            withSpring(1, SPRING_PRESS),\n                          );\n                        }\n                        onLongPress(event);\n                      }\n                    : undefined\n                }\n                onPressIn={() => {\n                  if (!reduce) pressed.value = withSpring(0.98, SPRING_PRESS);\n                }}\n                onPressOut={() => {\n                  pressed.value = withSpring(1, SPRING_PRESS);\n                }}\n                style={[\n                  styles.pressable,\n                  align === \"end\" ? styles.end : styles.start,\n                ]}\n              >\n                {bubble}\n              </Pressable>\n            ) : (\n              bubble\n            )}\n          </Animated.View>\n        </GestureDetector>\n      </View>\n    </BubbleContext.Provider>\n  );\n}\n\nexport type MessageBubbleGroupProps = ViewProps & {\n  /** Space between runs from different sides: 6 (compact) or 12 points. Defaults to \"compact\". */\n  spacing?: \"compact\" | \"default\";\n  className?: string;\n};\n\nconst RUN_GAP = 2;\n\n/**\n * Stacks bubbles and joins consecutive ones from the same side into runs:\n * they sit 2 points apart with tightened corners, and runs sit further apart.\n */\nexport function MessageBubbleGroup({\n  spacing = \"compact\",\n  className = \"\",\n  children,\n  ...props\n}: MessageBubbleGroupProps) {\n  const side = useContext(MessageSideContext);\n  const items = Children.toArray(children);\n  const alignOf = (child: ReactNode) =>\n    (isValidElement<{ align?: MessageBubbleAlign }>(child)\n      ? child.props.align\n      : undefined) ??\n    side ??\n    \"start\";\n  const gap = spacing === \"compact\" ? 6 : 12;\n  return (\n    <View className={`w-full ${className}`} {...props}>\n      {items.map((child, index) => {\n        const align = alignOf(child);\n        const joinsPrevious = index > 0 && alignOf(items[index - 1]) === align;\n        const joinsNext =\n          index < items.length - 1 && alignOf(items[index + 1]) === align;\n        const position: MessageBubblePosition = joinsPrevious\n          ? joinsNext\n            ? \"middle\"\n            : \"last\"\n          : joinsNext\n            ? \"first\"\n            : \"single\";\n        return (\n          <PositionContext.Provider\n            key={isValidElement(child) && child.key != null ? child.key : index}\n            value={position}\n          >\n            <View\n              style={index > 0 && { marginTop: joinsPrevious ? RUN_GAP : gap }}\n            >\n              {child}\n            </View>\n          </PositionContext.Provider>\n        );\n      })}\n    </View>\n  );\n}\n\nexport type MessageBubbleCollapsibleProps = {\n  open?: boolean;\n  /** Defaults to false. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Lines shown while collapsed, at the bubble's 24 point line height. Defaults to 4. */\n  collapsedLines?: number;\n  /** Defaults to \"Show more\". */\n  moreLabel?: string;\n  /** Defaults to \"Show less\". */\n  lessLabel?: string;\n  /** Pops open without motion, independently of the system preference. */\n  disableAnimation?: boolean;\n  children: ReactNode;\n};\n\n/**\n * Clamps a long bubble to a few lines with a soft edge, and springs it open\n * to its full height. The toggle only appears when the content overflows.\n */\nexport function MessageBubbleCollapsible({\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  collapsedLines = 4,\n  moreLabel = \"Show more\",\n  lessLabel = \"Show less\",\n  disableAnimation = false,\n  children,\n}: MessageBubbleCollapsibleProps) {\n  const { variant } = useContext(BubbleContext);\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const open = controlledOpen ?? internalOpen;\n  const [full, setFull] = useState(0);\n  const collapsed = collapsedLines * LINE_HEIGHT;\n  const overflows = full > collapsed + 1;\n  const height = useSharedValue(collapsed);\n  const turn = useSharedValue(open ? 1 : 0);\n  const measured = full > 0;\n  const surface = useResolveClassNames(\n    SURFACE_CLASS[variant] || \"bg-background\",\n  );\n  const { color: tone } = useResolveClassNames(TEXT_CLASS[variant]);\n\n  useEffect(() => {\n    if (!measured) return;\n    const target = open || !overflows ? full : collapsed;\n    height.value = reduce ? target : withSpring(target, SPRING_LAYOUT);\n    turn.value = reduce\n      ? open\n        ? 1\n        : 0\n      : withSpring(open ? 1 : 0, SPRING_SWAP);\n  }, [collapsed, full, height, measured, open, overflows, reduce, turn]);\n\n  const clipStyle = useAnimatedStyle(() => ({ height: height.value }));\n  const fadeStyle = useAnimatedStyle(() => ({\n    opacity: Math.min(1, Math.max(0, (full - height.value) / LINE_HEIGHT)),\n  }));\n  const chevronStyle = useAnimatedStyle(() => ({\n    transform: [{ rotate: `${turn.value * 180}deg` }],\n  }));\n\n  const toggle = () => {\n    const next = !open;\n    if (controlledOpen === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n\n  return (\n    <View>\n      <Animated.View style={[styles.clip, measured && clipStyle]}>\n        {/* Keeps its full height inside the clip, so the clamp knows how far to open. */}\n        <View\n          style={styles.measure}\n          onLayout={({ nativeEvent }) => setFull(nativeEvent.layout.height)}\n        >\n          {wrapText(children)}\n        </View>\n        {overflows ? (\n          <Animated.View\n            pointerEvents=\"none\"\n            style={[StyleSheet.absoluteFill, fadeStyle]}\n          >\n            <FadeEdge\n              color={surface.backgroundColor}\n              height={LINE_HEIGHT * 1.5}\n            />\n          </Animated.View>\n        ) : null}\n      </Animated.View>\n      {overflows ? (\n        <Pressable\n          accessibilityRole=\"button\"\n          accessibilityState={{ expanded: open }}\n          onPress={toggle}\n          hitSlop={8}\n          style={styles.toggle}\n        >\n          <Text\n            className={`text-xs font-medium ${TEXT_CLASS[variant]} opacity-70`}\n          >\n            {open ? lessLabel : moreLabel}\n          </Text>\n          <Animated.View style={chevronStyle}>\n            <ChevronDownGlyph size={12} color={tone} />\n          </Animated.View>\n        </Pressable>\n      ) : null}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  row: { width: \"100%\" },\n  bubble: { minWidth: 36 },\n  ghost: { width: \"100%\" },\n  pressable: { maxWidth: \"100%\" },\n  swipe: { maxWidth: \"82%\" },\n  arrow: { position: \"absolute\", top: 0, bottom: 0, justifyContent: \"center\" },\n  arrowStart: { left: 4 },\n  arrowEnd: { right: 4 },\n  start: { alignSelf: \"flex-start\" },\n  end: { alignSelf: \"flex-end\" },\n  clip: { overflow: \"hidden\" },\n  measure: { flexShrink: 0 },\n  toggle: {\n    flexDirection: \"row\",\n    alignItems: \"center\",\n    gap: 4,\n    marginTop: 6,\n    minHeight: 24,\n    alignSelf: \"flex-start\",\n  },\n});\n","type":"component"},{"path":"src/hooks/use-reduced-motion.ts","content":"import { useEffect, useState } from \"react\";\nimport { AccessibilityInfo } from \"react-native\";\nimport { useReducedMotion } from \"react-native-reanimated\";\n\n/** Reanimated supplies a synchronous initial value; native events keep it current. */\nexport function useReducedMotionPreference() {\n  const initialValue = useReducedMotion();\n  const [reducedMotion, setReducedMotion] = useState(initialValue);\n\n  useEffect(() => {\n    let active = true;\n    let receivedEvent = false;\n    const subscription = AccessibilityInfo.addEventListener(\n      \"reduceMotionChanged\",\n      (value) => {\n        receivedEvent = true;\n        setReducedMotion(value);\n      },\n    );\n    void AccessibilityInfo.isReduceMotionEnabled().then((value) => {\n      if (active && !receivedEvent) setReducedMotion(value);\n    });\n    return () => {\n      active = false;\n      subscription.remove();\n    };\n  }, []);\n\n  return reducedMotion;\n}\n","type":"shared"},{"path":"src/primitives/agent-icons.tsx","content":"import type { ReactNode } from \"react\";\nimport type { ColorValue } from \"react-native\";\nimport Svg, { Circle, Path, Rect } from \"react-native-svg\";\n\ntype GlyphProps = { size?: number; color?: ColorValue; strokeWidth?: number };\n\nfunction Glyph({\n  size = 16,\n  color = \"currentColor\",\n  strokeWidth = 2,\n  children,\n}: GlyphProps & { children: ReactNode }) {\n  return (\n    <Svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke={color}\n      strokeWidth={strokeWidth}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      {children}\n    </Svg>\n  );\n}\n\n// Simple 24-unit stroke glyphs shared by the agent components.\n\nexport function ChevronDownGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M6 9l6 6 6-6\" />\n    </Glyph>\n  );\n}\n\nexport function ArrowUpGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 19V5M5.5 11.5L12 5l6.5 6.5\" />\n    </Glyph>\n  );\n}\n\nexport function PlusGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 5v14M5 12h14\" />\n    </Glyph>\n  );\n}\n\nexport function StopGlyph({ size = 12, color }: GlyphProps) {\n  return (\n    <Svg width={size} height={size} viewBox=\"0 0 12 12\">\n      <Rect x={1} y={1} width={10} height={10} rx={2.5} fill={color} />\n    </Svg>\n  );\n}\n\nexport function CheckGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M5 12.5l4.5 4.5L19 7\" />\n    </Glyph>\n  );\n}\n\nexport function CopyGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={8.5} y={8.5} width={12} height={12} rx={2.5} />\n      <Path d=\"M15.5 5.5v-.5A2 2 0 0 0 13.5 3h-8.5A2 2 0 0 0 3 5v8.5a2 2 0 0 0 2 2h.5\" />\n    </Glyph>\n  );\n}\n\nexport function RetryGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M3.5 12a8.5 8.5 0 1 0 2.6-6.1L3.5 8.5\" />\n      <Path d=\"M3.5 3.5v5h5\" />\n    </Glyph>\n  );\n}\n\nexport function ThumbGlyph({\n  down = false,\n  ...props\n}: GlyphProps & { down?: boolean }) {\n  return (\n    <Glyph {...props}>\n      <Path\n        d=\"M7.5 10.5v10M7.5 10.5L11 3.5a2.5 2.5 0 0 1 2.5 2.5v3.5h5a2 2 0 0 1 2 2.3l-1.2 7A2 2 0 0 1 17.3 20.5H4.5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1z\"\n        transform={down ? \"rotate(180 12 12)\" : undefined}\n      />\n    </Glyph>\n  );\n}\n\nexport function SearchGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={11} cy={11} r={6.5} />\n      <Path d=\"M16 16l4 4\" />\n    </Glyph>\n  );\n}\n\nexport function GlobeGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M3.5 12h17M12 3.5c2.4 2.5 3.5 5.3 3.5 8.5s-1.1 6-3.5 8.5c-2.4-2.5-3.5-5.3-3.5-8.5s1.1-6 3.5-8.5z\" />\n    </Glyph>\n  );\n}\n\nexport function FileGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M14 3.5H7.5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V8z\" />\n      <Path d=\"M14 3.5V8h4.5M9 13h6M9 16.5h4\" />\n    </Glyph>\n  );\n}\n\nexport function PencilGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M4 20h4L19 9a2.1 2.1 0 0 0-4-4L4 16z\" />\n      <Path d=\"M13.5 6.5l4 4M14 20h6\" />\n    </Glyph>\n  );\n}\n\nexport function TerminalGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={3.5} y={4.5} width={17} height={15} rx={2.5} />\n      <Path d=\"M7.5 10l2.5 2-2.5 2M12.5 15h4\" />\n    </Glyph>\n  );\n}\n\nexport function WrenchGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M14.5 6.5a4 4 0 0 0 5 5l-8.8 8.8a2.1 2.1 0 0 1-3-3l8.8-8.8a4 4 0 0 1-2-2z\" />\n    </Glyph>\n  );\n}\n\nexport function SparkleGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 4l1.8 4.6L18.5 10.5l-4.7 1.9L12 17l-1.8-4.6-4.7-1.9 4.7-1.9z\" />\n      <Path d=\"M18.5 16.5v3M17 18h3\" />\n    </Glyph>\n  );\n}\n\nexport function MessageGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M20 14.5a2 2 0 0 1-2 2H8.5L4 20V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z\" />\n    </Glyph>\n  );\n}\n\nexport function ImageGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={3.5} y={4.5} width={17} height={15} rx={2.5} />\n      <Circle cx={9} cy={10} r={1.5} />\n      <Path d=\"M20.5 15.5l-4.5-4.5-9 8.5\" />\n    </Glyph>\n  );\n}\n\nexport function ShieldGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 3.5l7 2.5v5.5c0 4.4-3 7.7-7 9-4-1.3-7-4.6-7-9V6z\" />\n      <Path d=\"M9 12l2 2 4-4\" />\n    </Glyph>\n  );\n}\n\nexport function AlertGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M12 8v4.5M12 16h.01\" />\n    </Glyph>\n  );\n}\n\nexport function CrossGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M6.5 6.5l11 11M17.5 6.5l-11 11\" />\n    </Glyph>\n  );\n}\n\nexport function HelpGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M9.6 9.5a2.5 2.5 0 0 1 4.8 1c0 1.7-2.4 2.2-2.4 3.5M12 16.8h.01\" />\n    </Glyph>\n  );\n}\n\nexport function ArrowGlyph({\n  back = false,\n  ...props\n}: GlyphProps & { back?: boolean }) {\n  return (\n    <Glyph {...props}>\n      <Path\n        d=\"M5 12h14M13 6l6 6-6 6\"\n        transform={back ? \"rotate(180 12 12)\" : undefined}\n      />\n    </Glyph>\n  );\n}\n\nexport function BracesGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M8.5 4H8a2 2 0 0 0-2 2v3.5A2.5 2.5 0 0 1 3.5 12 2.5 2.5 0 0 1 6 14.5V18a2 2 0 0 0 2 2h.5M15.5 4h.5a2 2 0 0 1 2 2v3.5a2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0-2.5 2.5V18a2 2 0 0 1-2 2h-.5\" />\n    </Glyph>\n  );\n}\n\nexport function BanGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M6 6l12 12\" />\n    </Glyph>\n  );\n}\n\nexport function CircleCheckGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M8.5 12.2l2.4 2.4 4.6-4.8\" />\n    </Glyph>\n  );\n}\n\nexport function CircleCrossGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M9.5 9.5l5 5M14.5 9.5l-5 5\" />\n    </Glyph>\n  );\n}\n\nexport function ListGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M4 6.5l1.5 1.5L8 5.5M4 13l1.5 1.5L8 12M11.5 7h8.5M11.5 13.5h8.5M11.5 19h8.5M4.5 19h2\" />\n    </Glyph>\n  );\n}\n\n/** An open ring, spun by SpinGlyph. */\nexport function RingGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M20.5 12a8.5 8.5 0 1 1-6-8.1\" />\n    </Glyph>\n  );\n}\n\nexport function ReplyGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M9.5 7L4.5 12l5 5M4.5 12h10a5 5 0 0 1 5 5v1\" />\n    </Glyph>\n  );\n}\n\nexport function WrapGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M4 6.5h16M4 12h12.5a3.25 3.25 0 0 1 0 6.5H12M4 18.5h4.5M14 16.5l-2 2 2 2\" />\n    </Glyph>\n  );\n}\n\nexport function BookGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 6.5c-1.6-1.3-4-2-7.5-2v13c3.5 0 5.9.7 7.5 2 1.6-1.3 4-2 7.5-2v-13c-3.5 0-5.9.7-7.5 2zM12 6.5v13\" />\n    </Glyph>\n  );\n}\n\nexport function ExternalGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M14 4.5h5.5V10M19.5 4.5L11 13M17 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h4\" />\n    </Glyph>\n  );\n}\n\nexport function DoubleCheckGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M2.5 12.5l4.5 4.5 9-9.5M12 16.5l.5.5 9-9.5\" />\n    </Glyph>\n  );\n}\n\nexport function ClockGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M12 7.5V12l3 2\" />\n    </Glyph>\n  );\n}\n\nexport function MicGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={9} y={3.5} width={6} height={11} rx={3} />\n      <Path d=\"M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3\" />\n    </Glyph>\n  );\n}\n","type":"shared"},{"path":"src/primitives/fade-edge.tsx","content":"import {\n  type ColorValue,\n  StyleSheet,\n  View,\n  type ViewStyle,\n} from \"react-native\";\n\nconst BANDS = 8;\n\nexport type FadeEdgeProps = {\n  /** The surface color the content fades into. */\n  color: ColorValue | undefined;\n  /** Which edge is solid. Defaults to \"bottom\". */\n  edge?: \"top\" | \"bottom\";\n  /** Height of the fade in points. */\n  height: number;\n  style?: ViewStyle;\n};\n\n/**\n * A soft edge over clipped content, built from stacked bands so shared\n * components need no gradient library. Ignores touches.\n */\nexport function FadeEdge({\n  color,\n  edge = \"bottom\",\n  height,\n  style,\n}: FadeEdgeProps) {\n  return (\n    <View\n      pointerEvents=\"none\"\n      style={[\n        styles.edge,\n        edge === \"bottom\" ? styles.bottom : styles.top,\n        { height },\n        style,\n      ]}\n    >\n      {Array.from({ length: BANDS }, (_, index) => {\n        const t = (index + 1) / BANDS;\n        return (\n          <View\n            key={index}\n            style={{\n              flex: 1,\n              backgroundColor: color,\n              // Ease the ramp so the fade has no visible first step.\n              opacity: edge === \"bottom\" ? t * t : (1 - t + 1 / BANDS) ** 2,\n            }}\n          />\n        );\n      })}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  edge: { position: \"absolute\", left: 0, right: 0 },\n  bottom: { bottom: 0 },\n  top: { top: 0 },\n});\n","type":"shared"},{"path":"src/styles/motion.ts","content":"import { Easing, ReduceMotion } from \"react-native-reanimated\";\n\n// Mirrors beUI's lib/ease.ts so native motion matches the web library.\n\nexport const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);\nexport const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Content swaps: label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Shared-layout glides: pills, indicators and panels moving between positions. */\nexport const SPRING_LAYOUT = {\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Dragged handles and fills; critically damped so values never rebound off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\nexport const motion = {\n  press: SPRING_PRESS,\n  swap: SPRING_SWAP,\n  layout: SPRING_LAYOUT,\n  glide: SPRING_GLIDE,\n  exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },\n  fade: { duration: 150, reduceMotion: ReduceMotion.System },\n} as const;\n","type":"shared"},{"path":"src/styles/theme.css","content":"/* Matches the default beUI palette in ui-components/lib/themes.ts.\n   Native surface maps to card; muted maps to muted-foreground. */\n@layer theme {\n  :root {\n    @variant light {\n      --color-background: oklch(99% 0 0);\n      --color-surface: oklch(97% 0 0);\n      --color-foreground: oklch(15% 0 0);\n      --color-muted: oklch(50% 0 0);\n      --color-border: oklch(15% 0 0 / 0.06);\n      --color-border-strong: oklch(15% 0 0 / 0.16);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #dc2626;\n      --color-success: #059669;\n      --color-warning: #d97706;\n    }\n  }\n\n  :root {\n    @variant dark {\n      --color-background: #151515;\n      --color-surface: #1c1c1c;\n      --color-foreground: oklch(96% 0 0);\n      --color-muted: oklch(62% 0 0);\n      --color-border: rgb(255 255 255 / 0.05);\n      --color-border-strong: rgb(255 255 255 / 0.14);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #f87171;\n      --color-success: #34d399;\n      --color-warning: #fbbf24;\n    }\n  }\n\n  /* Tailwind's default monospace stack isn't a font React Native can load. */\n  :root {\n    @variant ios {\n      --font-mono: Menlo;\n    }\n  }\n\n  :root {\n    @variant android {\n      --font-mono: monospace;\n    }\n  }\n}\n","type":"theme"}]}