{"slug":"button","name":"Button","description":"Spring press, ripple, icons and a stateful variant.","group":"Actions","detail_url":"https://beui.dev/native/r/button","page_url":"https://beui.dev/react-native/button","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/button?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/button","libraries":["react","react-native","react-native-reanimated","uniwind"],"install":["npx expo install react-native-reanimated 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/button/button.styles.ts","content":"import type { ButtonSize, ButtonVariant } from \"./button.types\";\n\n// Class strings stay literal so Uniwind can discover them.\n\nexport const variants: Record<\n  ButtonVariant,\n  { container: string; label: string }\n> = {\n  primary: { container: \"bg-primary border-primary\", label: \"text-on-primary\" },\n  secondary: {\n    container: \"bg-surface border-border\",\n    label: \"text-foreground\",\n  },\n  ghost: {\n    container: \"bg-transparent border-transparent\",\n    label: \"text-muted\",\n  },\n  outline: {\n    container: \"bg-transparent border-border\",\n    label: \"text-foreground\",\n  },\n};\n\n/**\n * Visible heights match beUI web (32, 40, 48). `hitSlop` tops the touch area up\n * to 48 points; content may grow taller for larger text.\n */\nexport const sizes: Record<\n  ButtonSize,\n  {\n    container: string;\n    radius: string;\n    label: string;\n    icon: number;\n    gap: number;\n    hitSlop: number;\n  }\n> = {\n  sm: {\n    container: \"min-h-8 px-3 py-1 gap-1.5\",\n    radius: \"rounded-full\",\n    label: \"text-xs\",\n    icon: 14,\n    gap: 6,\n    hitSlop: 8,\n  },\n  md: {\n    container: \"min-h-10 px-5 py-2 gap-2\",\n    radius: \"rounded-full\",\n    label: \"text-sm\",\n    icon: 16,\n    gap: 8,\n    hitSlop: 4,\n  },\n  lg: {\n    container: \"min-h-12 px-6 py-2 gap-2\",\n    radius: \"rounded-full\",\n    label: \"text-base\",\n    icon: 18,\n    gap: 8,\n    hitSlop: 0,\n  },\n  icon: {\n    container: \"h-8 w-8\",\n    radius: \"rounded-lg\",\n    label: \"text-sm\",\n    icon: 16,\n    gap: 0,\n    hitSlop: 8,\n  },\n};\n","type":"component"},{"path":"src/components/button/button.tsx","content":"import { createContext, useContext, useEffect, useRef, useState } from \"react\";\nimport {\n  type ColorValue,\n  Pressable,\n  StyleSheet,\n  Text,\n  type TextProps,\n  View,\n} from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { renderIcon } from \"../../primitives/render-icon\";\nimport { EASE_OUT, motion } from \"../../styles/motion\";\nimport { sizes, variants } from \"./button.styles\";\nimport type {\n  ButtonIcon as ButtonIconType,\n  ButtonProps,\n  ButtonRootProps,\n  ButtonSize,\n  ButtonVariant,\n} from \"./button.types\";\n\nconst AnimatedPressable = Animated.createAnimatedComponent(Pressable);\n\nconst HOVER_SCALE = 1.02;\nconst RIPPLE_DURATION = 1600;\n\ntype ButtonContextValue = {\n  variant: ButtonVariant;\n  size: ButtonSize;\n  color: ColorValue | undefined;\n};\n\nconst ButtonContext = createContext<ButtonContextValue | null>(null);\n\n/** Internal: size, variant and resolved foreground color of the enclosing button. */\nexport function useButtonContext() {\n  const context = useContext(ButtonContext);\n  if (!context) throw new Error(\"Button content must be rendered in a Button.\");\n  return context;\n}\n\nexport { renderIcon as renderButtonIcon };\n\nfunction ButtonLabel({ className = \"\", ...props }: TextProps) {\n  const { variant, size } = useButtonContext();\n  return (\n    <Text\n      {...props}\n      className={`shrink text-center font-medium ${sizes[size].label} ${variants[variant].label} ${className}`}\n    />\n  );\n}\n\nfunction ButtonIcon({ icon }: { icon: ButtonIconType }) {\n  const { size, color } = useButtonContext();\n  return (\n    <View\n      accessibilityElementsHidden\n      importantForAccessibility=\"no-hide-descendants\"\n      aria-hidden\n      style={styles.icon}\n    >\n      {renderIcon(icon, { size: sizes[size].icon, color })}\n    </View>\n  );\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nfunction RippleDot({ ripple, color }: { ripple: Ripple; color: ColorValue }) {\n  const progress = useSharedValue(0);\n\n  useEffect(() => {\n    progress.value = withTiming(1, {\n      duration: RIPPLE_DURATION,\n      easing: EASE_OUT,\n    });\n  }, [progress]);\n\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: 0.3 * (1 - progress.value),\n    transform: [{ scale: 0.05 + 0.95 * progress.value }],\n  }));\n\n  return (\n    <Animated.View\n      style={[\n        styles.ripple,\n        {\n          left: ripple.x - ripple.size / 2,\n          top: ripple.y - ripple.size / 2,\n          width: ripple.size,\n          height: ripple.size,\n          borderRadius: ripple.size / 2,\n          backgroundColor: color,\n        },\n        animatedStyle,\n      ]}\n    />\n  );\n}\n\n/** Internal: the pressable surface, rendering arbitrary content. */\nexport function ButtonRoot({\n  variant = \"primary\",\n  size = \"md\",\n  pressScale = 0.93,\n  ripple = false,\n  disableAnimation = false,\n  disabled: disabledProp,\n  children,\n  className = \"\",\n  style,\n  hitSlop,\n  accessibilityState,\n  onPressIn,\n  onPressOut,\n  onHoverIn,\n  onHoverOut,\n  ref,\n  ...props\n}: ButtonRootProps) {\n  const disabled = disabledProp ?? false;\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n  const appearance = variants[variant];\n  const dimensions = sizes[size];\n  const { color } = useResolveClassNames(appearance.label);\n\n  const scale = useSharedValue(1);\n  const pressed = useSharedValue(false);\n  const hovered = useSharedValue(false);\n  const layout = useRef({ width: 0, height: 0 });\n  const nextRippleId = useRef(0);\n  const rippleTimers = useRef(new Set<ReturnType<typeof setTimeout>>());\n  const [ripples, setRipples] = useState<Ripple[]>([]);\n\n  useEffect(() => {\n    const timers = rippleTimers.current;\n    return () => {\n      for (const timer of timers) clearTimeout(timer);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!animate || disabled) scale.value = 1;\n  }, [animate, disabled, scale]);\n\n  const animatedStyle = useAnimatedStyle(\n    () => ({\n      transform: [{ scale: scale.value }],\n      // Without scale, pressing still needs visible feedback.\n      opacity: disabled ? 0.5 : !animate && pressed.value ? 0.7 : 1,\n    }),\n    [animate, disabled],\n  );\n\n  const restScale = () => (hovered.value ? HOVER_SCALE : 1);\n\n  return (\n    <ButtonContext.Provider value={{ variant, size, color }}>\n      <AnimatedPressable\n        {...props}\n        ref={ref}\n        accessible\n        accessibilityRole=\"button\"\n        accessibilityState={{ ...accessibilityState, disabled }}\n        aria-disabled={disabled}\n        disabled={disabled}\n        hitSlop={hitSlop ?? dimensions.hitSlop}\n        // Hugs its content unless the caller places it with a self-* class; on web,\n        // a default self-start would otherwise win over the caller's.\n        className={`${/\\bself-/.test(className) ? \"\" : \"self-start\"} flex-row items-center justify-center border ${appearance.container} ${dimensions.container} ${dimensions.radius} ${className}`}\n        style={[style, animatedStyle]}\n        onPressIn={(event) => {\n          pressed.value = true;\n          if (animate) scale.value = withSpring(pressScale, motion.press);\n          if (ripple && animate && color) {\n            const { locationX, locationY } = event.nativeEvent;\n            const { width, height } = layout.current;\n            const id = nextRippleId.current++;\n            setRipples((current) => [\n              ...current,\n              {\n                id,\n                x: locationX,\n                y: locationY,\n                size: Math.max(width, height) * 2,\n              },\n            ]);\n            const timer = setTimeout(() => {\n              rippleTimers.current.delete(timer);\n              setRipples((current) => current.filter((r) => r.id !== id));\n            }, RIPPLE_DURATION);\n            rippleTimers.current.add(timer);\n          }\n          onPressIn?.(event);\n        }}\n        onPressOut={(event) => {\n          pressed.value = false;\n          if (animate) scale.value = withSpring(restScale(), motion.press);\n          onPressOut?.(event);\n        }}\n        onHoverIn={(event) => {\n          hovered.value = true;\n          if (animate && !pressed.value) {\n            scale.value = withSpring(HOVER_SCALE, motion.press);\n          }\n          onHoverIn?.(event);\n        }}\n        onHoverOut={(event) => {\n          hovered.value = false;\n          if (animate && !pressed.value) {\n            scale.value = withSpring(1, motion.press);\n          }\n          onHoverOut?.(event);\n        }}\n      >\n        {ripple && (\n          <View\n            className={`absolute inset-0 overflow-hidden ${dimensions.radius}`}\n            style={styles.passThrough}\n            onLayout={({ nativeEvent }) => {\n              layout.current = nativeEvent.layout;\n            }}\n          >\n            {color != null &&\n              ripples.map((item) => (\n                <RippleDot key={item.id} ripple={item} color={color} />\n              ))}\n          </View>\n        )}\n        {children}\n      </AnimatedPressable>\n    </ButtonContext.Provider>\n  );\n}\n\nexport function Button(props: ButtonProps) {\n  if (props.size === \"icon\") {\n    const { icon, ...rest } = props;\n    return (\n      <ButtonRoot {...rest}>\n        <ButtonIcon icon={icon} />\n      </ButtonRoot>\n    );\n  }\n  const { startIcon, endIcon, children, ...rest } = props;\n  const label = Array.isArray(children) ? children.join(\"\") : String(children);\n  return (\n    <ButtonRoot {...rest}>\n      {startIcon != null && <ButtonIcon icon={startIcon} />}\n      <ButtonLabel>{label}</ButtonLabel>\n      {endIcon != null && <ButtonIcon icon={endIcon} />}\n    </ButtonRoot>\n  );\n}\n\nconst styles = StyleSheet.create({\n  icon: { flexShrink: 0 },\n  passThrough: { pointerEvents: \"none\" },\n  ripple: { position: \"absolute\" },\n});\n","type":"component"},{"path":"src/components/button/button.types.ts","content":"import type { ReactNode, Ref } from \"react\";\nimport type { PressableProps, StyleProp, View, ViewStyle } from \"react-native\";\nimport type { IconProps, IconSource } from \"../../primitives/render-icon\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\nexport type ButtonIconProps = IconProps;\n/** A component receiving `{ size, color }` (such as a lucide icon), or a prebuilt node. */\nexport type ButtonIcon = IconSource;\n\nexport type ButtonBaseProps = Omit<PressableProps, \"children\" | \"style\"> & {\n  variant?: ButtonVariant;\n  /** Scale while pressed. Defaults to 0.93, matching beUI web. */\n  pressScale?: number;\n  /** Spawn a ripple from the press point. Off by default. */\n  ripple?: boolean;\n  /** Disables press motion independently of the system preference. */\n  disableAnimation?: boolean;\n  style?: StyleProp<ViewStyle>;\n  ref?: Ref<View>;\n};\n\n/** Internal props for the shared pressable surface. */\nexport type ButtonRootProps = ButtonBaseProps & {\n  size?: ButtonSize;\n  children?: ReactNode;\n};\n\n/** Accepts interpolated JSX text such as `Save {count} items`. */\nexport type ButtonText = string | number | readonly (string | number)[];\n\nexport type ButtonProps = ButtonBaseProps &\n  (\n    | {\n        size?: Exclude<ButtonSize, \"icon\">;\n        children: ButtonText;\n        startIcon?: ButtonIcon;\n        endIcon?: ButtonIcon;\n        icon?: never;\n      }\n    | {\n        size: \"icon\";\n        icon: ButtonIcon;\n        /** Icon buttons have no visible text, so they need a spoken name. */\n        accessibilityLabel: string;\n        children?: never;\n        startIcon?: never;\n        endIcon?: never;\n      }\n  );\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport type StatefulButtonProps = ButtonBaseProps & {\n  size?: Exclude<ButtonSize, \"icon\">;\n  state?: ButtonState;\n  /** The idle label. Latin labels cascade letter by letter; other scripts slide as a whole. */\n  children: string;\n  loadingText?: string;\n  successText?: string;\n  errorText?: string;\n  /** Trailing icon shown only while idle. */\n  icon?: ButtonIcon;\n  loadingIcon?: ButtonIcon;\n  successIcon?: ButtonIcon;\n  errorIcon?: ButtonIcon;\n};\n","type":"component"},{"path":"src/components/button/index.ts","content":"export { Button } from \"./button\";\nexport type {\n  ButtonBaseProps,\n  ButtonIcon,\n  ButtonIconProps,\n  ButtonProps,\n  ButtonSize,\n  ButtonState,\n  ButtonText,\n  ButtonVariant,\n  StatefulButtonProps,\n} from \"./button.types\";\nexport { StatefulButton } from \"./stateful-button\";\nexport { CheckIcon, CrossIcon, Spinner } from \"./status-icons\";\n","type":"component"},{"path":"src/components/button/stateful-button.tsx","content":"import { useEffect, useRef } from \"react\";\nimport { AccessibilityInfo, StyleSheet, View } from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { usePresence } from \"../../hooks/use-presence\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { CascadeText } from \"../../primitives/cascade-text\";\nimport { motion } from \"../../styles/motion\";\nimport { ButtonRoot, renderButtonIcon, useButtonContext } from \"./button\";\nimport { sizes, variants } from \"./button.styles\";\nimport type {\n  ButtonIcon,\n  ButtonState,\n  StatefulButtonProps,\n} from \"./button.types\";\nimport { CheckIcon, CrossIcon, Spinner } from \"./status-icons\";\n\nfunction IconSlot({\n  icon,\n  appear,\n  exiting,\n  side,\n  reduce,\n}: {\n  icon: ButtonIcon;\n  appear: boolean;\n  exiting: boolean;\n  side: \"start\" | \"end\";\n  reduce: boolean;\n}) {\n  const { size, color } = useButtonContext();\n  const { icon: iconSize, gap } = sizes[size];\n  const slot = iconSize + gap;\n  const progress = useSharedValue(appear ? 0 : 1);\n\n  useEffect(() => {\n    progress.value = reduce\n      ? withTiming(exiting ? 0 : 1, motion.fade)\n      : exiting\n        ? withTiming(0, motion.exit)\n        : withSpring(1, motion.swap);\n  }, [exiting, progress, reduce]);\n\n  const slotStyle = useAnimatedStyle(\n    () => ({\n      // Width collapses with the icon so the row never pops in a single frame.\n      width: reduce ? (exiting ? 0 : slot) : Math.max(0, slot * progress.value),\n    }),\n    [exiting, reduce, slot],\n  );\n  const iconStyle = useAnimatedStyle(\n    () => ({\n      opacity: Math.min(1, Math.max(0, progress.value)),\n      transform: [{ scale: reduce ? 1 : 0.7 + 0.3 * progress.value }],\n    }),\n    [reduce],\n  );\n\n  return (\n    <Animated.View\n      style={[\n        styles.iconSlot,\n        side === \"start\" ? styles.alignStart : styles.alignEnd,\n        slotStyle,\n      ]}\n    >\n      <Animated.View style={iconStyle}>\n        {renderButtonIcon(icon, { size: iconSize, color })}\n      </Animated.View>\n    </Animated.View>\n  );\n}\n\nfunction StatefulContent({\n  state,\n  text,\n  icon,\n  statusIcons,\n}: {\n  state: ButtonState;\n  text: string;\n  icon: ButtonIcon | undefined;\n  statusIcons: Record<Exclude<ButtonState, \"idle\">, ButtonIcon>;\n}) {\n  const reduce = useReducedMotionPreference();\n  const { size, variant } = useButtonContext();\n  const leading = usePresence(\n    state === \"idle\" ? null : state,\n    state,\n    () => motion.exit.duration + 40,\n  );\n  const trailing = usePresence(\n    state === \"idle\" && icon != null ? \"idle\" : null,\n    icon,\n    () => motion.exit.duration + 40,\n  );\n\n  return (\n    <View\n      accessibilityElementsHidden\n      importantForAccessibility=\"no-hide-descendants\"\n      style={styles.content}\n    >\n      {leading.map((item) =>\n        item.value === \"idle\" ? null : (\n          <IconSlot\n            key={item.key}\n            icon={statusIcons[item.value]}\n            appear={item.appear}\n            exiting={item.exiting}\n            side=\"start\"\n            reduce={reduce}\n          />\n        ),\n      )}\n      <CascadeText\n        textKey={`${state}-${text}`}\n        text={text}\n        className={`font-medium ${sizes[size].label} ${variants[variant].label}`}\n        reduce={reduce}\n      />\n      {trailing.map((item) => (\n        <IconSlot\n          key={item.key}\n          icon={item.value}\n          appear={item.appear}\n          exiting={item.exiting}\n          side=\"end\"\n          reduce={reduce}\n        />\n      ))}\n    </View>\n  );\n}\n\nexport function StatefulButton({\n  state = \"idle\",\n  children,\n  loadingText = \"Loading\",\n  successText = \"Done\",\n  errorText = \"Try again\",\n  icon,\n  loadingIcon = Spinner,\n  successIcon = CheckIcon,\n  errorIcon = CrossIcon,\n  disabled,\n  accessibilityLabel,\n  accessibilityState,\n  ...props\n}: StatefulButtonProps) {\n  const busy = state === \"loading\";\n  const text =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n          ? errorText\n          : children;\n  const previousState = useRef(state);\n\n  // The button keeps focus while its name changes, so announce status changes explicitly.\n  useEffect(() => {\n    if (previousState.current === state) return;\n    previousState.current = state;\n    if (state !== \"idle\") AccessibilityInfo.announceForAccessibility(text);\n  }, [state, text]);\n\n  return (\n    <ButtonRoot\n      {...props}\n      disabled={disabled || busy}\n      accessibilityLabel={accessibilityLabel ?? text}\n      accessibilityState={{ ...accessibilityState, busy }}\n      aria-busy={busy}\n    >\n      <StatefulContent\n        state={state}\n        text={text}\n        icon={icon}\n        statusIcons={{\n          loading: loadingIcon,\n          success: successIcon,\n          error: errorIcon,\n        }}\n      />\n    </ButtonRoot>\n  );\n}\n\nconst styles = StyleSheet.create({\n  content: { flexDirection: \"row\", alignItems: \"center\", flexShrink: 1 },\n  iconSlot: {\n    flexDirection: \"row\",\n    alignItems: \"center\",\n    overflow: \"hidden\",\n    flexShrink: 0,\n  },\n  alignStart: { justifyContent: \"flex-start\" },\n  alignEnd: { justifyContent: \"flex-end\" },\n});\n","type":"component"},{"path":"src/components/button/status-icons.tsx","content":"import { useEffect } from \"react\";\nimport { StyleSheet, View } from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  Easing,\n  ReduceMotion,\n  useAnimatedStyle,\n  useSharedValue,\n  withRepeat,\n  withTiming,\n} from \"react-native-reanimated\";\nimport type { ButtonIconProps } from \"./button.types\";\n\n// Dependency-free status glyphs drawn with borders, so the button works with any icon set.\n\nfunction strokeFor(size: number) {\n  return Math.max(1.5, size / 9);\n}\n\n/** A three-quarter ring that keeps turning; progress is essential, so it ignores reduced motion. */\nexport function Spinner({ size, color }: ButtonIconProps) {\n  const rotation = useSharedValue(0);\n\n  useEffect(() => {\n    rotation.value = withRepeat(\n      withTiming(360, {\n        duration: 800,\n        easing: Easing.linear,\n        reduceMotion: ReduceMotion.Never,\n      }),\n      -1,\n      false,\n    );\n    return () => cancelAnimation(rotation);\n  }, [rotation]);\n\n  const animatedStyle = useAnimatedStyle(() => ({\n    transform: [{ rotate: `${rotation.value}deg` }],\n  }));\n\n  const ring = size * 0.8;\n  return (\n    <View style={[styles.box, { width: size, height: size }]}>\n      <Animated.View\n        style={[\n          {\n            width: ring,\n            height: ring,\n            borderRadius: ring / 2,\n            borderWidth: strokeFor(size),\n            borderColor: color,\n            borderTopColor: \"transparent\",\n          },\n          animatedStyle,\n        ]}\n      />\n    </View>\n  );\n}\n\nexport function CheckIcon({ size, color }: ButtonIconProps) {\n  const stroke = strokeFor(size);\n  return (\n    <View style={[styles.box, { width: size, height: size }]}>\n      <View\n        style={{\n          width: size * 0.3,\n          height: size * 0.6,\n          marginTop: -size * 0.12,\n          borderRightWidth: stroke,\n          borderBottomWidth: stroke,\n          borderColor: color,\n          borderBottomRightRadius: stroke / 2,\n          transform: [{ rotate: \"45deg\" }],\n        }}\n      />\n    </View>\n  );\n}\n\nexport function CrossIcon({ size, color }: ButtonIconProps) {\n  const stroke = strokeFor(size);\n  const bar = {\n    position: \"absolute\" as const,\n    width: size * 0.7,\n    height: stroke,\n    borderRadius: stroke / 2,\n    backgroundColor: color,\n  };\n  return (\n    <View style={[styles.box, { width: size, height: size }]}>\n      <View style={[bar, { transform: [{ rotate: \"45deg\" }] }]} />\n      <View style={[bar, { transform: [{ rotate: \"-45deg\" }] }]} />\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  box: { alignItems: \"center\", justifyContent: \"center\" },\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/cascade-text.tsx","content":"import { type ReactNode, useEffect, useState } from \"react\";\nimport { StyleSheet, Text, View } from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { usePresence } from \"../hooks/use-presence\";\nimport { motion } from \"../styles/motion\";\n\nconst CASCADE_STAGGER = 25;\nconst TEXT_DISTANCE = 14;\n// Splitting into letters breaks shaping in connected and complex scripts.\nconst CASCADE_SAFE = /^[\\u0020-\\u007e\\u00a0-\\u024f]+$/;\n\nfunction letters(text: string) {\n  // Whitespace-only Text can measure zero-width on Android.\n  return Array.from(text.replace(/ /g, \"\\u00a0\"));\n}\n\nfunction exitDuration(text: string, cascade: boolean) {\n  const stagger = cascade ? (text.length - 1) * CASCADE_STAGGER * 0.5 : 0;\n  return motion.exit.duration + stagger + 40;\n}\n\n/** Progress runs 0 (below) → 1 (resting) → 2 (exited above). */\nfunction Slide({\n  appear,\n  exiting,\n  distance,\n  delay,\n  reduce,\n  children,\n}: {\n  appear: boolean;\n  exiting: boolean;\n  distance: number;\n  delay: number;\n  reduce: boolean;\n  children: ReactNode;\n}) {\n  const progress = useSharedValue(appear ? 0 : 1);\n\n  useEffect(() => {\n    if (reduce) {\n      progress.value = withTiming(exiting ? 2 : 1, motion.fade);\n    } else if (exiting) {\n      progress.value = withDelay(delay / 2, withTiming(2, motion.exit));\n    } else {\n      progress.value = withDelay(delay, withSpring(1, motion.swap));\n    }\n  }, [delay, exiting, progress, reduce]);\n\n  const animatedStyle = useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      opacity: Math.min(1, Math.max(0, p <= 1 ? p : 2 - p)),\n      transform: [{ translateY: reduce ? 0 : (1 - p) * distance }],\n    };\n  }, [distance, reduce]);\n\n  return <Animated.View style={animatedStyle}>{children}</Animated.View>;\n}\n\nfunction Label({\n  text,\n  cascade,\n  className,\n}: {\n  text: string;\n  cascade: boolean;\n  className: string;\n}) {\n  return cascade ? (\n    letters(text).map((char, index) => (\n      <Text key={index} className={className}>\n        {char}\n      </Text>\n    ))\n  ) : (\n    <Text numberOfLines={1} className={className}>\n      {text}\n    </Text>\n  );\n}\n\nexport type CascadeTextProps = {\n  /** Changing the key swaps in `text`, even when the text itself repeats. */\n  textKey: string;\n  text: string;\n  /** Text style applied to every letter. */\n  className: string;\n  reduce: boolean;\n};\n\n/**\n * Letter-by-letter slot roll: old letters roll up and away as new ones spring\n * in from below, left to right, while the width springs to fit. Shared by\n * StatefulButton and TextCascade.\n */\nexport function CascadeText({\n  textKey,\n  text,\n  className,\n  reduce,\n}: CascadeTextProps) {\n  const canCascade = (value: string) => !reduce && CASCADE_SAFE.test(value);\n  const items = usePresence(textKey, text, (value) =>\n    reduce ? motion.fade.duration + 40 : exitDuration(value, canCascade(value)),\n  );\n  const [measured, setMeasured] = useState<{\n    width: number;\n    height: number;\n  } | null>(null);\n  const width = useSharedValue(0);\n\n  const widthStyle = useAnimatedStyle(() => ({ width: width.value }));\n  const labelClass = className;\n  const distance = measured ? measured.height * 1.05 : 20;\n\n  return (\n    <Animated.View\n      style={[\n        styles.textSlot,\n        measured && { height: measured.height },\n        measured && widthStyle,\n      ]}\n    >\n      {/* Measures the incoming label with the same per-letter layout it renders with. */}\n      <View style={styles.measureHost}>\n        <View\n          style={styles.row}\n          onLayout={({ nativeEvent: { layout } }) => {\n            if (measured) {\n              width.value = reduce\n                ? layout.width\n                : withSpring(layout.width, motion.swap);\n            } else {\n              width.value = layout.width;\n            }\n            setMeasured({ width: layout.width, height: layout.height });\n          }}\n        >\n          <Label text={text} cascade={canCascade(text)} className={className} />\n        </View>\n      </View>\n\n      {items.map((item) => {\n        const cascade = canCascade(item.value);\n        return (\n          <View key={item.key} style={[styles.row, measured && styles.layer]}>\n            {cascade ? (\n              letters(item.value).map((char, index) => (\n                <Slide\n                  key={index}\n                  appear={item.appear}\n                  exiting={item.exiting}\n                  distance={distance}\n                  delay={index * CASCADE_STAGGER}\n                  reduce={reduce}\n                >\n                  <Text className={labelClass}>{char}</Text>\n                </Slide>\n              ))\n            ) : (\n              <Slide\n                appear={item.appear}\n                exiting={item.exiting}\n                distance={TEXT_DISTANCE}\n                delay={0}\n                reduce={reduce}\n              >\n                <Text numberOfLines={1} className={labelClass}>\n                  {item.value}\n                </Text>\n              </Slide>\n            )}\n          </View>\n        );\n      })}\n    </Animated.View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  textSlot: { overflow: \"hidden\", flexShrink: 0 },\n  row: { flexDirection: \"row\", alignItems: \"center\" },\n  layer: { position: \"absolute\", left: 0, top: 0 },\n  measureHost: {\n    position: \"absolute\",\n    left: 0,\n    top: 0,\n    width: 10000,\n    flexDirection: \"row\",\n    alignItems: \"flex-start\",\n    opacity: 0,\n    pointerEvents: \"none\",\n  },\n});\n","type":"shared"},{"path":"src/primitives/render-icon.tsx","content":"import {\n  type ComponentType,\n  createElement,\n  isValidElement,\n  type ReactNode,\n} from \"react\";\nimport type { ColorValue } from \"react-native\";\n\nexport type IconProps = { size: number; color: ColorValue | undefined };\n/** A component receiving `{ size, color }` (such as a lucide icon), or a prebuilt node. */\nexport type IconSource = ComponentType<IconProps> | ReactNode;\n\nexport function renderIcon(icon: IconSource, props: IconProps) {\n  if (typeof icon === \"function\") return createElement(icon, props);\n  // forwardRef and memo components (lucide-react-native, for example) are objects.\n  if (\n    icon != null &&\n    typeof icon === \"object\" &&\n    !isValidElement(icon) &&\n    \"$$typeof\" in icon\n  ) {\n    return createElement(icon as unknown as ComponentType<IconProps>, props);\n  }\n  return icon;\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","type":"theme"}]}