{"slug":"expandable-control","name":"Expandable Control","description":"Buttons and chips that spring open to reveal more.","group":"Actions","detail_url":"https://beui.dev/native/r/expandable-control","page_url":"https://beui.dev/react-native/expandable-control","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/expandable-control?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/expandable-control","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/expandable-control/expandable-control.tsx","content":"import { type Ref, useCallback, useEffect, useState } from \"react\";\nimport {\n  type GestureResponderEvent,\n  Pressable,\n  type PressableProps,\n  StyleSheet,\n  Text,\n  View,\n} from \"react-native\";\nimport Animated, {\n  Extrapolation,\n  interpolate,\n  interpolateColor,\n  ReduceMotion,\n  type SharedValue,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { DrawnPath, type PathSegment } from \"../../primitives/drawn-path\";\nimport { type IconSource, renderIcon } from \"../../primitives/render-icon\";\nimport { SPRING_PRESS } from \"../../styles/motion\";\n\n// One spring drives everything: a 0 to 1 progress that the width, the\n// revealed content's fade and slide, and the surface's tint all read from.\n// Folding is opening played backward, overshoot included, and a tap\n// mid-motion reverses from wherever it is. Tuned quicker than the web\n// version's continuity spring, like a native control under a finger, with a\n// hint of give at the end.\nconst CONTINUITY = {\n  stiffness: 320,\n  damping: 22,\n  mass: 0.8,\n  reduceMotion: ReduceMotion.System,\n};\nconst PRESS_SCALE = 0.96;\n\nconst BUTTON_HEIGHT = 44;\nconst BUTTON_PAD = 4;\n// How narrow the outline may dip while a fold overshoots past closed.\nconst MIN_WIDTH = 30;\nconst ICON_SLOT = 36;\n// The label's trailing space inside the open pill.\nconst LABEL_END = 12;\n// How far revealed content slides as it appears.\nconst SLIDE = 8;\nconst CHIP_HEIGHT = 40;\nconst ACTION_WIDTH = 32;\nconst CHIP_PAD = 12;\n// The label's trailing padding once the action has made room.\nconst CHIP_PAD_OPEN = 4;\nconst CLOSE: PathSegment[] = [\n  [18, 6, 6, 18],\n  [6, 6, 18, 18],\n];\n\ntype ExpandableState = {\n  /** Controlled state. Omit it and use `defaultExpanded` for uncontrolled use. */\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n};\n\nfunction useExpandableState({\n  expanded,\n  defaultExpanded = false,\n  onExpandedChange,\n}: ExpandableState) {\n  const [internal, setInternal] = useState(defaultExpanded);\n  const controlled = expanded !== undefined;\n  const current = expanded ?? internal;\n  const setExpanded = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternal(next);\n      onExpandedChange?.(next);\n    },\n    [controlled, onExpandedChange],\n  );\n  return [current, setExpanded] as const;\n}\n\n/** The control's one progress value: springs to 1 open and 0 folded. */\nfunction useProgress(open: boolean, reduce: boolean) {\n  const progress = useSharedValue(open ? 1 : 0);\n  useEffect(() => {\n    const target = open ? 1 : 0;\n    progress.value = reduce ? target : withSpring(target, CONTINUITY);\n  }, [open, progress, reduce]);\n  return progress;\n}\n\n/**\n * Fade and slide for revealed content, read from the shared progress, with an\n * optional quarter turn in for icons.\n */\nfunction useRevealStyle(progress: SharedValue<number>, turn = 0) {\n  return useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      // Content appears over the back part of the way open, so it's never\n      // seen squeezed in a surface still too narrow for it.\n      opacity: interpolate(p, [0.3, 0.9], [0, 1], Extrapolation.CLAMP),\n      transform: [\n        {\n          translateX: interpolate(p, [0, 1], [-SLIDE, 0], Extrapolation.CLAMP),\n        },\n        { rotate: `${turn * (1 - Math.min(1, Math.max(0, p)))}deg` },\n      ],\n    };\n  });\n}\n\n/**\n * The surface tints and its outline firms up as it opens, so an open control\n * reads as on at a glance. Both follow the same progress as the motion.\n */\nfunction useSurfaceStyle(progress: SharedValue<number>) {\n  const { backgroundColor: tint } = useResolveClassNames(\"bg-foreground/5\");\n  const rest = useResolveClassNames(\"border-border\");\n  const strong = useResolveClassNames(\"border-border-strong\");\n  // On web, borders resolve per side.\n  const from = String(rest.borderColor ?? rest.borderTopColor ?? \"transparent\");\n  const to = String(\n    strong.borderColor ?? strong.borderTopColor ?? \"transparent\",\n  );\n  const fill = String(tint ?? \"transparent\");\n  return useAnimatedStyle(() => {\n    const p = Math.min(1, Math.max(0, progress.value));\n    return {\n      backgroundColor: interpolateColor(p, [0, 1], [\"transparent\", fill]),\n      borderColor: interpolateColor(p, [0, 1], [from, to]),\n    };\n  });\n}\n\n/** Sinks a pressable surface slightly while a finger is on it. */\nfunction usePress(enabled: boolean) {\n  const scale = useSharedValue(1);\n  const style = useAnimatedStyle(() => ({\n    transform: [{ scale: scale.value }],\n  }));\n  return {\n    style,\n    onPressIn: () => {\n      if (enabled) scale.value = withSpring(PRESS_SCALE, SPRING_PRESS);\n    },\n    onPressOut: () => {\n      if (enabled) scale.value = withSpring(1, SPRING_PRESS);\n    },\n  };\n}\n\nexport type ExpandableButtonProps = Omit<PressableProps, \"children\" | \"style\"> &\n  ExpandableState & {\n    /** A component given `{ size, color }`, or a prebuilt element. */\n    icon: IconSource;\n    label: string;\n    /** Moves without springing, independently of the system preference. */\n    disableAnimation?: boolean;\n    className?: string;\n    labelClassName?: string;\n    ref?: Ref<View>;\n  };\n\n/**\n * A round icon button that springs open to reveal its label when pressed, and\n * folds back the same way on the next press.\n */\nexport function ExpandableButton({\n  icon,\n  label,\n  expanded,\n  defaultExpanded,\n  onExpandedChange,\n  onPress,\n  onPressIn,\n  onPressOut,\n  disabled,\n  disableAnimation = false,\n  className = \"\",\n  labelClassName = \"\",\n  accessibilityLabel,\n  ref,\n  ...props\n}: ExpandableButtonProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const [open, setOpen] = useExpandableState({\n    expanded,\n    defaultExpanded,\n    onExpandedChange,\n  });\n  const { color: foreground } = useResolveClassNames(\"text-foreground\");\n  const [labelWidth, setLabelWidth] = useState(0);\n  const press = usePress(!reduce && !disabled);\n  const progress = useProgress(open, reduce);\n  const revealStyle = useRevealStyle(progress);\n  const surfaceStyle = useSurfaceStyle(progress);\n\n  // The pill grows by the label's width. Past closed, a fold's overshoot dips\n  // the outline a few points narrower, mirroring the overshoot past open,\n  // while the icon below stays centered and full size.\n  const pillStyle = useAnimatedStyle(() => ({\n    width: Math.max(MIN_WIDTH, BUTTON_HEIGHT + labelWidth * progress.value),\n  }));\n  const iconStyle = useAnimatedStyle(() => {\n    const width = Math.max(\n      MIN_WIDTH,\n      BUTTON_HEIGHT + labelWidth * progress.value,\n    );\n    return {\n      transform: [{ translateX: Math.min(0, width - BUTTON_HEIGHT) / 2 }],\n    };\n  });\n\n  const labelClasses = `text-sm font-medium text-foreground ${labelClassName}`;\n\n  return (\n    <Animated.View style={[styles.hug, press.style]}>\n      <Pressable\n        {...props}\n        ref={ref}\n        accessibilityRole=\"button\"\n        accessibilityLabel={accessibilityLabel ?? label}\n        aria-expanded={open}\n        aria-disabled={disabled ?? false}\n        disabled={disabled}\n        onPress={(event: GestureResponderEvent) => {\n          onPress?.(event);\n          if (!event.defaultPrevented) setOpen(!open);\n        }}\n        onPressIn={(event) => {\n          press.onPressIn();\n          onPressIn?.(event);\n        }}\n        onPressOut={(event) => {\n          press.onPressOut();\n          onPressOut?.(event);\n        }}\n      >\n        <Animated.View\n          className={`flex-row items-center overflow-hidden rounded-full border border-border ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n          style={[styles.button, pillStyle, surfaceStyle]}\n        >\n          <Animated.View style={[styles.iconSlot, iconStyle]}>\n            {renderIcon(icon, { size: 16, color: foreground as string })}\n          </Animated.View>\n          <Animated.View style={[styles.label, revealStyle]}>\n            <Text numberOfLines={1} className={labelClasses}>\n              {label}\n            </Text>\n          </Animated.View>\n        </Animated.View>\n      </Pressable>\n\n      {/* Measured at its natural width, outside the clipped pill. */}\n      <View pointerEvents=\"none\" style={styles.measure}>\n        <Text\n          numberOfLines={1}\n          className={labelClasses}\n          onLayout={({ nativeEvent }) =>\n            setLabelWidth(Math.ceil(nativeEvent.layout.width) + LABEL_END)\n          }\n        >\n          {label}\n        </Text>\n      </View>\n    </Animated.View>\n  );\n}\n\nexport type ExpandableChipProps = ExpandableState & {\n  label: string;\n  /** Replaces the X. A component given `{ size, color }`, or an element. */\n  actionIcon?: IconSource;\n  /** Names the action for screen readers, such as \"Remove React\". */\n  actionLabel: string;\n  onAction?: () => void;\n  /** Folds the chip after its action runs. Defaults to true. */\n  collapseOnAction?: boolean;\n  disabled?: boolean;\n  /** Moves without springing, independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  labelClassName?: string;\n};\n\n/**\n * A chip that springs open to reveal a trailing action, such as remove, when\n * its label is pressed, and folds back the same way.\n */\nexport function ExpandableChip({\n  label,\n  actionIcon,\n  actionLabel,\n  onAction,\n  collapseOnAction = true,\n  expanded,\n  defaultExpanded,\n  onExpandedChange,\n  disabled = false,\n  disableAnimation = false,\n  className = \"\",\n  labelClassName = \"\",\n}: ExpandableChipProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const [open, setOpen] = useExpandableState({\n    expanded,\n    defaultExpanded,\n    onExpandedChange,\n  });\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const drawn = useSharedValue(1);\n  const press = usePress(!reduce && !disabled);\n  const progress = useProgress(open, reduce);\n  // The X turns in as it appears.\n  const revealStyle = useRevealStyle(progress, -90);\n  const surfaceStyle = useSurfaceStyle(progress);\n\n  // Opening, the action slot grows while the label's trailing padding gives\n  // some of its room back, so the chip widens by their difference. Past\n  // closed, the padding alone takes up the fold's overshoot, narrowing the\n  // chip by as much as opening's overshoot widens it.\n  const slotStyle = useAnimatedStyle(() => ({\n    width: Math.max(0, ACTION_WIDTH * progress.value),\n  }));\n  const labelStyle = useAnimatedStyle(() => {\n    const p = progress.value;\n    const grow = ACTION_WIDTH - (CHIP_PAD - CHIP_PAD_OPEN);\n    return {\n      paddingRight:\n        p >= 0\n          ? CHIP_PAD - (CHIP_PAD - CHIP_PAD_OPEN) * p\n          : CHIP_PAD + grow * p,\n    };\n  });\n\n  return (\n    <Animated.View\n      className={`flex-row items-center overflow-hidden rounded-full border border-border ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      style={[styles.chip, styles.hug, press.style, surfaceStyle]}\n    >\n      <Pressable\n        accessibilityRole=\"button\"\n        accessibilityLabel={label}\n        aria-expanded={open}\n        aria-disabled={disabled}\n        disabled={disabled}\n        onPress={() => setOpen(!open)}\n        onPressIn={press.onPressIn}\n        onPressOut={press.onPressOut}\n      >\n        <Animated.View style={[styles.chipLabel, labelStyle]}>\n          <Text\n            numberOfLines={1}\n            className={`text-sm font-medium text-foreground ${labelClassName}`}\n          >\n            {label}\n          </Text>\n        </Animated.View>\n      </Pressable>\n      <Animated.View\n        style={[styles.actionSlot, slotStyle]}\n        accessibilityElementsHidden={!open}\n        importantForAccessibility={open ? \"auto\" : \"no-hide-descendants\"}\n      >\n        <Animated.View style={revealStyle}>\n          <Pressable\n            accessibilityRole=\"button\"\n            accessibilityLabel={actionLabel}\n            disabled={disabled || !open}\n            hitSlop={6}\n            onPress={() => {\n              onAction?.();\n              if (collapseOnAction) setOpen(false);\n            }}\n            className=\"h-10 w-8 items-center justify-center rounded-full active:opacity-60\"\n          >\n            {actionIcon != null ? (\n              renderIcon(actionIcon, { size: 14, color: muted as string })\n            ) : (\n              <DrawnPath\n                segments={CLOSE}\n                size={14}\n                strokeWidth={2}\n                color={muted}\n                progress={drawn}\n              />\n            )}\n          </Pressable>\n        </Animated.View>\n      </Animated.View>\n    </Animated.View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  hug: { alignSelf: \"flex-start\" },\n  button: { height: BUTTON_HEIGHT, padding: BUTTON_PAD - 1 },\n  iconSlot: {\n    width: ICON_SLOT,\n    height: ICON_SLOT,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  // Never squeezed while the pill grows; the pill clips it instead.\n  label: { paddingRight: LABEL_END, flexShrink: 0 },\n  measure: { position: \"absolute\", opacity: 0, left: 0, top: 0 },\n  chip: { height: CHIP_HEIGHT },\n  chipLabel: {\n    height: CHIP_HEIGHT - 2,\n    justifyContent: \"center\",\n    paddingLeft: CHIP_PAD,\n  },\n  actionSlot: { height: CHIP_HEIGHT - 2, overflow: \"hidden\" },\n});\n","type":"component"},{"path":"src/components/expandable-control/index.ts","content":"export type {\n  ExpandableButtonProps,\n  ExpandableChipProps,\n} from \"./expandable-control\";\nexport { ExpandableButton, ExpandableChip } from \"./expandable-control\";\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/drawn-path.tsx","content":"import { type ColorValue, StyleSheet, View } from \"react-native\";\nimport Animated, {\n  interpolate,\n  type SharedValue,\n  useAnimatedStyle,\n} from \"react-native-reanimated\";\n\n/** A straight line in a square viewBox: [x1, y1, x2, y2]. */\nexport type PathSegment = [x1: number, y1: number, x2: number, y2: number];\n\nexport type DrawnPathProps = {\n  /** Straight segments drawn in order, like an SVG polyline. */\n  segments: PathSegment[];\n  /** Rendered width and height in points. */\n  size: number;\n  /** Stroke width in viewBox units. */\n  strokeWidth: number;\n  /** Side of the square viewBox the segments are written in. Defaults to 24. */\n  viewBox?: number;\n  color: ColorValue | undefined;\n  /** 0 draws nothing and 1 draws every segment, like SVG `pathLength`. */\n  progress: SharedValue<number>;\n};\n\nfunction length([x1, y1, x2, y2]: PathSegment) {\n  return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/**\n * Draws a polyline on with rotated Views, so shared components need no SVG\n * library. Round caps extend half a stroke past each end, like SVG's linecap.\n */\nexport function DrawnPath({\n  segments,\n  size,\n  strokeWidth,\n  viewBox = 24,\n  color,\n  progress,\n}: DrawnPathProps) {\n  const unit = size / viewBox;\n  const stroke = strokeWidth * unit;\n  const total = segments.reduce((sum, segment) => sum + length(segment), 0);\n  let start = 0;\n\n  return (\n    <View style={{ width: size, height: size }}>\n      {segments.map((segment) => {\n        const from = start / total;\n        start += length(segment);\n        return (\n          <Stroke\n            key={segment.join()}\n            segment={segment}\n            unit={unit}\n            stroke={stroke}\n            from={from}\n            to={start / total}\n            progress={progress}\n            color={color}\n          />\n        );\n      })}\n    </View>\n  );\n}\n\nfunction Stroke({\n  segment,\n  unit,\n  stroke,\n  from,\n  to,\n  progress,\n  color,\n}: {\n  segment: PathSegment;\n  unit: number;\n  stroke: number;\n  from: number;\n  to: number;\n  progress: SharedValue<number>;\n  color: ColorValue | undefined;\n}) {\n  const [x1, y1, x2, y2] = segment;\n  const full = length(segment) * unit;\n  const angle = Math.atan2(y2 - y1, x2 - x1);\n\n  const animatedStyle = useAnimatedStyle(() => {\n    const drawn = interpolate(progress.value, [from, to], [0, 1], \"clamp\");\n    return { opacity: drawn > 0 ? 1 : 0, width: stroke + full * drawn };\n  });\n\n  return (\n    <Animated.View\n      style={[\n        styles.stroke,\n        {\n          left: x1 * unit - stroke / 2,\n          top: y1 * unit - stroke / 2,\n          height: stroke,\n          borderRadius: stroke / 2,\n          backgroundColor: color,\n          // Rotate around the start point so the stroke grows from it.\n          transformOrigin: [stroke / 2, stroke / 2, 0],\n          transform: [{ rotate: `${angle}rad` }],\n        },\n        animatedStyle,\n      ]}\n    />\n  );\n}\n\nconst styles = StyleSheet.create({\n  stroke: { position: \"absolute\" },\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"}]}