Morph Fab
Skia gooey radial/directional FAB menu with staggered blob-merge spring animation.
Web preview — components render natively on iOS & Android
import { MorphFab, MorphFabTrigger, MorphFabItem, MorphFabItemIcon, MorphFabItemLabel } from "@/components/ui/morph-fab";
import { Camera, Image, Mic } from "lucide-react-native";
<MorphFab direction="up">
<MorphFabTrigger />
<MorphFabItem value="camera" onSelect={(value) => console.log(value)}>
<MorphFabItemIcon><Camera size={20} color="#fafafa" /></MorphFabItemIcon>
<MorphFabItemLabel>Camera</MorphFabItemLabel>
</MorphFabItem>
<MorphFabItem value="photo" onSelect={(value) => console.log(value)}>
<MorphFabItemIcon><Image size={20} color="#fafafa" /></MorphFabItemIcon>
<MorphFabItemLabel>Photo</MorphFabItemLabel>
</MorphFabItem>
<MorphFabItem value="audio" onSelect={(value) => console.log(value)}>
<MorphFabItemIcon><Mic size={20} color="#fafafa" /></MorphFabItemIcon>
<MorphFabItemLabel>Audio</MorphFabItemLabel>
</MorphFabItem>
</MorphFab>Installation#
npx @aniui/cli add morph-fabUsage#
import { MorphFab, MorphFabTrigger, MorphFabItem, MorphFabItemIcon, MorphFabItemLabel } from "@/components/ui/morph-fab";
import { Camera, Image, Mic } from "lucide-react-native";
<MorphFab direction="up">
<MorphFabTrigger />
<MorphFabItem value="camera" onSelect={(value) => console.log(value)}>
<MorphFabItemIcon><Camera size={20} color="#fafafa" /></MorphFabItemIcon>
<MorphFabItemLabel>Camera</MorphFabItemLabel>
</MorphFabItem>
<MorphFabItem value="photo" onSelect={(value) => console.log(value)}>
<MorphFabItemIcon><Image size={20} color="#fafafa" /></MorphFabItemIcon>
<MorphFabItemLabel>Photo</MorphFabItemLabel>
</MorphFabItem>
<MorphFabItem value="audio" onSelect={(value) => console.log(value)}>
<MorphFabItemIcon><Mic size={20} color="#fafafa" /></MorphFabItemIcon>
<MorphFabItemLabel>Audio</MorphFabItemLabel>
</MorphFabItem>
</MorphFab>Source#
components/ui/morph-fab.tsx
import React, {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useState,
} from "react";
import { Dimensions, Pressable, Text, View, type LayoutChangeEvent } from "react-native";
import { Blur, Canvas, ColorMatrix, Group, Paint, RoundedRect } from "@shopify/react-native-skia";
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withSpring,
type SharedValue,
type WithSpringConfig,
} from "react-native-reanimated";
import { Plus } from "lucide-react-native";
import { cn } from "@/lib/utils";
type MorphFabDirection = "up" | "down" | "left" | "right";
type MorphFabAlign = "start" | "center" | "end";
type MorphFabRect = { x: number; y: number; w: number; h: number; r: number };
type MorphFabItemEntry = { id: string; w: number; h: number; color?: string };
type MorphFabGeo = {
layerW: number;
layerH: number;
left: number;
top: number;
fab: MorphFabRect;
slots: MorphFabRect[];
};
const OPEN_SPRING: WithSpringConfig = { stiffness: 140, damping: 17, mass: 0.45 };
const CLOSE_SPRING: WithSpringConfig = { stiffness: 170, damping: 20, mass: 0.45 };
const PRESS_IN_SPRING: WithSpringConfig = { stiffness: 150, damping: 14, mass: 0.5 };
const PRESS_OUT_SPRING: WithSpringConfig = { stiffness: 260, damping: 16, mass: 0.5 };
// Alpha-threshold ColorMatrix: sharpens a blurred layer's alpha channel so
// nearby blurred shapes visually fuse into one blob instead of overlapping
// translucently — the same metaball technique used elsewhere in this family.
const GOO_MATRIX = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 22, -11];
const LAYER_PADDING = 36;
function clampRadius(r: number, w: number, h: number) {
return Math.max(0, Math.min(r, Math.min(w, h) / 2));
}
function crossOffset(align: MorphFabAlign, span: number, itemSpan: number) {
if (align === "start") return 0;
if (align === "end") return span - itemSpan;
return (span - itemSpan) / 2;
}
function buildGeo(
triggerW: number,
triggerH: number,
items: MorphFabItemEntry[],
direction: MorphFabDirection,
align: MorphFabAlign,
sideOffset: number,
spacing: number,
itemRadius: number,
triggerRadius: number,
): MorphFabGeo {
const empty: MorphFabGeo = {
layerW: 0,
layerH: 0,
left: 0,
top: 0,
fab: { x: 0, y: 0, w: 0, h: 0, r: 0 },
slots: [],
};
if (triggerW <= 0 || triggerH <= 0) return empty;
const vertical = direction === "up" || direction === "down";
const slots: MorphFabRect[] = [];
let run = sideOffset;
for (const item of items) {
const r = clampRadius(itemRadius, item.w, item.h);
let x = 0;
let y = 0;
if (direction === "up") {
x = crossOffset(align, triggerW, item.w);
y = -(run + item.h);
} else if (direction === "down") {
x = crossOffset(align, triggerW, item.w);
y = triggerH + run;
} else if (direction === "left") {
x = -(run + item.w);
y = crossOffset(align, triggerH, item.h);
} else {
x = triggerW + run;
y = crossOffset(align, triggerH, item.h);
}
slots.push({ x, y, w: item.w, h: item.h, r });
run += (vertical ? item.h : item.w) + spacing;
}
let minX = 0;
let minY = 0;
let maxX = triggerW;
let maxY = triggerH;
for (const slot of slots) {
minX = Math.min(minX, slot.x);
minY = Math.min(minY, slot.y);
maxX = Math.max(maxX, slot.x + slot.w);
maxY = Math.max(maxY, slot.y + slot.h);
}
return {
left: minX - LAYER_PADDING,
top: minY - LAYER_PADDING,
layerW: maxX - minX + LAYER_PADDING * 2,
layerH: maxY - minY + LAYER_PADDING * 2,
fab: { x: 0, y: 0, w: triggerW, h: triggerH, r: clampRadius(triggerRadius, triggerW, triggerH) },
slots,
};
}
function itemProgress(p: number, index: number, count: number, stagger: number) {
"worklet";
const span = Math.max(0.0001, 1 - stagger * Math.max(0, count - 1));
const t = (p - index * stagger) / span;
return t < 0 ? 0 : t > 1 ? 1 : t;
}
function slotAt(geo: MorphFabGeo, index: number, p: number): MorphFabRect {
"worklet";
const fab = geo.fab;
const slot = geo.slots[index];
if (!slot) return fab;
const lerp = (a: number, b: number) => a + (b - a) * p;
return {
x: lerp(fab.x, slot.x),
y: lerp(fab.y, slot.y),
w: lerp(fab.w, slot.w),
h: lerp(fab.h, slot.h),
r: lerp(fab.r, slot.r),
};
}
type MorphFabContextValue = {
open: boolean;
toggle: () => void;
setOpen: (open: boolean) => void;
progress: SharedValue<number>;
triggerScale: SharedValue<number>;
items: MorphFabItemEntry[];
registerItem: (id: string, color?: string) => void;
unregisterItem: (id: string) => void;
updateItem: (id: string, w: number, h: number) => void;
selectItem: (value: string | undefined, onSelect?: (value?: string) => void) => void;
geo: MorphFabGeo;
stagger: number;
itemPadding: number;
itemGap: number;
};
const MorphFabContext = createContext<MorphFabContextValue | null>(null);
function useMorphFabContext(component: string) {
const ctx = useContext(MorphFabContext);
if (!ctx) throw new Error(`${component} must be rendered inside <MorphFab>.`);
return ctx;
}
export interface MorphFabProps {
children: React.ReactNode;
className?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
direction?: MorphFabDirection;
align?: MorphFabAlign;
sideOffset?: number;
spacing?: number;
itemRadius?: number;
itemPadding?: number;
itemGap?: number;
triggerRadius?: number;
triggerColor?: string;
itemColor?: string;
gooStrength?: number;
stagger?: number;
closeOnSelect?: boolean;
dismissOnOutsidePress?: boolean;
}
/** Root: a Skia goo layer merges the trigger + item blobs, while real
* Pressable content sits on top so taps, accessibility, and layout stay
* ordinary React Native — the canvas is purely a visual metaball behind it. */
export function MorphFab({
children,
className,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
direction = "up",
align = "center",
sideOffset = 16,
spacing = 12,
itemRadius = 999,
itemPadding = 16,
itemGap = 10,
triggerRadius = 999,
triggerColor = "#18181b",
itemColor = "#27272a",
gooStrength = 9,
stagger = 0.1,
closeOnSelect = true,
dismissOnOutsidePress = true,
}: MorphFabProps) {
const isControlled = controlledOpen !== undefined;
const [uncontrolled, setUncontrolled] = useState(defaultOpen);
const open = isControlled ? controlledOpen : uncontrolled;
const progress = useSharedValue(defaultOpen ? 1 : 0);
const triggerScale = useSharedValue(1);
const [triggerSize, setTriggerSize] = useState({ w: 0, h: 0 });
const [items, setItems] = useState<MorphFabItemEntry[]>([]);
const setOpen = useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolled(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [open, setOpen]);
const selectItem = useCallback(
(value?: string, onSelect?: (value?: string) => void) => {
onSelect?.(value);
if (closeOnSelect) setOpen(false);
},
[closeOnSelect, setOpen],
);
useEffect(() => {
progress.value = withSpring(open ? 1 : 0, open ? OPEN_SPRING : CLOSE_SPRING);
}, [open, progress]);
const registerItem = useCallback((id: string, color?: string) => {
setItems((prev) => (prev.some((i) => i.id === id) ? prev : [...prev, { id, w: 0, h: 0, color }]));
}, []);
const unregisterItem = useCallback((id: string) => {
setItems((prev) => prev.filter((i) => i.id !== id));
}, []);
const updateItem = useCallback((id: string, w: number, h: number) => {
setItems((prev) =>
prev.map((i) => (i.id === id && (Math.abs(i.w - w) > 0.5 || Math.abs(i.h - h) > 0.5) ? { ...i, w, h } : i)),
);
}, []);
const geo = useMemo(
() => buildGeo(triggerSize.w, triggerSize.h, items, direction, align, sideOffset, spacing, itemRadius, triggerRadius),
[triggerSize, items, direction, align, sideOffset, spacing, itemRadius, triggerRadius],
);
const ready = geo.layerW > 0 && items.every((i) => i.w > 0 && i.h > 0);
const ctx = useMemo<MorphFabContextValue>(
() => ({
open,
toggle,
setOpen,
progress,
triggerScale,
items,
registerItem,
unregisterItem,
updateItem,
selectItem,
geo,
stagger,
itemPadding,
itemGap,
}),
[open, toggle, setOpen, progress, triggerScale, items, registerItem, unregisterItem, updateItem, selectItem, geo, stagger, itemPadding, itemGap],
);
const cx = geo.fab.x + geo.fab.w / 2;
const cy = geo.fab.y + geo.fab.h / 2;
const fx = useDerivedValue(() => cx - (geo.fab.w * triggerScale.value) / 2, [geo]);
const fy = useDerivedValue(() => cy - (geo.fab.h * triggerScale.value) / 2, [geo]);
const fw = useDerivedValue(() => geo.fab.w * triggerScale.value, [geo]);
const fh = useDerivedValue(() => geo.fab.h * triggerScale.value, [geo]);
const fr = useDerivedValue(() => geo.fab.r * triggerScale.value, [geo]);
const { width: screenW, height: screenH } = Dimensions.get("window");
return (
<MorphFabContext.Provider value={ctx}>
<View className={cn("relative", className)}>
{open && dismissOnOutsidePress ? (
<Pressable
accessible={false}
onPress={() => setOpen(false)}
style={{ position: "absolute", left: -screenW, top: -screenH, width: screenW * 2, height: screenH * 2, zIndex: 5 }}
/>
) : null}
{ready ? (
<Canvas
pointerEvents="none"
style={{ position: "absolute", zIndex: 0, left: geo.left, top: geo.top, width: geo.layerW, height: geo.layerH }}
>
<Group
transform={[{ translateX: -geo.left }, { translateY: -geo.top }]}
layer={
<Paint>
<Blur blur={gooStrength} />
<ColorMatrix matrix={GOO_MATRIX} />
</Paint>
}
>
<RoundedRect x={fx} y={fy} width={fw} height={fh} r={fr} color={triggerColor} />
{items.map((item, index) => (
<MorphFabBlob
key={item.id}
index={index}
count={items.length}
geo={geo}
progress={progress}
stagger={stagger}
color={item.color ?? itemColor}
/>
))}
</Group>
</Canvas>
) : null}
<View
pointerEvents="box-none"
onLayout={(e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) => (prev.w === width && prev.h === height ? prev : { w: width, h: height }));
}}
style={{ alignSelf: "flex-start", zIndex: 10 }}
>
{children}
</View>
</View>
</MorphFabContext.Provider>
);
}
function MorphFabBlob({
index,
count,
geo,
progress,
stagger,
color,
}: {
index: number;
count: number;
geo: MorphFabGeo;
progress: SharedValue<number>;
stagger: number;
color: string;
}) {
const rect = useDerivedValue(
() => slotAt(geo, index, itemProgress(progress.value, index, count, stagger)),
[geo, index, count, stagger],
);
const x = useDerivedValue(() => rect.value.x);
const y = useDerivedValue(() => rect.value.y);
const w = useDerivedValue(() => rect.value.w);
const h = useDerivedValue(() => rect.value.h);
const r = useDerivedValue(() => rect.value.r);
return <RoundedRect x={x} y={y} width={w} height={h} r={r} color={color} />;
}
export interface MorphFabTriggerProps extends Omit<React.ComponentPropsWithoutRef<typeof Pressable>, "children"> {
children?: React.ReactNode;
className?: string;
size?: number;
pressScale?: number;
rotate?: number;
}
export function MorphFabTrigger({ children, className, size = 60, pressScale = 0.94, rotate = 45, ...props }: MorphFabTriggerProps) {
const { toggle, open, progress, triggerScale } = useMorphFabContext("MorphFabTrigger");
const pressStyle = useAnimatedStyle(() => ({ transform: [{ scale: triggerScale.value }] }));
const iconStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${interpolate(progress.value, [0, 1], [0, rotate])}deg` }],
}));
return (
<Animated.View style={pressStyle}>
<Pressable
onPress={toggle}
onPressIn={() => {
triggerScale.value = withSpring(pressScale, PRESS_IN_SPRING);
}}
onPressOut={() => {
triggerScale.value = withSpring(1, PRESS_OUT_SPRING);
}}
accessible={true}
accessibilityRole="button"
accessibilityState={{ expanded: open }}
className={cn("min-h-12 min-w-12 items-center justify-center", className)}
style={{ width: size, height: size, borderRadius: size / 2 }}
{...props}
>
<Animated.View style={iconStyle}>{children ?? <Plus size={24} color="#fafafa" strokeWidth={2.5} />}</Animated.View>
</Pressable>
</Animated.View>
);
}
export interface MorphFabItemProps extends React.ComponentPropsWithoutRef<typeof Pressable> {
className?: string;
value?: string;
disabled?: boolean;
onSelect?: (value?: string) => void;
color?: string;
pressScale?: number;
}
export function MorphFabItem({ children, className, value, disabled = false, onSelect, color, pressScale = 0.92, ...props }: MorphFabItemProps) {
const id = useId();
const { items, registerItem, unregisterItem, updateItem, selectItem, geo, progress, stagger, open, itemPadding, itemGap } =
useMorphFabContext("MorphFabItem");
const index = items.findIndex((i) => i.id === id);
const count = items.length;
const slot = index >= 0 ? geo.slots[index] : undefined;
const pressed = useSharedValue(0);
useEffect(() => {
registerItem(id, color);
return () => unregisterItem(id);
}, [id, registerItem, unregisterItem, color]);
const animatedStyle = useAnimatedStyle(() => {
const t = index < 0 ? 0 : itemProgress(progress.value, index, count, stagger);
const target = geo.slots[index] ?? geo.fab;
const dx = geo.fab.x + geo.fab.w / 2 - (target.x + target.w / 2);
const dy = geo.fab.y + geo.fab.h / 2 - (target.y + target.h / 2);
return {
opacity: interpolate(t, [0, 0.55, 1], [0, 0, 1], Extrapolation.CLAMP),
transform: [
{ translateX: dx * (1 - t) },
{ translateY: dy * (1 - t) },
{ scale: interpolate(t, [0, 1], [0.6, 1], Extrapolation.CLAMP) * interpolate(pressed.value, [0, 1], [1, pressScale]) },
],
};
}, [geo, index, count, stagger, pressScale]);
return (
<Animated.View
onLayout={(e: LayoutChangeEvent) => updateItem(id, e.nativeEvent.layout.width, e.nativeEvent.layout.height)}
pointerEvents={open ? "box-none" : "none"}
style={[{ position: "absolute", left: slot?.x ?? 0, top: slot?.y ?? 0, zIndex: 10 }, animatedStyle]}
>
<Pressable
disabled={disabled || !open}
onPress={() => selectItem(value, onSelect)}
onPressIn={() => {
pressed.value = withSpring(1, PRESS_IN_SPRING);
}}
onPressOut={() => {
pressed.value = withSpring(0, PRESS_OUT_SPRING);
}}
accessible={true}
accessibilityRole="button"
accessibilityState={{ disabled }}
className={cn("min-h-12 min-w-12 flex-row items-center justify-center", className)}
style={{ padding: itemPadding, gap: itemGap }}
{...props}
>
{children}
</Pressable>
</Animated.View>
);
}
export interface MorphFabItemIconProps {
children: React.ReactNode;
className?: string;
}
export function MorphFabItemIcon({ children, className }: MorphFabItemIconProps) {
return <View className={cn("items-center justify-center", className)}>{children}</View>;
}
export interface MorphFabItemLabelProps {
children: React.ReactNode;
className?: string;
}
export function MorphFabItemLabel({ children, className }: MorphFabItemLabelProps) {
return (
<Text className={cn("text-sm font-semibold text-white", className)} numberOfLines={1}>
{children}
</Text>
);
}

