Glass FAB that splits like a droplet — action orbs spring out staggered then reel back into the blob
components/ui/glass-fab-morph/glass-fab-morph.tsx@lumioui/core@latestreact-native-reanimated@*| actions | GlassFabMorphAction[] | |
| icon? | IconName | |
| onAction? | (index: number) => void | |
| open? | boolean | Controlled open state — uncontrolled when omitted. |
| defaultOpen? | boolean | |
| onOpenChange? | (open: boolean) => void | |
| direction? | "up" | "left" | Direction the orbs fan out. Default "up". |
| className? | string |
Glass FAB that splits like a droplet — action orbs spring out staggered then reel back into the blob
import { GlassFabMorph } from "@/components/ui/glass-fab-morph/glass-fab-morph";
export function Example() {
return (
<GlassFabMorph />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, type ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { cn, spring, useHaptic, useReducedMotion } from "@lumioui/core";
import { Glass, GlassContainer } from "../glass";
import { Icon, type IconName } from "../icon";
import { Text } from "../text";
import { AnimatedPressable } from "@lumioui/core";
/**
* GlassFabMorph — the glass FAB that splits like a droplet: press it and
* action orbs spring out of the main blob (staggered pop, slightly apart
* so iOS 26 unions them mid-merge), then reel back in on close.
*
* <GlassFabMorph icon="plus" actions={[{icon:"camera"},{icon:"edit"}]} onAction={fn} />
* Expo Go compatible.
*/
export interface GlassFabMorphAction {
icon: IconName;
label?: string;
accessibilityLabel?: string;
}
export interface GlassFabMorphProps extends Omit<ViewProps, "className"> {
icon?: IconName;
actions: GlassFabMorphAction[];
onAction?: (index: number) => void;
/** Controlled open state — uncontrolled when omitted. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Direction the orbs fan out. Default "up". */
direction?: "up" | "left";
className?: string;
}
export const GlassFabMorph = forwardRef<
React.ElementRef<typeof View>,
GlassFabMorphProps
>(function GlassFabMorph(
{
icon = "plus",
actions,
onAction,
open,
defaultOpen = false,
onOpenChange,
direction = "up",
className,
...props
},
ref
) {
const [internal, setInternal] = React.useState(defaultOpen);
const isOpen = open ?? internal;
const haptics = useHaptic();
const reduce = useReducedMotion();
const rotation = useSharedValue(0);
React.useEffect(() => {
rotation.value = reduce ? (isOpen ? 45 : 0) : withSpring(isOpen ? 45 : 0, spring.glide);
}, [isOpen, reduce, rotation]);
const toggle = () => {
haptics.light();
const next = !isOpen;
if (open === undefined) setInternal(next);
onOpenChange?.(next);
};
const iconStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<View
ref={ref}
className={cn(
"items-center",
direction === "up" ? "flex-col-reverse" : "flex-row-reverse",
className
)}
{...props}
>
<Glass interactive className="h-16 w-16 items-center justify-center rounded-full">
<AnimatedPressable
onPress={toggle}
accessibilityRole="button"
accessibilityState={{ expanded: isOpen }}
accessibilityLabel={isOpen ? "Close actions" : "Open actions"}
className="h-full w-full items-center justify-center"
>
<Animated.View style={iconStyle}>
<Icon name={icon} size="lg" color="foreground" />
</Animated.View>
</AnimatedPressable>
</Glass>
<GlassContainer
spacing={14}
effect="regular"
className={cn(
"items-center",
direction === "up" ? "mb-3 flex-col-reverse gap-3" : "mr-3 flex-row-reverse gap-3"
)}
>
{actions.map((a, i) => (
<ActionOrb
key={a.label ?? a.icon}
action={a}
index={i}
open={isOpen}
direction={direction}
onPress={() => {
haptics.selection();
onAction?.(i);
toggle();
}}
/>
))}
</GlassContainer>
</View>
);
});
function ActionOrb({
action,
index,
open,
direction,
onPress,
}: {
action: GlassFabMorphAction;
index: number;
open: boolean;
direction: "up" | "left";
onPress: () => void;
}) {
const reduce = useReducedMotion();
const progress = useSharedValue(open ? 1 : 0);
React.useEffect(() => {
// stagger out one after another, faster reel back in
const delay = open ? index * 45 : (0 + index) * 20;
const t = setTimeout(() => {
progress.value = reduce ? (open ? 1 : 0) : withSpring(open ? 1 : 0, open ? spring.pop : spring.press);
}, reduce ? 0 : delay);
return () => clearTimeout(t);
}, [open, index, progress, reduce]);
const style = useAnimatedStyle(() => {
const p = progress.value;
return {
opacity: p,
transform: [
direction === "up"
? { translateY: (1 - p) * (16 + index * 8) }
: { translateX: (1 - p) * -(16 + index * 8) },
{ scale: 0.5 + p * 0.5 },
],
};
});
return (
<Animated.View style={style} pointerEvents={open ? "auto" : "none"}>
<Glass
interactive
className={cn(
"items-center justify-center rounded-full",
action.label && "h-12 flex-row gap-2 px-4"
)}
style={!action.label ? { width: 52, height: 52 } : undefined}
>
<AnimatedPressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={action.accessibilityLabel ?? action.label}
className="h-full w-full flex-row items-center justify-center gap-2"
>
<Icon name={action.icon} size="sm" color="foreground" />
{action.label ? (
<Text variant="small" className="font-medium">
{action.label}
</Text>
) : null}
</AnimatedPressable>
</Glass>
</Animated.View>
);
}
GlassFabMorph.displayName = "GlassFabMorph";