{"slug":"tabs","name":"Tabs","description":"Pill, segment and underline with a gliding indicator.","group":"Navigation and lists","detail_url":"https://beui.dev/native/r/tabs","page_url":"https://beui.dev/react-native/tabs","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/tabs?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/tabs","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/tabs/index.ts","content":"export type {\n  TabsContentProps,\n  TabsIcons,\n  TabsListProps,\n  TabsProps,\n  TabsTriggerProps,\n  TabsVariant,\n} from \"./tabs\";\nexport { Tabs, TabsContent, TabsList, TabsTrigger } from \"./tabs\";\n","type":"component"},{"path":"src/components/tabs/tabs.tsx","content":"import {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  type LayoutRectangle,\n  Platform,\n  Pressable,\n  type PressableProps,\n  ScrollView,\n  StyleSheet,\n  Text,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport Animated, {\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, SPRING_SWAP } from \"../../styles/motion\";\n\nexport type TabsVariant = \"pill\" | \"segment\" | \"underline\";\n/** Whether trigger icons show on every tab or only on the selected one. */\nexport type TabsIcons = \"always\" | \"selected\";\n\n// Settles without overshoot, so the indicator never pokes past the list edges.\nconst INDICATOR_SPRING = {\n  stiffness: 245,\n  damping: 36,\n  mass: 1.2,\n  reduceMotion: ReduceMotion.System,\n} as const;\nconst CONTENT_FADE = {\n  duration: 180,\n  easing: EASE_OUT,\n  reduceMotion: ReduceMotion.System,\n};\n// Layout sizes can round below the text's real width; a little slack stops the\n// overlay label from truncating while keeping it centered.\nconst SLACK = 1;\n// Trigger icons sit before the label at the label's text size.\nconst ICON_SIZE = 14;\nconst ICON_GAP = 6;\n// Width of the fade over a list edge that has more tabs past it.\nconst EDGE_FADE = 24;\n// Space kept between a revealed tab and the edge, so it clears the fade.\nconst REVEAL_MARGIN = EDGE_FADE + 8;\n// The color behind each variant's tabs, which the edge fades blend into.\nconst fadeColors: Record<TabsVariant, string> = {\n  pill: \"bg-surface\",\n  segment: \"bg-surface\",\n  underline: \"bg-background\",\n};\n\n/** The same color at zero alpha, so a gradient fades without graying. */\nfunction transparentOf(color: string) {\n  const hex = color.match(/^#([0-9a-f]{6})$/i)?.[1];\n  if (hex) {\n    const [r, g, b] = [0, 2, 4].map((i) =>\n      Number.parseInt(hex.slice(i, i + 2), 16),\n    );\n    return `rgba(${r}, ${g}, ${b}, 0)`;\n  }\n  const rgb = color.match(/^rgba?\\(([^,]+),([^,]+),([^,)]+)/);\n  if (rgb) return `rgba(${rgb[1]},${rgb[2]},${rgb[3]}, 0)`;\n  return \"transparent\";\n}\n\n/** A horizontal gradient, on native through experimental_backgroundImage. */\nfunction fadeStyle(color: string, toward: \"left\" | \"right\") {\n  const gradient = `linear-gradient(to ${toward}, ${transparentOf(color)}, ${color})`;\n  return Platform.OS === \"web\"\n    ? ({ backgroundImage: gradient } as object)\n    : { experimental_backgroundImage: gradient };\n}\n\nconst surfaces: Record<TabsVariant, string> = {\n  pill: \"rounded-full bg-surface\",\n  segment: \"rounded-lg bg-surface\",\n  underline: \"\",\n};\nconst lists: Record<TabsVariant, string> = {\n  pill: \"p-1\",\n  segment: \"p-0.5\",\n  // The border sits on the list so the indicator can overlap it inside the scroll view.\n  underline: \"border-b border-border\",\n};\n// Space between tabs. A number rather than a class, so the indicator can work\n// out where tabs will settle while their icons are still opening.\nconst listGaps: Record<TabsVariant, number> = {\n  pill: 4,\n  segment: 0,\n  underline: 4,\n};\nconst triggers: Record<TabsVariant, string> = {\n  pill: \"px-3.5 py-1.5 rounded-full\",\n  segment: \"px-3.5 py-1.5 rounded-md\",\n  underline: \"min-h-11 px-3 pt-1 pb-2.5\",\n};\nconst indicatorRadius: Record<TabsVariant, number> = {\n  pill: 9999,\n  segment: 8,\n  underline: 0,\n};\n\ntype TabsContextValue = {\n  value: string;\n  select: (value: string) => void;\n  variant: TabsVariant;\n  icons: TabsIcons;\n  animate: boolean;\n};\n\n/** A tab's width with its selected-only icon folded and open. */\ntype TabWidths = { folded: number; open: number };\n\ntype Indicator = {\n  x: SharedValue<number>;\n  width: SharedValue<number>;\n  visible: SharedValue<number>;\n  report: (value: string, layout: LayoutRectangle, widths: TabWidths) => void;\n};\n\nconst TabsContext = createContext<TabsContextValue | null>(null);\nconst IndicatorContext = createContext<Indicator | null>(null);\n\nfunction useTabs() {\n  const context = useContext(TabsContext);\n  if (!context) throw new Error(\"Tabs parts must be rendered inside Tabs.\");\n  return context;\n}\n\nexport type TabsProps = ViewProps & {\n  /** Controlled value. Omit it and use `defaultValue` for uncontrolled use. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  variant?: TabsVariant;\n  /**\n   * With \"selected\", a trigger's icon springs open only on the selected tab\n   * and folds away when another is chosen. Defaults to \"always\".\n   */\n  icons?: TabsIcons;\n  /** Disables indicator and content motion independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n};\n\nexport function Tabs({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  variant = \"pill\",\n  icons = \"always\",\n  disableAnimation = false,\n  ...props\n}: TabsProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue);\n  const value = valueProp ?? uncontrolled;\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n\n  const context = useMemo<TabsContextValue>(\n    () => ({\n      value,\n      variant,\n      icons,\n      animate,\n      select: (next) => {\n        if (valueProp === undefined) setUncontrolled(next);\n        onValueChange?.(next);\n      },\n    }),\n    [animate, icons, onValueChange, value, valueProp, variant],\n  );\n\n  return (\n    <TabsContext.Provider value={context}>\n      <View {...props} />\n    </TabsContext.Provider>\n  );\n}\n\nexport type TabsListProps = Omit<ViewProps, \"children\"> & {\n  /** `TabsTrigger` elements. They must be direct children so they can be measured. */\n  children: ReactNode;\n  className?: string;\n  /** Overrides the indicator's color utility, such as `bg-foreground`. */\n  indicatorClassName?: string;\n};\n\nexport function TabsList({\n  children,\n  className = \"\",\n  indicatorClassName,\n  ...props\n}: TabsListProps) {\n  const { value, variant, animate } = useTabs();\n  const layouts = useRef(new Map<string, LayoutRectangle>());\n  const widths = useRef(new Map<string, TabWidths>());\n  // The box the indicator is heading for, so repeat reports don't restart it.\n  const heading = useRef<LayoutRectangle | null>(null);\n  const scrollRef = useRef<ScrollView>(null);\n  const scrollX = useRef(0);\n  const viewportWidth = useRef(0);\n  const contentWidth = useRef(0);\n  const [edges, setEdges] = useState({ start: false, end: false });\n  const { backgroundColor: fadeColor } = useResolveClassNames(\n    fadeColors[variant],\n  );\n  const valueRef = useRef(value);\n  valueRef.current = value;\n  const animateRef = useRef(animate);\n  animateRef.current = animate;\n\n  const x = useSharedValue(0);\n  const y = useSharedValue(0);\n  const width = useSharedValue(0);\n  const height = useSharedValue(0);\n  const visible = useSharedValue(0);\n\n  // Show a fade on each side that has tabs scrolled out of view.\n  const updateEdges = useCallback(() => {\n    const max = contentWidth.current - viewportWidth.current;\n    const next = {\n      start: scrollX.current > 1,\n      end: max > 1 && scrollX.current < max - 1,\n    };\n    setEdges((current) =>\n      current.start === next.start && current.end === next.end ? current : next,\n    );\n  }, []);\n\n  // Keep the selected tab clear of the viewport edges when the list scrolls.\n  const reveal = useCallback((layout: LayoutRectangle) => {\n    const viewport = viewportWidth.current;\n    // A list that fits has nothing to scroll; asking anyway nudges it sideways.\n    if (!viewport || contentWidth.current <= viewport + 1) return;\n    const start = scrollX.current;\n    let target = start;\n    if (layout.x < start + REVEAL_MARGIN) {\n      target = layout.x - REVEAL_MARGIN;\n    } else if (layout.x + layout.width > start + viewport - REVEAL_MARGIN) {\n      target = layout.x + layout.width - viewport + REVEAL_MARGIN;\n    }\n    if (target !== start) {\n      scrollRef.current?.scrollTo({\n        x: Math.max(0, target),\n        animated: animateRef.current,\n      });\n    }\n  }, []);\n\n  // Where the target tab will settle once every icon has finished opening or\n  // folding. With icons=\"selected\" tabs resize as the selection changes, and\n  // their reported layouts trail the icon's spring by a frame or more. Aiming\n  // at the settled box gives one glide instead of chasing late measurements.\n  const settled = useCallback(\n    (target: string): LayoutRectangle | null => {\n      const layout = layouts.current.get(target);\n      if (!layout) return null;\n      const tabs = [...layouts.current.entries()].sort(\n        (a, b) => a[1].x - b[1].x,\n      );\n      // The first tab never moves; the rest follow from the widths.\n      let x = tabs[0]?.[1].x ?? 0;\n      for (const [item] of tabs) {\n        const size = widths.current.get(item);\n        const width = size ? (item === target ? size.open : size.folded) : 0;\n        if (item === target)\n          return { x, y: layout.y, width, height: layout.height };\n        x += width + listGaps[variant];\n      }\n      return null;\n    },\n    [variant],\n  );\n\n  const move = useCallback(\n    (target: string) => {\n      const box = settled(target);\n      if (!box) {\n        visible.value = 0;\n        heading.current = null;\n        return;\n      }\n      const last = heading.current;\n      if (\n        last &&\n        visible.value === 1 &&\n        Math.abs(last.x - box.x) < 0.5 &&\n        Math.abs(last.width - box.width) < 0.5 &&\n        Math.abs(last.y - box.y) < 0.5 &&\n        Math.abs(last.height - box.height) < 0.5\n      ) {\n        return;\n      }\n      heading.current = box;\n      // Height and vertical position never animate; only the glide does.\n      y.value = box.y;\n      height.value = box.height;\n      if (visible.value === 0 || !animateRef.current) {\n        x.value = box.x;\n        width.value = box.width;\n      } else {\n        x.value = withSpring(box.x, INDICATOR_SPRING);\n        width.value = withSpring(box.width, INDICATOR_SPRING);\n      }\n      visible.value = 1;\n      reveal(box);\n    },\n    [height, reveal, settled, visible, width, x, y],\n  );\n\n  useEffect(() => {\n    move(value);\n  }, [move, value]);\n\n  const report = useCallback(\n    (item: string, layout: LayoutRectangle, size: TabWidths) => {\n      layouts.current.set(item, layout);\n      widths.current.set(item, size);\n      // Any tab can move the target, such as one before it changing size.\n      move(valueRef.current);\n    },\n    [move],\n  );\n\n  const indicator = useMemo<Indicator>(\n    () => ({ x, width, visible, report }),\n    [report, visible, width, x],\n  );\n\n  const indicatorStyle = useAnimatedStyle(() =>\n    variant === \"underline\"\n      ? {\n          opacity: visible.value,\n          width: width.value,\n          transform: [{ translateX: x.value }],\n        }\n      : {\n          opacity: visible.value,\n          width: width.value,\n          height: height.value,\n          transform: [{ translateX: x.value }, { translateY: y.value }],\n        },\n  );\n\n  return (\n    <IndicatorContext.Provider value={indicator}>\n      <View\n        {...props}\n        className={`self-start max-w-full overflow-hidden ${surfaces[variant]} ${className}`}\n      >\n        <ScrollView\n          ref={scrollRef}\n          horizontal\n          showsHorizontalScrollIndicator={false}\n          style={styles.scroller}\n          scrollEventThrottle={16}\n          onScroll={(event) => {\n            scrollX.current = event.nativeEvent.contentOffset.x;\n            updateEdges();\n          }}\n          onLayout={(event) => {\n            viewportWidth.current = event.nativeEvent.layout.width;\n            updateEdges();\n          }}\n          onContentSizeChange={(width) => {\n            contentWidth.current = width;\n            updateEdges();\n          }}\n        >\n          <View\n            accessibilityRole=\"tablist\"\n            className={`flex-row items-center ${lists[variant]}`}\n            style={{ gap: listGaps[variant] }}\n          >\n            <Animated.View\n              className={indicatorClassName ?? \"bg-primary\"}\n              style={[\n                variant === \"underline\" ? styles.underline : styles.indicator,\n                { borderRadius: indicatorRadius[variant] },\n                indicatorStyle,\n              ]}\n            />\n            {children}\n          </View>\n        </ScrollView>\n        {edges.start && fadeColor ? (\n          <View\n            style={[\n              styles.fade,\n              styles.fadeStart,\n              fadeStyle(String(fadeColor), \"left\"),\n            ]}\n          />\n        ) : null}\n        {edges.end && fadeColor ? (\n          <View\n            style={[\n              styles.fade,\n              styles.fadeEnd,\n              fadeStyle(String(fadeColor), \"right\"),\n            ]}\n          />\n        ) : null}\n      </View>\n    </IndicatorContext.Provider>\n  );\n}\n\nexport type TabsTriggerProps = Omit<\n  PressableProps,\n  \"children\" | \"style\" | \"onPress\"\n> & {\n  value: string;\n  /**\n   * An icon before the label: a component given `{ size, color }`, or a\n   * prebuilt node. Colored like the label in each state.\n   */\n  icon?: IconSource;\n  /** Text children are colored for each state. Other elements render as given. */\n  children: ReactNode;\n  className?: string;\n  onPress?: PressableProps[\"onPress\"];\n};\n\nfunction TabLabel({\n  children,\n  className,\n}: {\n  children: ReactNode;\n  className: string;\n}) {\n  return typeof children === \"string\" || typeof children === \"number\" ? (\n    <Text numberOfLines={1} className={`text-sm font-medium ${className}`}>\n      {children}\n    </Text>\n  ) : (\n    children\n  );\n}\n\n/** A label with an optional icon before it, in one layer's colors. */\nfunction TabContent({\n  children,\n  className,\n  icon,\n  color,\n  iconStyle,\n}: {\n  children: ReactNode;\n  className: string;\n  icon?: IconSource;\n  color: string | undefined;\n  iconStyle: object;\n}) {\n  const label = <TabLabel className={className}>{children}</TabLabel>;\n  if (icon == null) return label;\n  return (\n    <View style={styles.content}>\n      <Animated.View\n        accessibilityElementsHidden\n        importantForAccessibility=\"no-hide-descendants\"\n        style={[styles.icon, iconStyle]}\n      >\n        {renderIcon(icon, { size: ICON_SIZE, color })}\n      </Animated.View>\n      {label}\n    </View>\n  );\n}\n\nfunction clamp01(value: number) {\n  \"worklet\";\n  return Math.min(1, Math.max(0, value));\n}\n\n/** How much of a tab the indicator leaves uncovered on each side. */\nfunction overlap(\n  left: number,\n  width: number,\n  indicatorX: number,\n  indicatorWidth: number,\n  visible: number,\n) {\n  \"worklet\";\n  const start = Math.min(width, Math.max(0, indicatorX - left));\n  const end = Math.min(\n    width,\n    Math.max(0, left + width - (indicatorX + indicatorWidth)),\n  );\n  const hidden = visible === 0 || start + end >= width;\n  return hidden ? { start: 0, end: 0, hidden } : { start, end, hidden };\n}\n\nexport function TabsTrigger({\n  value,\n  icon,\n  children,\n  className = \"\",\n  disabled,\n  accessibilityState,\n  onLayout,\n  onPress,\n  ...props\n}: TabsTriggerProps) {\n  const { value: current, select, variant, icons, animate } = useTabs();\n  const indicator = useContext(IndicatorContext);\n  if (!indicator) throw new Error(\"TabsTrigger must be rendered in TabsList.\");\n  const selected = current === value;\n  const underline = variant === \"underline\";\n  const labelClass = underline && selected ? \"text-foreground\" : \"text-muted\";\n  const { color: labelColor } = useResolveClassNames(labelClass);\n  const { color: onPrimary } = useResolveClassNames(\"text-on-primary\");\n\n  // One progress value opens the icon on both label layers at once, so the\n  // on-indicator copy never drifts from the one beneath it.\n  const shown = icons === \"always\" || selected;\n  const reveal = useSharedValue(shown ? 1 : 0);\n  // Only a selected-only icon changes the tab's width.\n  const slot = icon != null && icons === \"selected\" ? ICON_SIZE + ICON_GAP : 0;\n  const left = useSharedValue(0);\n  // The folded width. The live width follows the icon's spring on the UI\n  // thread, rather than layout events that arrive a frame or more late.\n  const folded = useSharedValue(0);\n  const sizes = useRef<TabWidths | null>(null);\n  const layout = useRef<LayoutRectangle | null>(null);\n  // Whether the icon is at rest. Layouts measured mid-spring are already\n  // stale when they arrive, so only resting ones set the widths.\n  const resting = useRef(true);\n  const shownRef = useRef(shown);\n  shownRef.current = shown;\n\n  const measure = useCallback(\n    (next: LayoutRectangle) => {\n      const base = next.width - (shownRef.current ? slot : 0);\n      sizes.current = { folded: base, open: base + slot };\n      folded.value = base;\n    },\n    [folded, slot],\n  );\n\n  useEffect(() => {\n    if (!animate) {\n      reveal.value = shown ? 1 : 0;\n      return;\n    }\n    resting.current = false;\n    const rest = () => {\n      resting.current = true;\n      if (!layout.current) return;\n      measure(layout.current);\n      if (sizes.current) indicator.report(value, layout.current, sizes.current);\n    };\n    reveal.value = withSpring(shown ? 1 : 0, SPRING_SWAP, (finished) => {\n      if (finished) scheduleOnRN(rest);\n    });\n  }, [animate, indicator, measure, reveal, shown, value]);\n  const iconStyle = useAnimatedStyle(() => {\n    // The spring overshoots a little. Below zero a width is invalid and the\n    // icon snaps to its natural size, so layout uses the clamped progress.\n    const open = clamp01(reveal.value);\n    return {\n      width: ICON_SIZE * open,\n      marginRight: ICON_GAP * open,\n      opacity: open,\n      transform: [{ scale: 0.6 + 0.4 * reveal.value }],\n    };\n  });\n  // The on-indicator label shows only where the indicator overlaps this tab.\n  // Each style reads its shared values itself: a style only updates for the\n  // values its own worklet reads, not ones read inside a helper it calls.\n  const clipStyle = useAnimatedStyle(() => {\n    const { start, end, hidden } = overlap(\n      left.value,\n      folded.value + slot * clamp01(reveal.value),\n      indicator.x.value,\n      indicator.width.value,\n      indicator.visible.value,\n    );\n    return { opacity: hidden ? 0 : 1, left: start, right: end };\n  });\n  // The inner layer spans the whole tab by its insets, so it resizes with the\n  // tab natively and its label sits exactly over the one beneath.\n  const innerStyle = useAnimatedStyle(() => {\n    const { start, end } = overlap(\n      left.value,\n      folded.value + slot * clamp01(reveal.value),\n      indicator.x.value,\n      indicator.width.value,\n      indicator.visible.value,\n    );\n    return { left: -start - SLACK, right: -end - SLACK };\n  });\n\n  return (\n    <Pressable\n      {...props}\n      accessibilityRole=\"tab\"\n      accessibilityState={{\n        ...accessibilityState,\n        selected,\n        disabled: disabled ?? false,\n      }}\n      aria-selected={selected}\n      disabled={disabled}\n      className={`shrink-0 items-center justify-center ${triggers[variant]} ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      onLayout={(event) => {\n        const next = event.nativeEvent.layout;\n        layout.current = next;\n        left.value = next.x;\n        if (resting.current || !sizes.current) measure(next);\n        if (sizes.current) indicator.report(value, next, sizes.current);\n        onLayout?.(event);\n      }}\n      onPress={(event) => {\n        onPress?.(event);\n        if (!event.defaultPrevented) select(value);\n      }}\n    >\n      <TabContent\n        className={labelClass}\n        icon={icon}\n        color={labelColor == null ? undefined : String(labelColor)}\n        iconStyle={iconStyle}\n      >\n        {children}\n      </TabContent>\n      {underline ? null : (\n        <Animated.View\n          accessibilityElementsHidden\n          importantForAccessibility=\"no-hide-descendants\"\n          aria-hidden\n          style={[styles.clip, clipStyle]}\n        >\n          <Animated.View\n            className={`items-center justify-center ${triggers[variant]}`}\n            style={[styles.clipInner, innerStyle]}\n          >\n            <TabContent\n              className=\"text-on-primary\"\n              icon={icon}\n              color={onPrimary == null ? undefined : String(onPrimary)}\n              iconStyle={iconStyle}\n            >\n              {children}\n            </TabContent>\n          </Animated.View>\n        </Animated.View>\n      )}\n    </Pressable>\n  );\n}\n\nexport type TabsContentProps = ViewProps & {\n  value: string;\n  className?: string;\n};\n\nexport function TabsContent({\n  value,\n  className = \"\",\n  style,\n  ...props\n}: TabsContentProps) {\n  const { value: current, animate } = useTabs();\n  const active = current === value;\n  const progress = useSharedValue(0);\n\n  useEffect(() => {\n    if (!active) return;\n    progress.value = 0;\n    progress.value = withTiming(1, CONTENT_FADE);\n  }, [active, progress]);\n\n  const animatedStyle = useAnimatedStyle(\n    () => ({\n      opacity: progress.value,\n      transform: [{ translateY: animate ? 4 * (1 - progress.value) : 0 }],\n    }),\n    [animate],\n  );\n\n  if (!active) return null;\n  return (\n    <Animated.View\n      {...props}\n      className={`mt-4 ${className}`}\n      style={[style, animatedStyle]}\n    />\n  );\n}\n\nconst styles = StyleSheet.create({\n  // Native ScrollViews default to flexGrow: 1 and would stretch to fill the\n  // page vertically; size to the tab row instead.\n  scroller: { flexGrow: 0 },\n  fade: {\n    position: \"absolute\",\n    top: 0,\n    bottom: 0,\n    width: EDGE_FADE,\n    pointerEvents: \"none\",\n  },\n  fadeStart: { left: 0 },\n  fadeEnd: { right: 0 },\n  indicator: { position: \"absolute\", left: 0, top: 0 },\n  underline: { position: \"absolute\", left: 0, bottom: -1, height: 1 },\n  clip: {\n    position: \"absolute\",\n    top: 0,\n    bottom: 0,\n    overflow: \"hidden\",\n    pointerEvents: \"none\",\n  },\n  clipInner: { position: \"absolute\", top: 0, bottom: 0 },\n  content: { flexDirection: \"row\", alignItems: \"center\" },\n  icon: {\n    height: ICON_SIZE,\n    overflow: \"hidden\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n});\n","type":"component"},{"path":"src/hooks/use-reduced-motion.ts","content":"import { useEffect, useState } from \"react\";\nimport { AccessibilityInfo } from \"react-native\";\nimport { useReducedMotion } from \"react-native-reanimated\";\n\n/** Reanimated supplies a synchronous initial value; native events keep it current. */\nexport function useReducedMotionPreference() {\n  const initialValue = useReducedMotion();\n  const [reducedMotion, setReducedMotion] = useState(initialValue);\n\n  useEffect(() => {\n    let active = true;\n    let receivedEvent = false;\n    const subscription = AccessibilityInfo.addEventListener(\n      \"reduceMotionChanged\",\n      (value) => {\n        receivedEvent = true;\n        setReducedMotion(value);\n      },\n    );\n    void AccessibilityInfo.isReduceMotionEnabled().then((value) => {\n      if (active && !receivedEvent) setReducedMotion(value);\n    });\n    return () => {\n      active = false;\n      subscription.remove();\n    };\n  }, []);\n\n  return reducedMotion;\n}\n","type":"shared"},{"path":"src/primitives/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"}]}