{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "speed-dial",
"type": "registry:ui",
"title": "Speed Dial",
"description": "FAB expanding into labeled mini actions.",
"registryDependencies": [
"https://raw.githubusercontent.com/truongnat/rnui/main/registry/dist/nativewind/utils.json",
"https://raw.githubusercontent.com/truongnat/rnui/main/registry/dist/nativewind/text.json",
"https://raw.githubusercontent.com/truongnat/rnui/main/registry/dist/nativewind/theme.json"
],
"files": [
{
"path": "registry/shared/ui/speed-dial.tsx",
"type": "registry:ui",
"target": "components/ui/speed-dial.tsx",
"content": "import { useEffect, useRef, useState, type ReactNode } from 'react';\nimport {\n Animated,\n Platform,\n Pressable,\n StyleSheet,\n View,\n type GestureResponderEvent,\n type ViewProps,\n} from 'react-native';\nimport { useSafeAreaInsets } from 'react-native-safe-area-context';\nimport { Text } from '@/components/ui/text';\nimport { cn, useThemeColor } from '@/lib/utils';\n\nexport interface SpeedDialAction {\n key: string;\n icon?: ReactNode;\n label?: string;\n onPress?: () => void;\n}\n\nexport interface SpeedDialProps extends ViewProps {\n /** Main FAB content (e.g. Plus icon). */\n icon: ReactNode;\n /** Custom icon shown when open (if not using automatic 45° rotation). */\n openIcon?: ReactNode;\n actions: SpeedDialAction[];\n /** Open/close state controlled from outside. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Offset from screen edge considering device Home indicator. Default: true. */\n safeArea?: boolean;\n /** Dim the backdrop when open. Default: false. */\n overlay?: boolean;\n className?: string;\n}\n\n/**\n * Animated floating SpeedDial with spring rotation, staggered action slide-in,\n * and concrete native styling.\n */\nexport function SpeedDial({\n icon,\n openIcon,\n actions,\n open: controlledOpen,\n onOpenChange,\n safeArea = false,\n overlay = false,\n className,\n style,\n ...props\n}: SpeedDialProps) {\n const [uncontrolledOpen, setUncontrolledOpen] = useState(false);\n const isOpen = controlledOpen ?? uncontrolledOpen;\n\n const insets = useSafeAreaInsets();\n const colors = useThemeColor();\n\n const anim = useRef(new Animated.Value(isOpen ? 1 : 0)).current;\n\n const toggle = () => {\n const next = !isOpen;\n if (controlledOpen === undefined) {\n setUncontrolledOpen(next);\n }\n onOpenChange?.(next);\n };\n\n const close = () => {\n if (controlledOpen === undefined) {\n setUncontrolledOpen(false);\n }\n onOpenChange?.(false);\n };\n\n useEffect(() => {\n Animated.spring(anim, {\n toValue: isOpen ? 1 : 0,\n damping: 18,\n stiffness: 260,\n mass: 0.7,\n useNativeDriver: true,\n }).start();\n }, [isOpen, anim]);\n\n const fabRotation = anim.interpolate({\n inputRange: [0, 1],\n outputRange: ['0deg', '45deg'],\n });\n\n const overlayOpacity = anim.interpolate({\n inputRange: [0, 1],\n outputRange: [0, 1],\n });\n\n return (\n <>\n {/* Backdrop overlay */}\n {overlay && isOpen && (\n \n \n \n )}\n\n {/* Floating Actions Container */}\n \n {/* Action Items */}\n {actions.map((action, idx) => {\n const count = actions.length;\n const reverseIndex = count - 1 - idx;\n\n const itemTranslateY = anim.interpolate({\n inputRange: [0, 1],\n outputRange: [22 * (reverseIndex + 1), 0],\n });\n\n const itemScale = anim.interpolate({\n inputRange: [0, 0.4, 1],\n outputRange: [0.3, 0.75, 1],\n });\n\n const itemOpacity = anim.interpolate({\n inputRange: [0, 0.3, 1],\n outputRange: [0, 0.4, 1],\n });\n\n return (\n \n {action.label ? (\n \n \n {action.label}\n \n \n ) : null}\n\n {\n action.onPress?.();\n close();\n }}\n hitSlop={8}\n style={[\n styles.actionButton,\n {\n backgroundColor: colors.card || '#ffffff',\n borderColor: colors.border,\n },\n ]}\n >\n {action.icon}\n \n \n );\n })}\n\n {/* Main Trigger FAB with Guaranteed Solid Dimensions */}\n \n \n {isOpen && openIcon ? openIcon : icon}\n \n \n \n >\n );\n}\n\nconst styles = StyleSheet.create({\n backdrop: {\n backgroundColor: 'rgba(0, 0, 0, 0.35)',\n zIndex: 40,\n },\n actionRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: 10,\n },\n actionLabelPill: {\n borderRadius: 9999,\n borderWidth: 1,\n paddingHorizontal: 12,\n paddingVertical: 6,\n ...Platform.select({\n ios: {\n shadowColor: '#000000',\n shadowOffset: { width: 0, height: 2 },\n shadowOpacity: 0.1,\n shadowRadius: 4,\n },\n android: {\n elevation: 3,\n },\n default: {},\n }),\n },\n labelText: {\n fontSize: 12,\n fontWeight: '700',\n },\n actionButton: {\n width: 44,\n height: 44,\n borderRadius: 22,\n borderWidth: 1,\n alignItems: 'center',\n justifyContent: 'center',\n ...Platform.select({\n ios: {\n shadowColor: '#000000',\n shadowOffset: { width: 0, height: 2 },\n shadowOpacity: 0.12,\n shadowRadius: 4,\n },\n android: {\n elevation: 4,\n },\n default: {},\n }),\n },\n mainFab: {\n width: 56,\n height: 56,\n borderRadius: 28,\n alignItems: 'center',\n justifyContent: 'center',\n ...Platform.select({\n ios: {\n shadowColor: '#000000',\n shadowOffset: { width: 0, height: 4 },\n shadowOpacity: 0.22,\n shadowRadius: 8,\n },\n android: {\n elevation: 6,\n },\n default: {},\n }),\n },\n});\n"
}
]
}