{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bloom-menu",
  "type": "registry:item",
  "title": "Bloom Menu",
  "description": "A floating action button that blooms: the plus turns, a halo opens and actions spring out one by one.",
  "categories": [
    "interaction"
  ],
  "dependencies": [
    "react-native-reanimated",
    "react-native-worklets"
  ],
  "registryDependencies": [
    "https://kinetik-ui.dev/r/kinetik-tokens.json",
    "https://kinetik-ui.dev/r/kinetik-motion.json",
    "https://kinetik-ui.dev/r/kinetik-haptics.json"
  ],
  "files": [
    {
      "path": "src/components/kinetik/bloom-menu.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/bloom-menu.tsx",
      "content": "import { useEffect, useState, type ReactNode } from 'react';\nimport {\n  Pressable,\n  StyleSheet,\n  Text,\n  View,\n  type AccessibilityActionEvent,\n  type StyleProp,\n  type ViewStyle,\n} from 'react-native';\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withSpring,\n  type SharedValue,\n} from 'react-native-reanimated';\n\nimport { useHaptic } from '../../lib/kinetik/hooks/use-haptic';\nimport { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';\nimport { spring } from '../../lib/kinetik/motion/motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { glow } from '../../lib/kinetik/tokens/tokens';\n\nexport type BloomAction = {\n  key: string;\n  /** Shown beside the action in the column layout, and read by screen readers. */\n  label: string;\n  icon: ReactNode;\n  onPress: () => void;\n};\n\nexport type BloomMenuProps = {\n  /** Two to five actions, nearest the button first. */\n  actions: readonly BloomAction[];\n  /** `column` stacks labelled actions above the button; `arc` fans icons around it. Defaults to `column`. */\n  layout?: 'column' | 'arc';\n  /** Controlled state. Leave unset to let the menu keep its own. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Name of the main button. Defaults to 'Create'. */\n  accessibilityLabel?: string;\n  /** A light tap as it opens and as an action is chosen. Defaults to true. */\n  haptics?: boolean;\n  /**\n   * Position the menu with this, usually absolutely in a corner. The box is\n   * large enough to hold the open menu but lets touches through where empty.\n   */\n  style?: StyleProp<ViewStyle>;\n};\n\nconst FAB = 60;\nconst ACTION = 48;\nconst GAP = 12;\n/** Width the column layout leaves for labels. */\nconst COLUMN_WIDTH = 240;\n/** Delay between actions as they bloom out, in ms. */\nconst STAGGER = 34;\n\ntype Spot = { x: number; y: number };\n\nfunction layoutFor(kind: 'column' | 'arc', n: number) {\n  if (kind === 'column') {\n    const height = FAB + GAP + n * (ACTION + GAP);\n    const c = { x: COLUMN_WIDTH - FAB / 2, y: height - FAB / 2 };\n    const spots: Spot[] = Array.from({ length: n }, (_, i) => ({\n      x: c.x,\n      y: c.y - FAB / 2 - GAP - ACTION / 2 - i * (ACTION + GAP),\n    }));\n    return { width: COLUMN_WIDTH, height, c, spots, reach: c.y - spots[n - 1]!.y + ACTION };\n  }\n  // A quarter circle from the left of the button round to above it.\n  const r = 92 + n * 6;\n  const size = r + FAB / 2 + ACTION / 2 + 8;\n  const c = { x: size - FAB / 2, y: size - FAB / 2 };\n  const spots: Spot[] = Array.from({ length: n }, (_, i) => {\n    const a = Math.PI * (1 + (n === 1 ? 0.25 : (0.5 * i) / (n - 1)));\n    return { x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r };\n  });\n  return { width: size, height: size, c, spots, reach: r + ACTION };\n}\n\n/**\n * A floating action button that blooms. The plus turns into a cross, a soft\n * halo opens behind it, and the actions spring out one after another, in a\n * labelled column or a fan of icons.\n */\nexport function BloomMenu({\n  actions,\n  layout = 'column',\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  accessibilityLabel = 'Create',\n  haptics = true,\n  style,\n}: BloomMenuProps) {\n  const { colors } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const [inner, setInner] = useState(defaultOpen);\n  const isOpen = open ?? inner;\n\n  const n = actions.length;\n  const box = layoutFor(layout, n);\n\n  const t = useSharedValue(isOpen ? 1 : 0);\n  const pressed = useSharedValue(0);\n  useEffect(() => {\n    t.set(withSpring(isOpen ? 1 : 0, spring(isOpen ? 'bouncy' : 'snappy', reduced)));\n  }, [isOpen, reduced, t]);\n\n  const setOpen = (next: boolean) => {\n    if (open === undefined) setInner(next);\n    if (next) haptic('tap');\n    onOpenChange?.(next);\n  };\n  const choose = (action: BloomAction) => {\n    haptic('tap');\n    setOpen(false);\n    action.onPress();\n  };\n\n  const cross = useAnimatedStyle(() => ({\n    transform: [{ rotate: `${t.get() * 135}deg` }, { scale: 1 - pressed.get() * 0.1 }],\n  }));\n  const halo = useAnimatedStyle(() => ({\n    opacity: Math.min(1, t.get() * 1.5),\n    transform: [{ scale: Math.max(0, t.get()) }],\n  }));\n\n  const onAccessibilityAction = (e: AccessibilityActionEvent) => {\n    if (e.nativeEvent.actionName === 'escape' && isOpen) setOpen(false);\n  };\n\n  return (\n    <View pointerEvents=\"box-none\" style={[{ width: box.width, height: box.height }, style]}>\n      <Animated.View\n        pointerEvents=\"none\"\n        style={[\n          styles.halo,\n          {\n            width: box.reach * 2,\n            height: box.reach * 2,\n            borderRadius: box.reach,\n            left: box.c.x - box.reach,\n            top: box.c.y - box.reach,\n            backgroundColor: colors.azureSoft,\n          },\n          halo,\n        ]}\n      />\n\n      {actions.map((action, i) => (\n        <Petal\n          key={action.key}\n          action={action}\n          spot={box.spots[i]!}\n          from={box.c}\n          width={box.width}\n          labelled={layout === 'column'}\n          open={isOpen}\n          // Out from the nearest; back in from the farthest.\n          delay={(isOpen ? i : n - 1 - i) * STAGGER}\n          reduced={reduced}\n          onPress={() => choose(action)}\n        />\n      ))}\n\n      <Pressable\n        onPress={() => setOpen(!isOpen)}\n        onPressIn={() => pressed.set(withSpring(1, spring('snappy', reduced)))}\n        onPressOut={() => pressed.set(withSpring(0, spring('bouncy', reduced)))}\n        accessibilityRole=\"button\"\n        accessibilityLabel={accessibilityLabel}\n        accessibilityState={{ expanded: isOpen }}\n        accessibilityActions={isOpen ? [{ name: 'escape' }] : []}\n        onAccessibilityAction={onAccessibilityAction}\n        style={[\n          styles.fab,\n          {\n            left: box.c.x - FAB / 2,\n            top: box.c.y - FAB / 2,\n            backgroundColor: colors.azure,\n            boxShadow: glow(colors.azure),\n          },\n        ]}\n      >\n        <Animated.View style={[styles.plus, cross]}>\n          <View style={[styles.bar, { backgroundColor: colors.onAzure }]} />\n          <View style={[styles.bar, styles.upright, { backgroundColor: colors.onAzure }]} />\n        </Animated.View>\n      </Pressable>\n    </View>\n  );\n}\n\nfunction Petal({\n  action,\n  spot,\n  from,\n  width,\n  labelled,\n  open,\n  delay,\n  reduced,\n  onPress,\n}: {\n  action: BloomAction;\n  spot: Spot;\n  from: Spot;\n  /** Width of the menu's box, to anchor the petal by its right edge. */\n  width: number;\n  labelled: boolean;\n  open: boolean;\n  delay: number;\n  reduced: boolean;\n  onPress: () => void;\n}) {\n  const { colors, font, radius, shadow } = useKinetikTheme();\n  const t: SharedValue<number> = useSharedValue(open ? 1 : 0);\n  useEffect(() => {\n    const to = withSpring(open ? 1 : 0, spring(open ? 'bouncy' : 'snappy', reduced));\n    t.set(reduced ? to : withDelay(delay, to));\n  }, [open, delay, reduced, t]);\n\n  const dx = from.x - spot.x;\n  const dy = from.y - spot.y;\n  const travel = useAnimatedStyle(() => {\n    const k = t.get();\n    return {\n      opacity: Math.max(0, Math.min(1, k * 2)),\n      transform: [\n        { translateX: dx * (1 - k) },\n        { translateY: dy * (1 - k) },\n        { scale: 0.4 + 0.6 * k },\n      ],\n    };\n  });\n  const tag = useAnimatedStyle(() => ({\n    opacity: Math.max(0, Math.min(1, t.get() * 2 - 0.8)),\n    transform: [{ translateX: (1 - t.get()) * 14 }],\n  }));\n\n  return (\n    <Animated.View\n      pointerEvents={open ? 'box-none' : 'none'}\n      accessibilityElementsHidden={!open}\n      importantForAccessibility={open ? 'auto' : 'no-hide-descendants'}\n      style={[\n        styles.petal,\n        { right: width - spot.x - ACTION / 2, top: spot.y - ACTION / 2 },\n        travel,\n      ]}\n    >\n      <Pressable\n        onPress={onPress}\n        accessibilityRole=\"button\"\n        accessibilityLabel={action.label}\n        style={styles.petalRow}\n      >\n        {labelled ? (\n          <Animated.View\n            style={[\n              styles.tag,\n              {\n                backgroundColor: colors.surfaceRaised,\n                borderColor: colors.border,\n                borderRadius: radius.sm,\n                boxShadow: shadow.sm,\n              },\n              tag,\n            ]}\n          >\n            <Text\n              numberOfLines={1}\n              style={{\n                color: colors.text,\n                fontSize: font.size.sm,\n                fontWeight: font.weight.semibold,\n              }}\n            >\n              {action.label}\n            </Text>\n          </Animated.View>\n        ) : null}\n        <View\n          style={[\n            styles.action,\n            {\n              backgroundColor: colors.surfaceRaised,\n              borderColor: colors.border,\n              boxShadow: shadow.md,\n            },\n          ]}\n        >\n          {action.icon}\n        </View>\n      </Pressable>\n    </Animated.View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  halo: { position: 'absolute' },\n  fab: {\n    position: 'absolute',\n    width: FAB,\n    height: FAB,\n    borderRadius: FAB / 2,\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n  plus: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center' },\n  bar: { position: 'absolute', width: 22, height: 2.5, borderRadius: 2 },\n  upright: { transform: [{ rotate: '90deg' }] },\n  // Anchored by its right edge, so labels grow leftwards; it scales from that side too.\n  petal: { position: 'absolute', transformOrigin: 'right' },\n  petalRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },\n  tag: {\n    paddingHorizontal: 10,\n    paddingVertical: 6,\n    borderWidth: StyleSheet.hairlineWidth,\n  },\n  action: {\n    width: ACTION,\n    height: ACTION,\n    borderRadius: ACTION / 2,\n    alignItems: 'center',\n    justifyContent: 'center',\n    borderWidth: StyleSheet.hairlineWidth,\n  },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "interaction",
    "web": "live",
    "perf": {
      "budget": "One spring per action plus one for the button; views only, transforms and opacity. Nothing runs at rest.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 17,
          "p90Ms": 20,
          "baselineMs": 20,
          "jankyPercent": 2.88,
          "atRest": "idle",
          "memoryMb": 148
        }
      ]
    },
    "a11y": {
      "reducedMotion": "Actions appear in place without travelling or staggering; the halo and the cross still change.",
      "screenReader": "The main button reports expanded; closed actions are hidden from screen readers. The escape gesture closes it.",
      "touchTarget": "60 pt main button, 48 pt actions; in the column layout the label is part of the action's target."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { BloomMenu } from '@/components/kinetik/bloom-menu';\n\n<BloomMenu\n  style={{ position: 'absolute', right: 16, bottom: 24 }}\n  actions={[\n    { key: 'photo', label: 'Photo', icon: <CameraIcon />, onPress: addPhoto },\n    { key: 'note', label: 'Note', icon: <NoteIcon />, onPress: addNote },\n    { key: 'voice', label: 'Voice memo', icon: <MicIcon />, onPress: recordVoice },\n  ]}\n/>;\n```\n\nPosition the menu with `style`, usually in a bottom corner. Its box holds the open menu but lets touches through where it is empty. `layout=\"arc\"` fans icons around the button instead of stacking labelled rows.\n\n## When to use\n\n- A screen whose main job is creating things of a few kinds: a note, a photo, a voice memo.\n- When two to five creation actions would otherwise crowd a toolbar.\n\n## When not to use\n\n- A single action. Use a plain floating button.\n- Navigation. Use tabs or a dock; a floating menu hides where people can go.\n\n## Notes\n\n- Pass `open` and `onOpenChange` to dim the screen behind it or to close it when the user taps elsewhere; the demo does both.\n- Actions are listed nearest the button first. The arc layout has no visible labels, so keep its icons unmistakable.\n- With reduced motion the actions appear in place.\n"
  },
  "docs": "Prefer `npx kinetik-ui add bloom-menu`: it installs dependencies with expo install, so versions match your Expo SDK."
}
