{"slug":"morphing-modal","name":"Morphing Modal","description":"A card that springs to the size of each view you open in it.","group":"Motion","detail_url":"https://beui.dev/native/r/morphing-modal","page_url":"https://beui.dev/react-native/morphing-modal","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/morphing-modal?runtime-version=exposdk%3A58.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/morphing-modal","libraries":["react","react-native","react-native-gesture-handler","react-native-reanimated","react-native-screens","react-native-svg","react-native-worklets","uniwind"],"install":["npx expo install react-native-gesture-handler react-native-reanimated react-native-screens 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/morphing-modal/index.ts","content":"export {\n  MorphingModal,\n  MorphingModalHeader,\n  type MorphingModalHeaderProps,\n  type MorphingModalProps,\n} from \"./morphing-modal\";\n","type":"component"},{"path":"src/components/morphing-modal/morphing-modal.tsx","content":"import {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  Pressable,\n  StyleSheet,\n  Text,\n  useWindowDimensions,\n  View,\n} from \"react-native\";\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\";\nimport Animated, {\n  Easing,\n  ReduceMotion,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { scheduleOnRN } from \"react-native-worklets\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { usePresence } from \"../../hooks/use-presence\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { ArrowGlyph, CrossGlyph } from \"../../primitives/agent-icons\";\nimport { Overlay } from \"../../primitives/overlay\";\nimport { EASE_OUT } from \"../../styles/motion\";\n\n/** The card rising into place and settling, without overshoot. */\nconst SPRING_OPEN = {\n  stiffness: 380,\n  damping: 38,\n  mass: 0.8,\n  reduceMotion: ReduceMotion.System,\n} as const;\n/** The card's height following a view change: quick, a touch of give. */\nconst SPRING_MORPH = {\n  stiffness: 420,\n  damping: 36,\n  mass: 0.7,\n  reduceMotion: ReduceMotion.System,\n} as const;\nconst CLOSE = {\n  duration: 220,\n  easing: Easing.bezier(0.32, 0, 0.67, 0),\n  reduceMotion: ReduceMotion.System,\n} as const;\nconst VIEW_IN = { duration: 240, easing: EASE_OUT } as const;\nconst VIEW_OUT = { duration: 150, easing: EASE_OUT } as const;\n/** How far to pull before letting go dismisses, and how fast a flick must be. */\nconst DISMISS_DISTANCE = 110;\nconst DISMISS_VELOCITY = 900;\nconst EDGE = 12;\n\ntype ModalContextValue = {\n  close: () => void;\n  reduce: boolean;\n};\n\nconst ModalContext = createContext<ModalContextValue | null>(null);\n\nfunction useModalContext(component: string) {\n  const context = useContext(ModalContext);\n  if (!context)\n    throw new Error(`${component} must be used inside <MorphingModal>.`);\n  return context;\n}\n\nexport type MorphingModalProps = {\n  /** The view to show, by name. `null` closes the modal. */\n  view: string | null;\n  onClose: () => void;\n  /**\n   * Called by the Android back button. Pass it on views reached from another\n   * view, so back steps out of them; without it, back closes the modal.\n   */\n  onBack?: () => void;\n  /** The current view's content. Give each view its own `view` name. */\n  children: ReactNode;\n  /**\n   * The bottom safe-area inset. The card sits low over the home indicator,\n   * close to the screen's curved corners, and keeps at least 12 points of\n   * space. Defaults to 0.\n   */\n  bottomInset?: number;\n  /** Whether a swipe down or a tap outside closes it. Defaults to true. */\n  dismissible?: boolean;\n  /** Read by screen readers when the modal opens. */\n  accessibilityLabel?: string;\n  className?: string;\n  /** Opens, morphs and closes without motion, independently of the system preference. */\n  disableAnimation?: boolean;\n};\n\n/**\n * A card that rises from the bottom of the screen and morphs its height as\n * you move between the views inside it, in the style of the Family wallet.\n * Each view fades and lifts into place while the card springs to its size.\n * Swipe it down, or tap outside, to close.\n */\nexport function MorphingModal({\n  view,\n  onClose,\n  onBack,\n  children,\n  bottomInset = 0,\n  dismissible = true,\n  accessibilityLabel,\n  className = \"\",\n  disableAnimation = false,\n}: MorphingModalProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const window = useWindowDimensions();\n  const open = view !== null;\n\n  // Stay mounted through the close, showing the last view.\n  const [present, setPresent] = useState(open);\n  if (open && !present) setPresent(true);\n  const shown = useRef(view);\n  if (view !== null) shown.current = view;\n  // Each view's latest content, so a view leaves looking as it did, not as it\n  // first appeared.\n  const latest = useRef(new Map<string, ReactNode>());\n  if (view !== null) latest.current.set(view, children);\n  const items = usePresence(\n    present ? shown.current : null,\n    children,\n    () => VIEW_OUT.duration,\n  );\n\n  const progress = useSharedValue(0);\n  const drag = useSharedValue(0);\n  const height = useSharedValue(0);\n  const [measured, setMeasured] = useState(false);\n  const measuredRef = useRef(false);\n\n  const hide = useCallback(() => {\n    latest.current.clear();\n    setPresent(false);\n    setMeasured(false);\n    measuredRef.current = false;\n    drag.value = 0;\n  }, [drag]);\n\n  useEffect(() => {\n    if (!present) return;\n    if (open) {\n      if (!measured) return;\n      progress.value = reduce\n        ? withTiming(1, { duration: 160 })\n        : withSpring(1, SPRING_OPEN);\n    } else {\n      progress.value = withTiming(\n        0,\n        reduce ? { duration: 140 } : CLOSE,\n        (done) => {\n          if (done) scheduleOnRN(hide);\n        },\n      );\n    }\n  }, [hide, measured, open, present, progress, reduce]);\n\n  // The first size lands before the card rises; later ones morph.\n  const onMeasure = useCallback(\n    (next: number) => {\n      if (!measuredRef.current) {\n        height.value = next;\n        measuredRef.current = true;\n        setMeasured(true);\n        return;\n      }\n      height.value = reduce ? next : withSpring(next, SPRING_MORPH);\n    },\n    [height, reduce],\n  );\n\n  const close = useCallback(() => {\n    if (open) onClose();\n  }, [onClose, open]);\n\n  const travel = window.height * 0.6;\n  const pan = Gesture.Pan()\n    .enabled(dismissible && open)\n    .activeOffsetY(10)\n    .failOffsetX([-16, 16])\n    .onUpdate((event) => {\n      // Down follows the finger; up resists, so the card stays anchored.\n      drag.value =\n        event.translationY > 0\n          ? event.translationY\n          : -Math.sqrt(-event.translationY) * 3;\n    })\n    .onEnd((event) => {\n      if (\n        event.translationY > DISMISS_DISTANCE ||\n        event.velocityY > DISMISS_VELOCITY\n      ) {\n        scheduleOnRN(close);\n      } else {\n        drag.value = withSpring(0, SPRING_OPEN);\n      }\n    });\n\n  const panelStyle = useAnimatedStyle(() => {\n    const p = progress.value;\n    if (reduce) return { opacity: p, transform: [{ translateY: drag.value }] };\n    return {\n      opacity: Math.min(1, p * 2),\n      transform: [\n        { translateY: (1 - p) * travel + Math.max(-40, drag.value) },\n        { scale: 0.96 + 0.04 * Math.min(1, p) },\n      ],\n    };\n  }, [reduce, travel]);\n  const sizeStyle = useAnimatedStyle(() => ({ height: height.value }));\n  const scrimStyle = useAnimatedStyle(() => ({\n    // The scrim lightens as the card is pulled away.\n    opacity:\n      Math.min(1, progress.value) *\n      (1 - Math.min(0.6, Math.max(0, drag.value) / 400)),\n  }));\n\n  if (!present) return null;\n\n  return (\n    <Overlay onRequestClose={() => (onBack ? onBack() : close())}>\n      <Animated.View\n        style={[StyleSheet.absoluteFill, scrimStyle]}\n        className=\"bg-black/25\"\n      >\n        <Pressable\n          accessibilityLabel=\"Close\"\n          accessibilityRole=\"button\"\n          style={StyleSheet.absoluteFill}\n          onPress={dismissible ? close : undefined}\n        />\n      </Animated.View>\n      <View\n        pointerEvents=\"box-none\"\n        style={[\n          styles.layer,\n          { paddingBottom: Math.max(EDGE, bottomInset - 20) },\n        ]}\n      >\n        <GestureDetector gesture={pan}>\n          <Animated.View\n            accessibilityViewIsModal\n            accessibilityLabel={accessibilityLabel}\n            style={[styles.card, panelStyle]}\n            className={`border border-border bg-background ${className}`}\n          >\n            <ModalContext.Provider value={{ close, reduce }}>\n              <Animated.View style={[styles.clip, sizeStyle]}>\n                {items.map((item) => (\n                  <ViewLayer\n                    key={item.key}\n                    appear={item.appear && measured}\n                    exiting={item.exiting}\n                    reduce={reduce}\n                    onMeasure={item.exiting ? undefined : onMeasure}\n                  >\n                    {latest.current.get(item.key) ?? item.value}\n                  </ViewLayer>\n                ))}\n              </Animated.View>\n            </ModalContext.Provider>\n          </Animated.View>\n        </GestureDetector>\n      </View>\n    </Overlay>\n  );\n}\n\n/**\n * One view inside the card. It sits on top of the others and measures its\n * own height, so the card can spring to it while the old view fades out\n * underneath.\n */\nfunction ViewLayer({\n  appear,\n  exiting,\n  reduce,\n  onMeasure,\n  children,\n}: {\n  appear: boolean;\n  exiting: boolean;\n  reduce: boolean;\n  onMeasure?: (height: number) => void;\n  children: ReactNode;\n}) {\n  const shown = useSharedValue(appear ? 0 : 1);\n\n  useEffect(() => {\n    const target = exiting ? 0 : 1;\n    shown.value = withTiming(target, exiting ? VIEW_OUT : VIEW_IN);\n  }, [exiting, shown]);\n\n  const style = useAnimatedStyle(() => {\n    const p = shown.value;\n    if (reduce) return { opacity: p };\n    // In from slightly below, out slightly upward.\n    const offset = exiting ? -10 : 10;\n    return {\n      opacity: p,\n      transform: [{ translateY: (1 - p) * offset }, { scale: 0.98 + 0.02 * p }],\n    };\n  }, [exiting, reduce]);\n\n  return (\n    <Animated.View\n      pointerEvents={exiting ? \"none\" : \"auto\"}\n      accessibilityElementsHidden={exiting}\n      importantForAccessibility={exiting ? \"no-hide-descendants\" : \"auto\"}\n      onLayout={({ nativeEvent }) => onMeasure?.(nativeEvent.layout.height)}\n      style={[styles.view, style]}\n    >\n      {children}\n    </Animated.View>\n  );\n}\n\nexport type MorphingModalHeaderProps = {\n  title?: string;\n  /** Shown before the title, such as a glyph for the view. */\n  icon?: ReactNode;\n  /** Shows a back button that calls this, for views reached from another. */\n  onBack?: () => void;\n  className?: string;\n};\n\n/**\n * The top row of a view: an optional back button and title, and a close\n * button. Every view can use one, so the controls stay put as views change.\n */\nexport function MorphingModalHeader({\n  title,\n  icon,\n  onBack,\n  className = \"\",\n}: MorphingModalHeaderProps) {\n  const { close } = useModalContext(\"MorphingModalHeader\");\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n\n  return (\n    <View className={`mb-4 min-h-8 flex-row items-center gap-3 ${className}`}>\n      {onBack ? (\n        <HeaderButton accessibilityLabel=\"Back\" onPress={onBack}>\n          <ArrowGlyph back size={16} color={muted} strokeWidth={2.2} />\n        </HeaderButton>\n      ) : null}\n      {icon}\n      <Text\n        accessibilityRole=\"header\"\n        numberOfLines={1}\n        className=\"flex-1 text-[17px] font-semibold text-foreground\"\n      >\n        {title}\n      </Text>\n      <HeaderButton accessibilityLabel=\"Close\" onPress={close}>\n        <CrossGlyph size={14} color={muted} strokeWidth={2.4} />\n      </HeaderButton>\n    </View>\n  );\n}\n\nfunction HeaderButton({\n  accessibilityLabel,\n  onPress,\n  children,\n}: {\n  accessibilityLabel: string;\n  onPress: () => void;\n  children: ReactNode;\n}) {\n  return (\n    <Pressable\n      accessibilityRole=\"button\"\n      accessibilityLabel={accessibilityLabel}\n      hitSlop={6}\n      onPress={onPress}\n      className=\"h-8 w-8 items-center justify-center rounded-full bg-foreground/5 active:bg-foreground/10\"\n    >\n      {children}\n    </Pressable>\n  );\n}\n\nconst styles = StyleSheet.create({\n  layer: {\n    ...StyleSheet.absoluteFill,\n    justifyContent: \"flex-end\",\n    alignItems: \"center\",\n    paddingHorizontal: EDGE,\n  },\n  card: {\n    width: \"100%\",\n    maxWidth: 440,\n    borderRadius: 36,\n    borderCurve: \"continuous\",\n    overflow: \"hidden\",\n  },\n  clip: { overflow: \"hidden\" },\n  view: { position: \"absolute\", top: 0, left: 0, right: 0, padding: 20 },\n});\n","type":"component"},{"path":"src/hooks/use-presence.ts","content":"import { useEffect, useRef, useState } from \"react\";\n\nexport type PresenceItem<T> = {\n  key: string;\n  value: T;\n  /** False for the item present on first render, so it does not animate in. */\n  appear: boolean;\n  exiting: boolean;\n};\n\n/**\n * Keeps replaced items mounted while they animate out, like AnimatePresence.\n * A key that returns while exiting is revived in place.\n */\nexport function usePresence<T>(\n  key: string | null,\n  value: T,\n  exitDuration: (value: T) => number,\n) {\n  const [items, setItems] = useState<PresenceItem<T>[]>(() =>\n    key === null ? [] : [{ key, value, appear: false, exiting: false }],\n  );\n  const [currentKey, setCurrentKey] = useState(key);\n  const exitDurationRef = useRef(exitDuration);\n  exitDurationRef.current = exitDuration;\n\n  // Adjust during render so the new item and the exits start in the same frame.\n  if (key !== currentKey) {\n    setCurrentKey(key);\n    setItems((previous) => {\n      const next = previous.map((item) =>\n        item.key === key\n          ? { ...item, value, exiting: false }\n          : { ...item, exiting: true },\n      );\n      if (key !== null && !previous.some((item) => item.key === key)) {\n        next.push({ key, value, appear: true, exiting: false });\n      }\n      return next;\n    });\n  }\n\n  const exitingKeys = items\n    .filter((item) => item.exiting)\n    .map((item) => item.key)\n    .join(\"\\n\");\n\n  useEffect(() => {\n    if (!exitingKeys) return;\n    const timers = items\n      .filter((item) => item.exiting)\n      .map((item) =>\n        setTimeout(() => {\n          setItems((previous) =>\n            previous.filter(\n              (candidate) => !(candidate.key === item.key && candidate.exiting),\n            ),\n          );\n        }, exitDurationRef.current(item.value)),\n      );\n    return () => {\n      for (const timer of timers) clearTimeout(timer);\n    };\n    // Restart removal timers only when the set of exiting items changes.\n  }, [exitingKeys]);\n\n  return items;\n}\n","type":"shared"},{"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/overlay.tsx","content":"import { type ReactNode, useEffect, useRef } from \"react\";\nimport { Modal, Platform, StyleSheet } from \"react-native\";\nimport { GestureHandlerRootView } from \"react-native-gesture-handler\";\nimport { FullWindowOverlay } from \"react-native-screens\";\n\nexport type OverlayProps = {\n  /** Defaults to true. */\n  visible?: boolean;\n  /** The Android back button. iOS has no equivalent. */\n  onRequestClose: () => void;\n  /** Called once the overlay is on screen. */\n  onShow?: () => void;\n  /** Read by VoiceOver as a modal container, so it stays inside. */\n  accessibilityViewIsModal?: boolean;\n  children: ReactNode;\n};\n\n/**\n * A layer above the whole window, for menus, popovers and modals.\n *\n * On iOS a React Native `Modal` is presented from the screen that renders it,\n * so inside native tabs it stops at the tab bar. `FullWindowOverlay` draws in\n * its own window above everything instead. On Android a `Modal` already opens\n * its own window, and handles the back button.\n */\nexport function Overlay({\n  visible = true,\n  onRequestClose,\n  onShow,\n  accessibilityViewIsModal = true,\n  children,\n}: OverlayProps) {\n  // Gestures inside a separate window or a modal need their own root.\n  const content = (\n    <GestureHandlerRootView style={styles.root}>\n      {children}\n    </GestureHandlerRootView>\n  );\n\n  if (Platform.OS === \"ios\") {\n    return visible ? (\n      <IosOverlay onShow={onShow} modal={accessibilityViewIsModal}>\n        {content}\n      </IosOverlay>\n    ) : null;\n  }\n\n  return (\n    <Modal\n      transparent\n      visible={visible}\n      animationType=\"none\"\n      statusBarTranslucent\n      navigationBarTranslucent\n      onShow={onShow}\n      onRequestClose={onRequestClose}\n    >\n      {content}\n    </Modal>\n  );\n}\n\nfunction IosOverlay({\n  onShow,\n  modal,\n  children,\n}: {\n  onShow?: () => void;\n  modal: boolean;\n  children: ReactNode;\n}) {\n  const onShowRef = useRef(onShow);\n  onShowRef.current = onShow;\n  // The overlay is on screen once it mounts.\n  useEffect(() => {\n    onShowRef.current?.();\n  }, []);\n\n  return (\n    <FullWindowOverlay unstable_accessibilityContainerViewIsModal={modal}>\n      {children}\n    </FullWindowOverlay>\n  );\n}\n\nconst styles = StyleSheet.create({ root: { flex: 1 } });\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"}]}