{"slug":"swipeable-list","name":"Swipeable List","description":"Rows that swipe to reveal actions.","group":"Navigation and lists","detail_url":"https://beui.dev/native/r/swipeable-list","page_url":"https://beui.dev/react-native/swipeable-list","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/swipeable-list?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/swipeable-list","libraries":["react","react-native","react-native-gesture-handler","react-native-reanimated","react-native-worklets","uniwind"],"install":["npx expo install react-native-gesture-handler 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/swipeable-list/index.ts","content":"export type {\n  SwipeAction,\n  SwipeActionTone,\n  SwipeableListClassNames,\n  SwipeableListItem,\n  SwipeableListProps,\n  SwipeableListValue,\n  SwipeSide,\n} from \"./swipeable-list\";\nexport { SwipeableList } from \"./swipeable-list\";\n","type":"component"},{"path":"src/components/swipeable-list/swipeable-list.tsx","content":"import {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  type AccessibilityActionEvent,\n  type ColorValue,\n  Pressable,\n  StyleSheet,\n  Text,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\";\nimport Animated, {\n  cancelAnimation,\n  type ExitAnimationsValues,\n  FadeIn,\n  interpolate,\n  LayoutAnimationConfig,\n  LinearTransition,\n  ReduceMotion,\n  type SharedValue,\n  useAnimatedStyle,\n  useSharedValue,\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 { type IconSource, renderIcon } from \"../../primitives/render-icon\";\nimport { EASE_OUT } from \"../../styles/motion\";\n\nexport type SwipeSide = \"left\" | \"right\";\n\nexport type SwipeableListValue = { id: string; side: SwipeSide };\n\nexport type SwipeActionTone =\n  | \"neutral\"\n  | \"primary\"\n  | \"success\"\n  | \"warning\"\n  | \"danger\";\n\nexport type SwipeAction = {\n  id: string;\n  /** Shown under the icon, and the spoken name. */\n  label: string;\n  /** A component given `{ size, color }`, or a prebuilt element. */\n  icon: IconSource;\n  /** Fill color of the action. Defaults to neutral. */\n  tone?: SwipeActionTone;\n  disabled?: boolean;\n  onPress?: (item: SwipeableListItem) => void;\n};\n\nexport type SwipeableListItem = {\n  id: string;\n  title?: string;\n  description?: string;\n  meta?: string;\n  leading?: ReactNode;\n  /** Replaces the default title, description and meta layout. */\n  content?: ReactNode;\n  /** Revealed by swiping right. The first is outermost and runs on a full swipe. */\n  leftActions?: SwipeAction[];\n  /** Revealed by swiping left. The last is outermost and runs on a full swipe. */\n  rightActions?: SwipeAction[];\n  disabled?: boolean;\n};\n\nexport type SwipeableListClassNames = {\n  root?: string;\n  item?: string;\n  surface?: string;\n  title?: string;\n  description?: string;\n  meta?: string;\n};\n\nexport type SwipeableListProps = Omit<ViewProps, \"children\"> & {\n  items: SwipeableListItem[];\n  /** The open row. Omit it and use `defaultValue` for uncontrolled use. */\n  value?: SwipeableListValue | null;\n  defaultValue?: SwipeableListValue | null;\n  onValueChange?: (value: SwipeableListValue | null) => void;\n  onAction?: (payload: {\n    item: SwipeableListItem;\n    action: SwipeAction;\n    side: SwipeSide;\n    /** True when a full swipe ran the action rather than a tap. */\n    fullSwipe: boolean;\n  }) => void;\n  /** Called when a row is tapped while closed. */\n  onItemPress?: (item: SwipeableListItem) => void;\n  /**\n   * Called as a drag crosses the full-swipe point in either direction, the\n   * moment to play a haptic. `armed` is true when releasing would run the action.\n   */\n  onSwipeThreshold?: (payload: {\n    item: SwipeableListItem;\n    side: SwipeSide;\n    armed: boolean;\n  }) => void;\n  /** Width of each action when a row rests open, in points. Defaults to 72. */\n  actionWidth?: number;\n  /** Swiping far enough runs the outermost action. Defaults to true. */\n  fullSwipe?: boolean;\n  /** Closes the row after a tapped action runs. Defaults to true. */\n  closeOnAction?: boolean;\n  /** Space between rows in points. Defaults to 8. */\n  gap?: number;\n  /** Settles rows without springing, independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  classNames?: SwipeableListClassNames;\n  renderItem?: (item: SwipeableListItem) => ReactNode;\n};\n\n// Settles quickly with a hint of give, like the system list rows.\nconst SETTLE = {\n  stiffness: 420,\n  damping: 38,\n  mass: 0.9,\n  reduceMotion: ReduceMotion.System,\n};\n// The outermost action grows to fill and shrinks back on a livelier spring.\nconst ARM = {\n  stiffness: 520,\n  damping: 30,\n  mass: 0.7,\n  reduceMotion: ReduceMotion.System,\n};\nconst COMMIT = { duration: 220, easing: EASE_OUT };\nconst OPEN_VELOCITY = 600;\nconst CLOSE_VELOCITY = 300;\nconst OPEN_DISTANCE_RATIO = 0.4;\nconst FLING_DISTANCE = 14;\n// A full swipe arms once the row is this far past its resting actions, and at\n// least this fraction of the row width.\nconst ARM_OVERSHOOT = 64;\nconst ARM_RATIO = 0.55;\n// How long a fully swiped row waits off screen for its removal before returning.\nconst RETURN_DELAY = 320;\nconst ICON = 20;\n// The row's corner radius, which action panels tuck under.\nconst CORNER = 16;\n\nconst FILLS: Record<SwipeActionTone, string> = {\n  neutral: \"bg-muted\",\n  primary: \"bg-primary\",\n  success: \"bg-success\",\n  warning: \"bg-warning\",\n  danger: \"bg-destructive\",\n};\n\n/** iOS-style resistance: further pulls move less, approaching `dimension`. */\nfunction rubberBand(overflow: number, dimension: number) {\n  \"worklet\";\n  const sign = overflow < 0 ? -1 : 1;\n  const distance = Math.abs(overflow);\n  return sign * ((distance * dimension * 0.55) / (dimension + 0.55 * distance));\n}\n\nfunction ActionPanel({\n  action,\n  index,\n  count,\n  side,\n  x,\n  rowWidth,\n  arm,\n  open,\n  onPress,\n}: {\n  action: SwipeAction;\n  index: number;\n  count: number;\n  side: SwipeSide;\n  x: SharedValue<number>;\n  rowWidth: SharedValue<number>;\n  arm: SharedValue<number>;\n  open: boolean;\n  onPress: () => void;\n}) {\n  const { backgroundColor } = useResolveClassNames(\n    FILLS[action.tone ?? \"neutral\"],\n  );\n  const { color } = useResolveClassNames(\"text-on-primary\");\n  // The outermost action sits at the screen edge and owns the full swipe.\n  const outer = side === \"left\" ? 0 : count - 1;\n\n  // The panel beside the row reaches under its rounded corners, so the corner\n  // notches show the action's color rather than whatever is behind the list.\n  const inner = side === \"left\" ? count - 1 : 0;\n\n  const panelStyle = useAnimatedStyle(() => {\n    const revealed = Math.max(0, side === \"left\" ? x.value : -x.value);\n    const share = revealed / count;\n    const a = arm.value;\n    const widthOf = (i: number) =>\n      i === outer ? share + (revealed - share) * a : share * (1 - a);\n    let offset = 0;\n    for (let i = 0; i < index; i += 1) offset += widthOf(i);\n    const width = widthOf(index);\n    // Grows from nothing so no color shows while the row is at rest.\n    const tuck = Math.min(CORNER, revealed);\n    const multi = count > 1;\n\n    if (side === \"right\") {\n      const edge = rowWidth.value - revealed;\n      let start = edge + offset;\n      if (index === inner) start -= tuck;\n      // While armed, the outer action takes over the edge beside the row.\n      if (index === outer && multi) start += (edge - tuck - start) * a;\n      const end = edge + offset + width;\n      return {\n        left: start,\n        width: end - start,\n        zIndex: index === outer ? 1 : 0,\n        paddingLeft: Math.max(0, edge - start),\n        paddingRight: 0,\n      };\n    }\n\n    const edge = revealed;\n    const start = offset;\n    let end = offset + width;\n    if (index === inner) end += tuck;\n    if (index === outer && multi) end += (edge + tuck - end) * a;\n    return {\n      left: start,\n      width: end - start,\n      zIndex: index === outer ? 1 : 0,\n      paddingLeft: 0,\n      paddingRight: Math.max(0, end - edge),\n    };\n  });\n\n  const contentStyle = useAnimatedStyle(() => {\n    const revealed = Math.max(0, side === \"left\" ? x.value : -x.value);\n    const share = revealed / count;\n    const width =\n      index === outer\n        ? share + (revealed - share) * arm.value\n        : share * (1 - arm.value);\n    // Content keeps its size and fades in as its panel makes room for it.\n    const shown = interpolate(width, [24, 64], [0, 1], \"clamp\");\n    return {\n      opacity: shown,\n      transform: [{ scale: 0.7 + 0.3 * shown }],\n    };\n  });\n\n  // While armed, the outer action's content rides the row's edge.\n  const alignStyle = useAnimatedStyle(() => {\n    if (index !== outer) return { alignItems: \"center\" as const };\n    return {\n      alignItems:\n        arm.value > 0.5\n          ? side === \"left\"\n            ? (\"flex-end\" as const)\n            : (\"flex-start\" as const)\n          : (\"center\" as const),\n    };\n  });\n\n  return (\n    <Animated.View\n      style={[styles.panel, { backgroundColor }, panelStyle]}\n      accessibilityElementsHidden={!open}\n      importantForAccessibility={open ? \"auto\" : \"no-hide-descendants\"}\n    >\n      <Pressable\n        accessibilityRole=\"button\"\n        accessibilityLabel={action.label}\n        disabled={action.disabled || !open}\n        onPress={onPress}\n        style={[styles.press, action.disabled && styles.disabled]}\n      >\n        <Animated.View style={[styles.panelInner, alignStyle]}>\n          <Animated.View style={[styles.actionContent, contentStyle]}>\n            {renderIcon(action.icon, { size: ICON, color })}\n            <Text\n              numberOfLines={1}\n              className=\"text-[11px] font-semibold text-on-primary\"\n            >\n              {action.label}\n            </Text>\n          </Animated.View>\n        </Animated.View>\n      </Pressable>\n    </Animated.View>\n  );\n}\n\nfunction Row({\n  item,\n  index,\n  gap,\n  actionWidth,\n  fullSwipe,\n  openValue,\n  setOpenValue,\n  closeOnAction,\n  onAction,\n  onItemPress,\n  onSwipeThreshold,\n  reduce,\n  pressedColor,\n  classNames,\n  renderItem,\n}: {\n  item: SwipeableListItem;\n  index: number;\n  gap: number;\n  actionWidth: number;\n  fullSwipe: boolean;\n  openValue: SwipeableListValue | null;\n  setOpenValue: (value: SwipeableListValue | null) => void;\n  closeOnAction: boolean;\n  onAction: SwipeableListProps[\"onAction\"];\n  onItemPress: SwipeableListProps[\"onItemPress\"];\n  onSwipeThreshold: SwipeableListProps[\"onSwipeThreshold\"];\n  reduce: boolean;\n  pressedColor: ColorValue | undefined;\n  classNames?: SwipeableListClassNames;\n  renderItem?: (item: SwipeableListItem) => ReactNode;\n}) {\n  const leftActions = item.leftActions ?? [];\n  const rightActions = item.rightActions ?? [];\n  const leftWidth = leftActions.length * actionWidth;\n  const rightWidth = rightActions.length * actionWidth;\n  const openSide = openValue?.id === item.id ? openValue.side : null;\n  const otherOpen = openValue !== null && openValue.id !== item.id;\n  const restFor = useCallback(\n    (side: SwipeSide | null) =>\n      side === \"left\" ? leftWidth : side === \"right\" ? -rightWidth : 0,\n    [leftWidth, rightWidth],\n  );\n\n  const x = useSharedValue(restFor(openSide));\n  const start = useSharedValue(0);\n  const rowWidth = useSharedValue(0);\n  const arm = useSharedValue(0);\n  const armed = useSharedValue(false);\n  const pressed = useSharedValue(0);\n  // The position this row last settled toward, so prop echoes don't re-animate it.\n  const commanded = useRef(restFor(openSide));\n  const mounted = useRef(true);\n  useEffect(\n    () => () => {\n      mounted.current = false;\n    },\n    [],\n  );\n\n  const settle = useCallback(\n    (target: number, velocity = 0) => {\n      \"worklet\";\n      x.value = reduce\n        ? target\n        : withSpring(target, { ...SETTLE, velocity: velocity * 0.6 });\n    },\n    [reduce, x],\n  );\n\n  const disarm = useCallback(() => {\n    \"worklet\";\n    armed.value = false;\n    arm.value = reduce ? 0 : withSpring(0, ARM);\n  }, [arm, armed, reduce]);\n\n  // Follow outside changes, such as another row opening and closing this one.\n  useEffect(() => {\n    const target = restFor(openSide);\n    if (commanded.current === target) return;\n    commanded.current = target;\n    settle(target);\n  }, [openSide, restFor, settle]);\n\n  const commit = useCallback(\n    (side: SwipeSide | null) => {\n      commanded.current = restFor(side);\n      setOpenValue(side ? { id: item.id, side } : null);\n    },\n    [item.id, restFor, setOpenValue],\n  );\n\n  const closeOthers = useCallback(() => setOpenValue(null), [setOpenValue]);\n\n  const runAction = useCallback(\n    (action: SwipeAction, side: SwipeSide, full: boolean) => {\n      if (action.disabled) return;\n      action.onPress?.(item);\n      onAction?.({ item, action, side, fullSwipe: full });\n      if (full || closeOnAction) {\n        commanded.current = 0;\n        setOpenValue(null);\n      }\n      if (!full && closeOnAction) settle(0);\n    },\n    [closeOnAction, item, onAction, setOpenValue, settle],\n  );\n\n  // A full swipe leaves the row off screen; if the action removed it, its exit\n  // animation collapses it from there. Otherwise it returns to rest.\n  const completeFullSwipe = useCallback(\n    (side: SwipeSide) => {\n      const action =\n        side === \"left\"\n          ? leftActions[0]\n          : rightActions[rightActions.length - 1];\n      if (action) runAction(action, side, true);\n      setTimeout(() => {\n        if (!mounted.current) return;\n        settle(0);\n        disarm();\n      }, RETURN_DELAY);\n    },\n    [disarm, leftActions, rightActions, runAction, settle],\n  );\n\n  const signalThreshold = useCallback(\n    (side: SwipeSide, isArmed: boolean) =>\n      onSwipeThreshold?.({ item, side, armed: isArmed }),\n    [item, onSwipeThreshold],\n  );\n\n  const canFullLeft = fullSwipe && leftActions.some((a) => !a.disabled);\n  const canFullRight = fullSwipe && rightActions.some((a) => !a.disabled);\n\n  const pan = Gesture.Pan()\n    .enabled(!item.disabled)\n    // Horizontal intent claims the row; vertical intent leaves the scroll alone.\n    .activeOffsetX([-10, 10])\n    .failOffsetY([-10, 10])\n    .onStart(() => {\n      cancelAnimation(x);\n      start.value = x.value;\n      if (otherOpen) scheduleOnRN(closeOthers);\n    })\n    .onUpdate((event) => {\n      const raw = start.value + event.translationX;\n      const width = rowWidth.value || 1;\n      // Full swipes travel freely to the row's edge; otherwise resistance\n      // starts after the last action.\n      const maxLeft = leftWidth > 0 ? (canFullLeft ? width : leftWidth) : 0;\n      const maxRight = rightWidth > 0 ? (canFullRight ? width : rightWidth) : 0;\n      x.value =\n        raw > maxLeft\n          ? maxLeft + rubberBand(raw - maxLeft, width)\n          : raw < -maxRight\n            ? -maxRight + rubberBand(raw + maxRight, width)\n            : raw;\n\n      const side: SwipeSide | null =\n        x.value > 0 ? \"left\" : x.value < 0 ? \"right\" : null;\n      const sideWidth = side === \"left\" ? leftWidth : rightWidth;\n      const allowed = side === \"left\" ? canFullLeft : canFullRight;\n      const threshold = Math.max(sideWidth + ARM_OVERSHOOT, width * ARM_RATIO);\n      const nowArmed =\n        side !== null && allowed && Math.abs(x.value) > threshold;\n      if (nowArmed !== armed.value) {\n        armed.value = nowArmed;\n        arm.value = reduce\n          ? nowArmed\n            ? 1\n            : 0\n          : withSpring(nowArmed ? 1 : 0, ARM);\n        if (side) scheduleOnRN(signalThreshold, side, nowArmed);\n      }\n    })\n    .onEnd((event) => {\n      const velocity = event.velocityX;\n      const latest = x.value;\n      const width = rowWidth.value || 1;\n\n      if (armed.value) {\n        const side: SwipeSide = latest > 0 ? \"left\" : \"right\";\n        x.value = reduce\n          ? side === \"left\"\n            ? width\n            : -width\n          : withTiming(side === \"left\" ? width : -width, COMMIT);\n        scheduleOnRN(completeFullSwipe, side);\n        return;\n      }\n\n      let side: SwipeSide | null = null;\n      if (openSide === \"left\") {\n        side =\n          latest < leftWidth * 0.6 || velocity < -CLOSE_VELOCITY\n            ? null\n            : \"left\";\n      } else if (openSide === \"right\") {\n        side =\n          -latest < rightWidth * 0.6 || velocity > CLOSE_VELOCITY\n            ? null\n            : \"right\";\n      } else if (\n        leftWidth > 0 &&\n        latest > 0 &&\n        (latest > leftWidth * OPEN_DISTANCE_RATIO ||\n          (velocity > OPEN_VELOCITY && latest > FLING_DISTANCE))\n      ) {\n        side = \"left\";\n      } else if (\n        rightWidth > 0 &&\n        latest < 0 &&\n        (-latest > rightWidth * OPEN_DISTANCE_RATIO ||\n          (velocity < -OPEN_VELOCITY && latest < -FLING_DISTANCE))\n      ) {\n        side = \"right\";\n      }\n\n      settle(\n        side === \"left\" ? leftWidth : side === \"right\" ? -rightWidth : 0,\n        velocity,\n      );\n      scheduleOnRN(commit, side);\n    });\n\n  const pressItem = useCallback(() => onItemPress?.(item), [item, onItemPress]);\n\n  // A tap closes an open row, as in native mail lists, or presses a closed one.\n  const tap = Gesture.Tap()\n    .enabled(!item.disabled && (openSide !== null || onItemPress !== undefined))\n    .onBegin(() => {\n      if (openSide === null) {\n        pressed.value = withTiming(1, { duration: 80 });\n      }\n    })\n    .onFinalize(() => {\n      pressed.value = withTiming(0, { duration: 200 });\n    })\n    .onEnd(() => {\n      if (openSide !== null) {\n        settle(0);\n        scheduleOnRN(commit, null);\n      } else {\n        scheduleOnRN(pressItem);\n      }\n    });\n\n  const surfaceStyle = useAnimatedStyle(() => ({\n    transform: [{ translateX: x.value }],\n  }));\n  const highlightStyle = useAnimatedStyle(() => ({\n    opacity: pressed.value,\n  }));\n\n  const accessibilityActions = useMemo(\n    () =>\n      [...leftActions, ...rightActions]\n        .filter((action) => !action.disabled)\n        .map((action) => ({ name: action.id, label: action.label })),\n    [leftActions, rightActions],\n  );\n\n  const onAccessibilityAction = (event: AccessibilityActionEvent) => {\n    const name = event.nativeEvent.actionName;\n    if (name === \"activate\") {\n      onItemPress?.(item);\n      return;\n    }\n    const left = leftActions.find((action) => action.id === name);\n    const right = rightActions.find((action) => action.id === name);\n    if (left) runAction(left, \"left\", false);\n    else if (right) runAction(right, \"right\", false);\n  };\n\n  // Removed rows fold their height and spacing away while siblings glide up.\n  const collapse = useMemo(() => {\n    const margin = index > 0 ? gap : 0;\n    return (values: ExitAnimationsValues) => {\n      \"worklet\";\n      const timing = { duration: 260, easing: EASE_OUT };\n      return {\n        initialValues: {\n          height: values.currentHeight,\n          marginTop: margin,\n          opacity: 1,\n        },\n        animations: {\n          height: withTiming(0, timing),\n          marginTop: withTiming(0, timing),\n          opacity: withTiming(0, { duration: 180 }),\n        },\n      };\n    };\n  }, [gap, index]);\n\n  const content = renderItem ? (\n    renderItem(item)\n  ) : item.content !== undefined ? (\n    item.content\n  ) : (\n    <View className=\"flex-row items-center gap-3\">\n      {item.leading ? <View className=\"shrink-0\">{item.leading}</View> : null}\n      <View className=\"flex-1\">\n        {item.title ? (\n          <Text\n            numberOfLines={1}\n            className={`text-sm font-medium text-foreground ${classNames?.title ?? \"\"}`}\n          >\n            {item.title}\n          </Text>\n        ) : null}\n        {item.description ? (\n          <Text\n            numberOfLines={1}\n            className={`mt-0.5 text-xs text-muted ${classNames?.description ?? \"\"}`}\n          >\n            {item.description}\n          </Text>\n        ) : null}\n      </View>\n      {item.meta ? (\n        <Text\n          className={`shrink-0 text-xs font-medium text-muted ${classNames?.meta ?? \"\"}`}\n        >\n          {item.meta}\n        </Text>\n      ) : null}\n    </View>\n  );\n\n  return (\n    <Animated.View\n      entering={reduce ? undefined : FadeIn.duration(220)}\n      exiting={reduce ? undefined : collapse}\n      layout={\n        reduce\n          ? undefined\n          : LinearTransition.springify().stiffness(380).damping(36).mass(0.9)\n      }\n      onLayout={({ nativeEvent }) => {\n        rowWidth.value = nativeEvent.layout.width;\n      }}\n      className={`overflow-hidden rounded-2xl ${item.disabled ? \"opacity-60\" : \"\"} ${classNames?.item ?? \"\"}`}\n      style={{ marginTop: index > 0 ? gap : 0 }}\n    >\n      <View style={StyleSheet.absoluteFill}>\n        {leftActions.map((action, i) => (\n          <ActionPanel\n            key={action.id}\n            action={action}\n            index={i}\n            count={leftActions.length}\n            side=\"left\"\n            x={x}\n            rowWidth={rowWidth}\n            arm={arm}\n            open={openSide === \"left\"}\n            onPress={() => runAction(action, \"left\", false)}\n          />\n        ))}\n        {rightActions.map((action, i) => (\n          <ActionPanel\n            key={action.id}\n            action={action}\n            index={i}\n            count={rightActions.length}\n            side=\"right\"\n            x={x}\n            rowWidth={rowWidth}\n            arm={arm}\n            open={openSide === \"right\"}\n            onPress={() => runAction(action, \"right\", false)}\n          />\n        ))}\n      </View>\n\n      <GestureDetector gesture={Gesture.Exclusive(pan, tap)}>\n        <Animated.View\n          accessible\n          accessibilityRole={onItemPress ? \"button\" : undefined}\n          accessibilityLabel={\n            [item.title, item.description, item.meta]\n              .filter(Boolean)\n              .join(\", \") || undefined\n          }\n          accessibilityHint={\n            accessibilityActions.length > 0\n              ? \"Has actions. Use the actions menu to choose one.\"\n              : undefined\n          }\n          accessibilityActions={\n            onItemPress\n              ? [{ name: \"activate\" }, ...accessibilityActions]\n              : accessibilityActions\n          }\n          onAccessibilityAction={onAccessibilityAction}\n          accessibilityState={{ disabled: item.disabled ?? false }}\n          className={`min-h-[72px] justify-center rounded-2xl border border-border bg-surface px-4 py-3 ${classNames?.surface ?? \"\"}`}\n          style={[styles.surface, surfaceStyle]}\n        >\n          <Animated.View\n            style={[\n              StyleSheet.absoluteFill,\n              styles.highlight,\n              { backgroundColor: pressedColor },\n              highlightStyle,\n            ]}\n          />\n          {content}\n        </Animated.View>\n      </GestureDetector>\n    </Animated.View>\n  );\n}\n\n/**\n * List rows that swipe to reveal actions. Actions stretch with the drag, and a\n * full swipe runs the outermost action with the row sliding away.\n */\nexport function SwipeableList({\n  items,\n  value,\n  defaultValue = null,\n  onValueChange,\n  onAction,\n  onItemPress,\n  onSwipeThreshold,\n  actionWidth = 72,\n  fullSwipe = true,\n  closeOnAction = true,\n  gap = 8,\n  disableAnimation = false,\n  className = \"\",\n  classNames,\n  renderItem,\n  ...props\n}: SwipeableListProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue);\n  const openValue = value === undefined ? uncontrolled : value;\n  const reducedMotion = useReducedMotionPreference();\n  const { backgroundColor: pressedColor } =\n    useResolveClassNames(\"bg-foreground/5\");\n\n  const setOpenValue = useCallback(\n    (next: SwipeableListValue | null) => {\n      if (value === undefined) setUncontrolled(next);\n      onValueChange?.(next);\n    },\n    [onValueChange, value],\n  );\n\n  return (\n    <View\n      {...props}\n      className={`w-full ${className} ${classNames?.root ?? \"\"}`}\n    >\n      {/* Rows present on first render appear in place; later ones fade in. */}\n      <LayoutAnimationConfig skipEntering>\n        {items.map((item, index) => (\n          <Row\n            key={item.id}\n            item={item}\n            index={index}\n            gap={gap}\n            actionWidth={actionWidth}\n            fullSwipe={fullSwipe}\n            openValue={openValue}\n            setOpenValue={setOpenValue}\n            closeOnAction={closeOnAction}\n            onAction={onAction}\n            onItemPress={onItemPress}\n            onSwipeThreshold={onSwipeThreshold}\n            reduce={reducedMotion || disableAnimation}\n            pressedColor={pressedColor}\n            classNames={classNames}\n            renderItem={renderItem}\n          />\n        ))}\n      </LayoutAnimationConfig>\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  surface: { boxShadow: \"0 1px 2px rgba(0, 0, 0, 0.06)\" },\n  highlight: { borderRadius: 16, pointerEvents: \"none\" },\n  panel: { position: \"absolute\", top: 0, bottom: 0, overflow: \"hidden\" },\n  press: { flex: 1 },\n  panelInner: { flex: 1, justifyContent: \"center\", paddingHorizontal: 12 },\n  actionContent: { alignItems: \"center\", gap: 4, minWidth: 48 },\n  disabled: { opacity: 0.5 },\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/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"}]}