{"slug":"bouncy-accordion","name":"Bouncy Accordion","description":"FAQ rows that spring apart as they open.","group":"Navigation and lists","detail_url":"https://beui.dev/native/r/bouncy-accordion","page_url":"https://beui.dev/react-native/bouncy-accordion","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/bouncy-accordion?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/bouncy-accordion","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/bouncy-accordion/bouncy-accordion.tsx","content":"import {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  Pressable,\n  StyleSheet,\n  Text,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport Animated, {\n  ReduceMotion,\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 { DrawnPath, type PathSegment } from \"../../primitives/drawn-path\";\nimport { type IconSource, renderIcon } from \"../../primitives/render-icon\";\nimport { EASE_OUT } from \"../../styles/motion\";\n\nexport type BouncyAccordionItem = {\n  id: string;\n  title: string;\n  /** The answer or details. Strings get the default text style. */\n  description?: ReactNode;\n  /** A component given `{ size, color }`, or a prebuilt element. */\n  icon?: IconSource;\n  disabled?: boolean;\n};\n\nexport type BouncyAccordionClassNames = {\n  root?: string;\n  item?: string;\n  trigger?: string;\n  title?: string;\n  description?: string;\n};\n\nexport type BouncyAccordionProps = Omit<ViewProps, \"children\"> & {\n  items: BouncyAccordionItem[];\n  /** The open item. Omit it and use `defaultValue` for uncontrolled use. */\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string | null) => void;\n  /** Tapping the open item closes it. Defaults to true. */\n  collapsible?: boolean;\n  /** Opens and closes without springing, independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  classNames?: BouncyAccordionClassNames;\n};\n\n// The web version's bouncy springs, as duration and damping ratio\n// (damping ratio = 1 - bounce).\nconst ROW = {\n  duration: 550,\n  dampingRatio: 0.62,\n  reduceMotion: ReduceMotion.System,\n};\nconst OPEN = {\n  duration: 580,\n  dampingRatio: 0.68,\n  reduceMotion: ReduceMotion.System,\n};\n// Closing is a little livelier than the web, where the bounce is mostly\n// clipped once the height reaches zero.\nconst CLOSE = {\n  duration: 520,\n  dampingRatio: 0.55,\n  reduceMotion: ReduceMotion.System,\n};\nconst CHEVRON = {\n  duration: 420,\n  dampingRatio: 0.72,\n  reduceMotion: ReduceMotion.System,\n};\nconst FADE = { duration: 180, easing: EASE_OUT };\n\nconst GAP = 12;\n// How far a closing row's overshoot may pull the rows below past rest. It\n// overlaps only the closing row's empty bottom padding, in the same color.\nconst SETTLE_OVERLAP = 14;\nconst RADIUS = 28;\nconst ICON = 16;\nconst CHEVRON_PATH: PathSegment[] = [\n  [6, 9, 12, 15],\n  [12, 15, 18, 9],\n];\n\nfunction Row({\n  item,\n  open,\n  startsGroup,\n  endsGroup,\n  separated,\n  reduce,\n  classNames,\n  onToggle,\n}: {\n  item: BouncyAccordionItem;\n  open: boolean;\n  startsGroup: boolean;\n  endsGroup: boolean;\n  separated: boolean;\n  reduce: boolean;\n  classNames?: BouncyAccordionClassNames;\n  onToggle: () => void;\n}) {\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const { backgroundColor: pressedColor } =\n    useResolveClassNames(\"bg-foreground/5\");\n  const [measured, setMeasured] = useState(0);\n  // The first measurement places an initially open row without animating it.\n  const firstMeasure = useRef(true);\n  const hasContent = item.description != null;\n\n  const gap = useSharedValue(separated ? GAP : 0);\n  const top = useSharedValue(startsGroup ? RADIUS : 0);\n  const bottom = useSharedValue(endsGroup ? RADIUS : 0);\n  const height = useSharedValue(open && hasContent ? measured : 0);\n  const fade = useSharedValue(open ? 1 : 0);\n  const turn = useSharedValue(open ? 1 : 0);\n  const pressed = useSharedValue(0);\n  const drawn = useSharedValue(1);\n\n  const to = useCallback(\n    (target: number, spring: typeof ROW) =>\n      reduce ? target : withSpring(target, spring),\n    [reduce],\n  );\n\n  useEffect(() => {\n    gap.value = to(separated ? GAP : 0, ROW);\n    top.value = to(startsGroup ? RADIUS : 0, ROW);\n    bottom.value = to(endsGroup ? RADIUS : 0, ROW);\n  }, [bottom, endsGroup, gap, separated, startsGroup, to, top]);\n\n  useEffect(() => {\n    const target = open && hasContent ? measured : 0;\n    if (firstMeasure.current && measured > 0) {\n      firstMeasure.current = false;\n      height.value = target;\n    } else {\n      height.value = to(target, open ? OPEN : CLOSE);\n    }\n    fade.value = reduce ? (open ? 1 : 0) : withTiming(open ? 1 : 0, FADE);\n    turn.value = to(open ? 1 : 0, CHEVRON);\n  }, [fade, hasContent, height, measured, open, reduce, to, turn]);\n\n  // A height can't go below zero, so a closing spring's overshoot becomes a\n  // small negative margin instead: the rows below travel slightly past rest and\n  // spring back, keeping the bounce the clamp would otherwise flatten.\n  const rowStyle = useAnimatedStyle(() => ({\n    marginTop: Math.max(-SETTLE_OVERLAP, gap.value),\n    marginBottom: Math.max(-SETTLE_OVERLAP, Math.min(0, height.value)),\n    borderTopLeftRadius: Math.max(0, top.value),\n    borderTopRightRadius: Math.max(0, top.value),\n    borderBottomLeftRadius: Math.max(0, bottom.value),\n    borderBottomRightRadius: Math.max(0, bottom.value),\n  }));\n  const contentStyle = useAnimatedStyle(() => ({\n    height: Math.max(0, height.value),\n  }));\n  const fadeStyle = useAnimatedStyle(() => ({ opacity: fade.value }));\n  const chevronStyle = useAnimatedStyle(() => ({\n    transform: [{ rotate: `${turn.value * 180}deg` }],\n  }));\n  const pressStyle = useAnimatedStyle(() => ({ opacity: pressed.value }));\n\n  return (\n    <Animated.View\n      className={`overflow-hidden bg-surface ${item.disabled ? \"opacity-50\" : \"\"} ${classNames?.item ?? \"\"}`}\n      style={rowStyle}\n    >\n      <Pressable\n        accessibilityRole=\"button\"\n        accessibilityState={{ expanded: open, disabled: item.disabled }}\n        accessibilityLabel={item.title}\n        disabled={item.disabled}\n        onPress={onToggle}\n        onPressIn={() => {\n          pressed.value = withTiming(1, { duration: 80 });\n        }}\n        onPressOut={() => {\n          pressed.value = withTiming(0, { duration: 220 });\n        }}\n        className={`min-h-[54px] flex-row items-center gap-4 px-5 py-3 ${classNames?.trigger ?? \"\"}`}\n      >\n        <Animated.View\n          style={[\n            StyleSheet.absoluteFill,\n            { backgroundColor: pressedColor },\n            pressStyle,\n            styles.passThrough,\n          ]}\n        />\n        {item.icon != null ? (\n          <View style={styles.icon}>\n            {renderIcon(item.icon, { size: ICON, color: muted })}\n          </View>\n        ) : null}\n        <Text\n          className={`flex-1 text-[15px] font-medium text-foreground ${classNames?.title ?? \"\"}`}\n        >\n          {item.title}\n        </Text>\n        <Animated.View style={[styles.chevron, chevronStyle]}>\n          <DrawnPath\n            segments={CHEVRON_PATH}\n            size={ICON}\n            strokeWidth={2}\n            color={muted}\n            progress={drawn}\n          />\n        </Animated.View>\n      </Pressable>\n\n      {hasContent ? (\n        <Animated.View\n          accessibilityElementsHidden={!open}\n          importantForAccessibility={open ? \"auto\" : \"no-hide-descendants\"}\n          style={[styles.content, contentStyle]}\n        >\n          {/* Measured at its natural height, independent of the animated frame. */}\n          <Animated.View\n            style={[styles.measure, fadeStyle]}\n            onLayout={({ nativeEvent }) => {\n              setMeasured(nativeEvent.layout.height);\n            }}\n          >\n            {typeof item.description === \"string\" ? (\n              <Text\n                className={`text-[15px] leading-6 text-muted ${classNames?.description ?? \"\"}`}\n              >\n                {item.description}\n              </Text>\n            ) : (\n              item.description\n            )}\n          </Animated.View>\n        </Animated.View>\n      ) : null}\n    </Animated.View>\n  );\n}\n\n/**\n * A single-open accordion whose open row springs apart from its neighbors,\n * rounding into its own group, while its content bounces open.\n */\nexport function BouncyAccordion({\n  items,\n  value,\n  defaultValue = null,\n  onValueChange,\n  collapsible = true,\n  disableAnimation = false,\n  className = \"\",\n  classNames,\n  ...props\n}: BouncyAccordionProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue);\n  const active = value === undefined ? uncontrolled : value;\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const activeIndex = items.findIndex((item) => item.id === active);\n\n  const toggle = useCallback(\n    (id: string) => {\n      const next = active === id ? (collapsible ? null : id) : id;\n      if (next === active) return;\n      if (value === undefined) setUncontrolled(next);\n      onValueChange?.(next);\n    },\n    [active, collapsible, onValueChange, value],\n  );\n\n  return (\n    <View\n      {...props}\n      className={`w-full ${className} ${classNames?.root ?? \"\"}`}\n    >\n      {items.map((item, index) => {\n        const open = item.id === active;\n        const previousOpen = activeIndex === index - 1;\n        const nextOpen = activeIndex === index + 1;\n        return (\n          <Row\n            key={item.id}\n            item={item}\n            open={open}\n            // The open row and its neighbors each round into separate groups.\n            startsGroup={open || index === 0 || previousOpen}\n            endsGroup={open || index === items.length - 1 || nextOpen}\n            separated={index > 0 && (open || previousOpen)}\n            reduce={reduce}\n            classNames={classNames}\n            onToggle={() => toggle(item.id)}\n          />\n        );\n      })}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  passThrough: { pointerEvents: \"none\" },\n  icon: {\n    width: 28,\n    height: 28,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  chevron: {\n    width: 24,\n    height: 24,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  content: { overflow: \"hidden\" },\n  measure: {\n    position: \"absolute\",\n    top: 0,\n    left: 0,\n    right: 0,\n    paddingHorizontal: 20,\n    paddingBottom: 20,\n  },\n});\n","type":"component"},{"path":"src/components/bouncy-accordion/index.ts","content":"export type {\n  BouncyAccordionClassNames,\n  BouncyAccordionItem,\n  BouncyAccordionProps,\n} from \"./bouncy-accordion\";\nexport { BouncyAccordion } from \"./bouncy-accordion\";\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"}]}