Skip to content

Instantly share code, notes, and snippets.

@ferretwithaberet
Last active September 17, 2025 17:51
Show Gist options
  • Save ferretwithaberet/5df16e9e99e98a165496a34c247c243a to your computer and use it in GitHub Desktop.
Save ferretwithaberet/5df16e9e99e98a165496a34c247c243a to your computer and use it in GitHub Desktop.
react-native-ui-lib FAB and SpeedDial
import { RenderFunctionOrNode, renderFunctionOrNode } from "@/utils/render";
import React from "react";
import { View } from "react-native";
import { Button, ButtonProps } from "react-native-ui-lib";
import tw, { Style } from "twrnc";
export type FABButtonProps = Omit<ButtonProps, "label" | "round"> & {
ref?: React.Ref<View>;
};
export const FABButton = (props: FABButtonProps) => {
const { style, ...restProps } = props;
return <Button {...restProps} style={[tw`w-12 h-12`, style]} round />;
};
type Position = ["top" | "bottom", "left" | "right"];
type PositionKey = `${Position[0]}-${Position[1]}`;
export const getPositionFromKey = (position: PositionKey) =>
position.split("-") as Position;
const POSITION_STYLING: Record<PositionKey, Style> = {
"top-left": tw`top-3 left-3`,
"top-right": tw`top-3 right-3`,
"bottom-left": tw`bottom-3 left-3 `,
"bottom-right": tw`bottom-3 right-3`,
};
export type FABProps = FABButtonProps & {
position?: PositionKey;
button?: RenderFunctionOrNode<[dom: React.ReactNode]>;
};
export const FAB = (props: FABProps) => {
const { position = "bottom-right", button, ...restProps } = props;
const _button = <FABButton {...restProps} />;
return (
<View style={[tw`absolute`, POSITION_STYLING[position]]}>
{renderFunctionOrNode(button, _button) ?? _button}
</View>
);
};
import { FAB, FABButton } from "./FAB";
import SpeedDial from "./SpeedDial";
export { FABButtonProps, FABProps } from "./FAB";
export { SpeedDialProps } from "./SpeedDial";
export default Object.assign(FAB, {
Button: FABButton,
SpeedDial,
});
export type RenderFunction<TRenderArgs extends unknown[]> = (
...args: TRenderArgs
) => React.ReactNode;
export type RenderFunctionOrNode<TRenderArgs extends unknown[] = []> =
| React.ReactNode
| RenderFunction<TRenderArgs>;
export const renderFunctionOrNode = <TRenderArgs extends unknown[] = []>(
renderFunctionOrNode: RenderFunctionOrNode<TRenderArgs>,
...args: TRenderArgs
) => {
if (typeof renderFunctionOrNode === "function")
return renderFunctionOrNode(...args);
return renderFunctionOrNode;
};
// TODO: Fix iOS exiting animation not working at all
import { getTextStyle } from "@/utils/text";
import React, { Children, useLayoutEffect, useRef, useState } from "react";
import { Platform, View as RNView, useWindowDimensions } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Colors, Modal, Text, View } from "react-native-ui-lib";
import tw from "twrnc";
import {
FAB,
FABButton,
FABButtonProps,
FABProps,
getPositionFromKey,
} from "./FAB";
type Layout = {
x: number;
y: number;
width: number;
height: number;
};
export type SpeedDialItemProps = FABButtonProps & {
label?: string;
};
const SpeedDialItem = (props: SpeedDialItemProps) => {
const { label, labelProps, ...restProps } = props;
const { white = true, style, ...restLabelProps } = labelProps ?? {};
return (
<>
<FABButton {...restProps} />
{label ? (
<Text
{...restLabelProps}
style={[getTextStyle("text70R"), style]}
white={white}
>
{label}
</Text>
) : null}
</>
);
};
export type SpeedDialProps = FABProps & {
open?: boolean;
initialOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
const SpeedDial = (props: SpeedDialProps) => {
const { position = "bottom-right" } = props;
const {
open,
initialOpen = false,
onOpenChange,
children,
...restProps
} = props;
const [innerOpen, setInnerOpen] = useState(initialOpen);
const actualOpen = open !== undefined ? open : innerOpen;
const dimensions = useWindowDimensions();
const safeAreaInsets = useSafeAreaInsets();
const [layout, setLayout] = useState<Layout | null>(null);
const buttonRef = useRef<RNView>(null);
useLayoutEffect(() => {
measureLayout();
}, [position]);
const measureLayout = () => {
buttonRef.current?.measureInWindow((x, y, width, height) =>
setLayout({ x, y, width, height })
);
};
const setOpen = (open: boolean) => {
setInnerOpen(open);
onOpenChange?.(open);
};
const [vertical, horizontal] = getPositionFromKey(position);
const isTop = vertical === "top";
const isLeft = horizontal === "left";
const safeAreaVertical = Platform.select({
default: 0,
android: safeAreaInsets.top + safeAreaInsets.bottom,
});
const safeAreaHorizontal = Platform.select({
default: 0,
android: safeAreaInsets.left + safeAreaInsets.right,
});
return (
<>
<FAB {...restProps} ref={buttonRef} onPress={() => setOpen(true)} />
<Modal
transparent
visible={actualOpen}
onBackgroundPress={() => setOpen(false)}
>
<Animated.View
style={[
tw`flex-1 pointer-events-box-none`,
{ backgroundColor: Colors.rgba(Colors.black, 0.8) },
]}
entering={FadeIn.duration(400)}
exiting={FadeOut.duration(400)}
>
{layout ? (
<View
style={[
tw.style("absolute gap-2 my-2", { "flex-col-reverse": !isTop }),
isTop
? {
top: layout.y + layout.height,
}
: {
bottom: dimensions.height - layout.y - safeAreaVertical,
},
isLeft
? { left: layout.x }
: {
right:
dimensions.width -
layout.x -
layout.width -
safeAreaHorizontal,
},
]}
>
{Children.toArray(children).map((child, index, arr) => {
const element = child as React.ReactElement<SpeedDialItemProps>;
const { onPress } = element.props;
const fadeDurationMs = 200;
const staggerMs = 50;
return (
<Animated.View
key={index}
style={tw.style("flex-row items-center gap-2", {
"flex-row-reverse": !isLeft,
})}
entering={FadeIn.duration(fadeDurationMs).delay(
index * staggerMs
)}
exiting={FadeOut.duration(fadeDurationMs).delay(
(arr.length - index - 1) * staggerMs
)}
>
{React.cloneElement(element, {
onPress: (e) => {
setOpen(false);
onPress?.(e);
},
})}
</Animated.View>
);
})}
</View>
) : null}
</Animated.View>
</Modal>
</>
);
};
export default Object.assign(SpeedDial, { Item: SpeedDialItem });
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment