Unfold Menu
Menu whose trigger unfolds into a full panel, its label morphing into the panel's title as it expands.
Web preview — components render natively on iOS & Android
import {
UnfoldMenu,
UnfoldMenuTrigger,
UnfoldMenuIcon,
UnfoldMenuLabel,
UnfoldMenuContent,
UnfoldMenuHeader,
UnfoldMenuTitle,
UnfoldMenuClose,
UnfoldMenuGrid,
UnfoldMenuItem,
} from "@/components/ui/unfold-menu";
import { Share2, Star } from "lucide-react-native";
<UnfoldMenu onSelect={(value) => console.log(value)}>
<UnfoldMenuTrigger>
<UnfoldMenuIcon><Share2 size={20} color="#111111" /></UnfoldMenuIcon>
<UnfoldMenuLabel>Share</UnfoldMenuLabel>
</UnfoldMenuTrigger>
<UnfoldMenuContent>
<UnfoldMenuHeader>
<UnfoldMenuTitle>Share</UnfoldMenuTitle>
<UnfoldMenuClose />
</UnfoldMenuHeader>
<UnfoldMenuGrid columns={3}>
<UnfoldMenuItem value="favorite">
<Star size={20} color="#6d7480" />
</UnfoldMenuItem>
</UnfoldMenuGrid>
</UnfoldMenuContent>
</UnfoldMenu>Installation#
npx @aniui/cli add unfold-menuUsage#
import {
UnfoldMenu,
UnfoldMenuTrigger,
UnfoldMenuIcon,
UnfoldMenuLabel,
UnfoldMenuContent,
UnfoldMenuHeader,
UnfoldMenuTitle,
UnfoldMenuClose,
UnfoldMenuGrid,
UnfoldMenuItem,
} from "@/components/ui/unfold-menu";
import { Share2, Star } from "lucide-react-native";
<UnfoldMenu onSelect={(value) => console.log(value)}>
<UnfoldMenuTrigger>
<UnfoldMenuIcon><Share2 size={20} color="#111111" /></UnfoldMenuIcon>
<UnfoldMenuLabel>Share</UnfoldMenuLabel>
</UnfoldMenuTrigger>
<UnfoldMenuContent>
<UnfoldMenuHeader>
<UnfoldMenuTitle>Share</UnfoldMenuTitle>
<UnfoldMenuClose />
</UnfoldMenuHeader>
<UnfoldMenuGrid columns={3}>
<UnfoldMenuItem value="favorite">
<Star size={20} color="#6d7480" />
</UnfoldMenuItem>
</UnfoldMenuGrid>
</UnfoldMenuContent>
</UnfoldMenu>Source#
components/ui/unfold-menu.tsx
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Modal,
Pressable,
useColorScheme,
useWindowDimensions,
View,
type LayoutChangeEvent,
type TextStyle,
type ViewStyle,
} from "react-native";
import Animated, {
Easing,
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withDelay,
withSpring,
withTiming,
type AnimatedStyle,
type SharedValue,
type WithSpringConfig,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "lucide-react-native";
import { cn } from "@/lib/utils";
interface Point {
x: number;
y: number;
}
interface AnchorRect {
x: number;
y: number;
w: number;
h: number;
}
interface PanelPosition {
left: number;
top: number;
}
/** Keeps the panel fully on-screen, centered on the anchor where possible. */
function clampToScreen(
anchor: AnchorRect,
panelWidth: number,
panelHeight: number,
screenWidth: number,
screenHeight: number,
margin: number,
insetTop: number,
insetBottom: number
): PanelPosition {
const idealLeft = anchor.x + anchor.w / 2 - panelWidth / 2;
const idealTop = anchor.y + anchor.h / 2 - panelHeight / 2;
const minLeft = margin;
const maxLeft = screenWidth - panelWidth - margin;
const minTop = insetTop + margin;
const maxTop = screenHeight - insetBottom - panelHeight - margin;
return {
left: Math.min(Math.max(idealLeft, minLeft), Math.max(maxLeft, minLeft)),
top: Math.min(Math.max(idealTop, minTop), Math.max(maxTop, minTop)),
};
}
export type UnfoldMenuTheme = "light" | "dark";
export interface UnfoldMenuPalette {
surface: string;
border: string;
text: string;
mutedText: string;
}
const LIGHT_PALETTE: UnfoldMenuPalette = { surface: "#ffffff", border: "#e3e7ec", text: "#111111", mutedText: "#6d7480" };
const DARK_PALETTE: UnfoldMenuPalette = { surface: "#171716", border: "#2b2a25", text: "#f6f3ec", mutedText: "#9a958a" };
const PALETTES: Record<UnfoldMenuTheme, UnfoldMenuPalette> = { light: LIGHT_PALETTE, dark: DARK_PALETTE };
const MORPH_SPRING: WithSpringConfig = { mass: 0.5, damping: 14, stiffness: 145 };
const MORPH_CLOSE_SPRING: WithSpringConfig = { mass: 0.5, damping: 14, stiffness: 146 };
const HANDOFF_DELAY = 460;
const HANDOFF_DURATION = 300;
const HANDOFF_CLOSE_DURATION = 340;
const HANDOFF_EASING = Easing.inOut(Easing.quad);
const REVEAL_DELAY = 260;
const REVEAL_DURATION = 220;
const REVEAL_EASING = Easing.out(Easing.cubic);
const CLOSE_DURATION = 280;
const CLOSE_EASING = Easing.inOut(Easing.quad);
const PRESS_TIMING = { duration: 120, easing: Easing.out(Easing.quad) };
const PRESS_SCALE = 0.97;
const STAGGER_SPAN = 0.45;
const ITEM_WINDOW = 0.55;
const ITEM_SCALE_FROM = 0.85;
const TRIGGER_ICON_FADE_END = 0.2;
const CONTENT_FADE_START = 0.3;
const CONTENT_FADE_END = 0.75;
const MORPH_LABEL_FADE_END = 0.6;
const MORPH_LABEL_SCALE_TO = 0.88;
const MORPH_TITLE_FADE_START = 0.4;
const MORPH_TITLE_SCALE_FROM = 0.88;
const MORPH_PUSH_DISTANCE = 14;
const DEFAULT_PANEL_WIDTH_RATIO = 0.86;
const MAX_PANEL_WIDTH = 420;
const DEFAULT_SCREEN_MARGIN = 12;
const DEFAULT_COLUMNS = 3;
const DEFAULT_RADIUS = 16;
const DEFAULT_ICON_SIZE = 20;
// ---------------------------------------------------------------------------
// Contexts
// ---------------------------------------------------------------------------
// The same children render 3 times, once per slot, so the trigger's icon and
// label can exist simultaneously as: the real anchor button (measured, then
// hidden once handed off), the read-only copy inside the growing panel, and
// a free-floating copy that travels from the anchor's position to the
// panel's title position — the shared-element "unfold" illusion.
type UnfoldMenuSlot = "anchor" | "overlay" | "float";
const UnfoldMenuSlotContext = createContext<UnfoldMenuSlot>("anchor");
function useUnfoldMenuSlot() {
return useContext(UnfoldMenuSlotContext);
}
interface UnfoldMenuMorph {
iconStyle?: AnimatedStyle<ViewStyle>;
labelStyle?: AnimatedStyle<TextStyle>;
onLabelLayout?: (event: LayoutChangeEvent) => void;
}
const UnfoldMenuMorphContext = createContext<UnfoldMenuMorph | null>(null);
function useUnfoldMenuMorph() {
return useContext(UnfoldMenuMorphContext);
}
const UnfoldMenuTintContext = createContext<string | null>(null);
function useUnfoldMenuTint(fallback: string): string {
return useContext(UnfoldMenuTintContext) ?? fallback;
}
const UnfoldMenuOffsetContext = createContext<Point>({ x: 0, y: 0 });
function useUnfoldMenuOffset() {
return useContext(UnfoldMenuOffsetContext);
}
interface UnfoldMenuGridInfo {
index: number;
count: number;
columns: number;
}
const UnfoldMenuGridContext = createContext<UnfoldMenuGridInfo>({ index: 0, count: 1, columns: 1 });
function useUnfoldMenuGrid() {
return useContext(UnfoldMenuGridContext);
}
interface UnfoldMenuContextValue {
open: boolean;
overlayVisible: boolean;
requestOpen: () => void;
setOpen: (open: boolean) => void;
select: (value?: string) => void;
progress: SharedValue<number>;
reveal: SharedValue<number>;
handoff: SharedValue<number>;
panelWidth: number;
registerPanel: (height: number) => void;
anchorRect: AnchorRect;
panelOrigin: Point;
labelOrigin: SharedValue<Point>;
titleOrigin: SharedValue<Point>;
registerLabelOrigin: (point: Point) => void;
registerTitleOrigin: (point: Point) => void;
palette: UnfoldMenuPalette;
iconSize: number;
reduceMotion: boolean;
}
const UnfoldMenuContext = createContext<UnfoldMenuContextValue | null>(null);
function useUnfoldMenu(component: string) {
const ctx = useContext(UnfoldMenuContext);
if (!ctx) throw new Error(`${component} must be rendered inside <UnfoldMenu>.`);
return ctx;
}
// ---------------------------------------------------------------------------
// Root
// ---------------------------------------------------------------------------
export interface UnfoldMenuProps {
children: React.ReactNode;
className?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onSelect?: (value?: string) => void;
closeOnSelect?: boolean;
panelWidth?: number;
screenMargin?: number;
radius?: number;
iconSize?: number;
theme?: UnfoldMenuTheme;
palette?: Partial<UnfoldMenuPalette>;
}
/**
* A compound menu whose trigger "unfolds" into a full panel: the trigger's
* measured rect morphs (position, width, height, all spring-driven) into the
* target panel rect, while the trigger's icon fades out and its label
* cross-fades into the panel's title at the title's own position — so the
* label reads as travelling and settling into place rather than two
* unrelated pieces of text swapping. `measureInWindow` (not `measure`) is
* used for the anchor so the morph starts from the right spot regardless of
* status bar / notch offsets once the panel opens inside a `Modal`.
*/
export function UnfoldMenu({
children,
className,
open: openProp,
defaultOpen = false,
onOpenChange,
onSelect,
closeOnSelect = true,
panelWidth: panelWidthProp,
screenMargin = DEFAULT_SCREEN_MARGIN,
radius = DEFAULT_RADIUS,
iconSize = DEFAULT_ICON_SIZE,
theme: themeProp,
palette: paletteProp,
}: UnfoldMenuProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = isControlled ? (openProp as boolean) : internalOpen;
const reduceMotion = useReducedMotion();
const insets = useSafeAreaInsets();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const systemDark = useColorScheme() === "dark";
const theme = themeProp ?? (systemDark ? "dark" : "light");
const progress = useSharedValue(open ? 1 : 0);
const reveal = useSharedValue(open ? 1 : 0);
const handoff = useSharedValue(open ? 1 : 0);
const labelOrigin = useSharedValue<Point>({ x: 0, y: 0 });
const titleOrigin = useSharedValue<Point>({ x: 0, y: 0 });
const anchorRef = useRef<View>(null);
const [anchorRect, setAnchorRect] = useState<AnchorRect>({ x: 0, y: 0, w: 0, h: 0 });
const [panelHeight, setPanelHeight] = useState(0);
const [mounted, setMounted] = useState(open);
const [presented, setPresented] = useState(open);
const panelWidth = Math.min(
panelWidthProp ?? screenWidth * DEFAULT_PANEL_WIDTH_RATIO,
screenWidth - screenMargin * 2,
panelWidthProp ?? MAX_PANEL_WIDTH
);
const setOpen = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
const requestOpen = useCallback(() => {
const node = anchorRef.current;
if (!node) {
setOpen(true);
return;
}
node.measureInWindow((x, y, w, h) => {
setAnchorRect({ x, y, w, h });
setOpen(true);
});
}, [setOpen]);
const select = useCallback(
(value?: string) => {
onSelect?.(value);
if (closeOnSelect) setOpen(false);
},
[onSelect, closeOnSelect, setOpen]
);
const registerPanel = useCallback((h: number) => {
setPanelHeight((prev) => (Math.abs(prev - h) < 0.5 ? prev : h));
}, []);
const registerLabelOrigin = useCallback((point: Point) => { labelOrigin.value = point; }, [labelOrigin]);
const registerTitleOrigin = useCallback((point: Point) => { titleOrigin.value = point; }, [titleOrigin]);
useEffect(() => {
if (!open) return;
setMounted(true);
// Two rAFs: the first lets the Modal mount and the anchor's real layout
// settle, the second flips `presented` so the very first animated frame
// already has a measured panel height to morph towards.
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => setPresented(true));
});
return () => {
cancelAnimationFrame(outer);
cancelAnimationFrame(inner);
};
}, [open]);
useEffect(() => {
if (reduceMotion) {
progress.value = open ? 1 : 0;
reveal.value = open ? 1 : 0;
handoff.value = open ? 1 : 0;
if (!open) setMounted(false);
return;
}
if (open) {
if (!presented) return;
progress.value = withSpring(1, MORPH_SPRING);
reveal.value = withDelay(REVEAL_DELAY, withTiming(1, { duration: REVEAL_DURATION, easing: REVEAL_EASING }));
handoff.value = withDelay(HANDOFF_DELAY, withTiming(1, { duration: HANDOFF_DURATION, easing: HANDOFF_EASING }));
return;
}
reveal.value = withTiming(0, { duration: CLOSE_DURATION, easing: CLOSE_EASING });
handoff.value = withTiming(0, { duration: HANDOFF_CLOSE_DURATION, easing: HANDOFF_EASING });
progress.value = withSpring(0, MORPH_CLOSE_SPRING, (finished) => {
"worklet";
if (finished) {
runOnJS(setMounted)(false);
runOnJS(setPresented)(false);
}
});
}, [open, presented, reduceMotion, progress, reveal, handoff]);
const palette = useMemo<UnfoldMenuPalette>(() => ({ ...PALETTES[theme], ...paletteProp }), [theme, paletteProp]);
const panelHeightResolved = panelHeight || anchorRect.h;
const target = clampToScreen(anchorRect, panelWidth, panelHeightResolved, screenWidth, screenHeight, screenMargin, insets.top, insets.bottom);
const panelOrigin = useMemo(() => ({ x: target.left, y: target.top }), [target.left, target.top]);
const ctx = useMemo<UnfoldMenuContextValue>(
() => ({
open, overlayVisible: mounted, requestOpen, setOpen, select, progress, reveal, handoff,
panelWidth, registerPanel, anchorRect, panelOrigin, labelOrigin, titleOrigin,
registerLabelOrigin, registerTitleOrigin, palette, iconSize, reduceMotion,
}),
[open, mounted, requestOpen, setOpen, select, progress, reveal, handoff, panelWidth, registerPanel,
anchorRect, panelOrigin, labelOrigin, titleOrigin, registerLabelOrigin, registerTitleOrigin, palette, iconSize, reduceMotion]
);
// CLAMPed rather than left to extend: MORPH_SPRING is underdamped by design
// (it's meant to feel springy), so progress overshoots past 1 before
// settling. `target`/`panelWidth`/`panelHeightResolved` are already the
// screen-safe end state — extending past them during that overshoot is
// exactly what pushed the panel out of bounds on expand. Clamping closes
// that gap without changing the feel at rest.
const morphStyle = useAnimatedStyle(() => {
const p = progress.value;
return {
transform: [
{ translateX: interpolate(p, [0, 1], [anchorRect.x, target.left], Extrapolation.CLAMP) },
{ translateY: interpolate(p, [0, 1], [anchorRect.y, target.top], Extrapolation.CLAMP) },
],
width: interpolate(p, [0, 1], [anchorRect.w, panelWidth], Extrapolation.CLAMP),
height: interpolate(p, [0, 1], [anchorRect.h, panelHeightResolved], Extrapolation.CLAMP),
};
});
return (
<UnfoldMenuContext.Provider value={ctx}>
<View className={className}>
<View ref={anchorRef} collapsable={false} className="self-start">
<UnfoldMenuSlotContext.Provider value="anchor">{children}</UnfoldMenuSlotContext.Provider>
</View>
</View>
{mounted ? (
<Modal
transparent
statusBarTranslucent
visible
animationType="none"
onRequestClose={() => setOpen(false)}
onShow={() => setPresented(true)}
>
<Pressable className="absolute inset-0" onPress={() => setOpen(false)} />
<Animated.View
pointerEvents="box-none"
className="absolute left-0 top-0 overflow-hidden border"
style={[{ backgroundColor: palette.surface, borderColor: palette.border, borderRadius: radius }, morphStyle]}
>
<UnfoldMenuSlotContext.Provider value="overlay">{children}</UnfoldMenuSlotContext.Provider>
</Animated.View>
{/*
The travelling trigger copy lives outside the panel: the panel
resizes every frame, and anything inside it gets re-laid-out
(text re-measured) along with it. Out here its layout is
computed once and only its transform moves each frame.
*/}
<UnfoldMenuSlotContext.Provider value="float">{children}</UnfoldMenuSlotContext.Provider>
</Modal>
) : null}
</UnfoldMenuContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Trigger
// ---------------------------------------------------------------------------
const triggerBase = "min-h-12 min-w-12 flex-row items-center justify-center gap-2 rounded-full border px-5";
export interface UnfoldMenuTriggerProps {
children: React.ReactNode;
className?: string;
}
export function UnfoldMenuTrigger({ children, className }: UnfoldMenuTriggerProps) {
const {
open, overlayVisible, requestOpen, progress, handoff, palette, reduceMotion,
anchorRect, panelOrigin, labelOrigin, titleOrigin, registerLabelOrigin,
} = useUnfoldMenu("UnfoldMenuTrigger");
const slot = useUnfoldMenuSlot();
const pressed = useSharedValue(0);
const travelStyle = useAnimatedStyle(() => {
const p = progress.value;
const toX = panelOrigin.x + titleOrigin.value.x - labelOrigin.value.x;
const toY = panelOrigin.y + titleOrigin.value.y - labelOrigin.value.y;
return {
transform: [
{ translateX: interpolate(p, [0, 1], [anchorRect.x, toX], Extrapolation.CLAMP) },
{ translateY: interpolate(p, [0, 1], [anchorRect.y, toY], Extrapolation.CLAMP) },
],
};
});
// The icon fades out almost immediately (by TRIGGER_ICON_FADE_END) — it was
// never meant to travel, only the label is. But it's a child of the same
// traveling container, so without a counter-transform it inherits the full
// journey toward the panel's title, which sits with far less padding before
// it than the icon+gap the trigger pill has before the label — dragging the
// icon well outside the panel (visible as a stray icon detached to one
// side, if the fade hasn't fully hidden it yet). Canceling the parent's
// translateX/Y here keeps the icon visually anchored in place while it
// fades, regardless of how far the label travels.
const morphIconStyle = useAnimatedStyle(() => {
const p = progress.value;
const toX = panelOrigin.x + titleOrigin.value.x - labelOrigin.value.x;
const toY = panelOrigin.y + titleOrigin.value.y - labelOrigin.value.y;
const travelX = interpolate(p, [0, 1], [anchorRect.x, toX], Extrapolation.CLAMP);
const travelY = interpolate(p, [0, 1], [anchorRect.y, toY], Extrapolation.CLAMP);
return {
opacity: interpolate(p, [0, TRIGGER_ICON_FADE_END], [1, 0], Extrapolation.CLAMP),
transform: [{ translateX: anchorRect.x - travelX }, { translateY: anchorRect.y - travelY }],
};
});
const morphLabelStyle = useAnimatedStyle(() => {
const h = handoff.value;
return {
opacity: interpolate(h, [0, MORPH_LABEL_FADE_END], [1, 0], Extrapolation.CLAMP),
transformOrigin: "left center",
transform: [
{ translateX: interpolate(h, [0, MORPH_LABEL_FADE_END], [0, -MORPH_PUSH_DISTANCE], Extrapolation.CLAMP) },
{ scale: interpolate(h, [0, MORPH_LABEL_FADE_END], [1, MORPH_LABEL_SCALE_TO], Extrapolation.CLAMP) },
],
};
});
const morph = useMemo<UnfoldMenuMorph>(() => ({ iconStyle: morphIconStyle, labelStyle: morphLabelStyle }), [morphIconStyle, morphLabelStyle]);
const onLabelLayout = useCallback(
(event: LayoutChangeEvent) => registerLabelOrigin({ x: event.nativeEvent.layout.x, y: event.nativeEvent.layout.y }),
[registerLabelOrigin]
);
const measurer = useMemo<UnfoldMenuMorph>(() => ({ onLabelLayout }), [onLabelLayout]);
const pressStyle = useAnimatedStyle(() => ({
transform: [{ scale: 1 - (1 - PRESS_SCALE) * (reduceMotion ? 0 : pressed.value) }],
}));
if (slot === "overlay") return null;
if (slot === "float") {
return (
<Animated.View
pointerEvents="none"
className={cn("absolute left-0 top-0 border-transparent bg-transparent", triggerBase, className)}
style={travelStyle}
>
<UnfoldMenuMorphContext.Provider value={morph}>
<UnfoldMenuTintContext.Provider value={palette.text}>{children}</UnfoldMenuTintContext.Provider>
</UnfoldMenuMorphContext.Provider>
</Animated.View>
);
}
return (
<Animated.View style={pressStyle}>
<Pressable
accessible={true}
accessibilityRole="button"
accessibilityState={{ expanded: open }}
onPress={requestOpen}
onPressIn={() => { pressed.value = withTiming(1, PRESS_TIMING); }}
onPressOut={() => { pressed.value = withTiming(0, PRESS_TIMING); }}
className={cn(triggerBase, className)}
style={[{ backgroundColor: palette.surface, borderColor: palette.border }, overlayVisible ? { opacity: 0 } : null]}
>
<UnfoldMenuMorphContext.Provider value={measurer}>
<UnfoldMenuTintContext.Provider value={palette.text}>{children}</UnfoldMenuTintContext.Provider>
</UnfoldMenuMorphContext.Provider>
</Pressable>
</Animated.View>
);
}
// ---------------------------------------------------------------------------
// Content / chrome
// ---------------------------------------------------------------------------
export interface UnfoldMenuContentProps {
children: React.ReactNode;
className?: string;
}
export function UnfoldMenuContent({ children, className }: UnfoldMenuContentProps) {
const { open, progress, panelWidth, registerPanel } = useUnfoldMenu("UnfoldMenuContent");
const slot = useUnfoldMenuSlot();
const onLayout = useCallback((event: LayoutChangeEvent) => registerPanel(event.nativeEvent.layout.height), [registerPanel]);
const layerStyle = useAnimatedStyle(() => ({
opacity: interpolate(progress.value, [CONTENT_FADE_START, CONTENT_FADE_END], [0, 1], Extrapolation.CLAMP),
}));
if (slot === "anchor") {
// Invisible, laid out once to measure the panel's natural height.
return (
<View pointerEvents="none" onLayout={onLayout} className="absolute left-0 top-0 opacity-0" style={{ width: panelWidth }}>
{children}
</View>
);
}
if (slot === "float") return null;
return (
<Animated.View
pointerEvents={open ? "auto" : "none"}
className={cn("absolute left-0 top-0", className)}
style={[{ width: panelWidth }, layerStyle]}
>
{children}
</Animated.View>
);
}
export interface UnfoldMenuHeaderProps {
children: React.ReactNode;
className?: string;
}
export function UnfoldMenuHeader({ children, className }: UnfoldMenuHeaderProps) {
const { palette } = useUnfoldMenu("UnfoldMenuHeader");
const slot = useUnfoldMenuSlot();
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 });
const onLayout = useCallback((event: LayoutChangeEvent) => {
const { x, y } = event.nativeEvent.layout;
setOffset((prev) => (prev.x === x && prev.y === y ? prev : { x, y }));
}, []);
return (
<View
onLayout={slot === "anchor" ? onLayout : undefined}
className={cn("flex-row items-center justify-between border-b px-4 py-3", className)}
style={{ borderBottomColor: palette.border }}
>
<UnfoldMenuOffsetContext.Provider value={offset}>{children}</UnfoldMenuOffsetContext.Provider>
</View>
);
}
export interface UnfoldMenuTitleProps {
children: React.ReactNode;
className?: string;
}
export function UnfoldMenuTitle({ children, className }: UnfoldMenuTitleProps) {
const { palette, handoff, registerTitleOrigin } = useUnfoldMenu("UnfoldMenuTitle");
const offset = useUnfoldMenuOffset();
const slot = useUnfoldMenuSlot();
const [local, setLocal] = useState<Point | null>(null);
const onLayout = useCallback((event: LayoutChangeEvent) => {
const { x, y } = event.nativeEvent.layout;
setLocal((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y }));
}, []);
useEffect(() => {
if (!local) return;
registerTitleOrigin({ x: offset.x + local.x, y: offset.y + local.y });
}, [local, offset.x, offset.y, registerTitleOrigin]);
const fadeStyle = useAnimatedStyle(() => {
const h = handoff.value;
return {
opacity: interpolate(h, [MORPH_TITLE_FADE_START, 1], [0, 1], Extrapolation.CLAMP),
transformOrigin: "left center",
transform: [
{ translateX: interpolate(h, [MORPH_TITLE_FADE_START, 1], [MORPH_PUSH_DISTANCE, 0], Extrapolation.CLAMP) },
{ scale: interpolate(h, [MORPH_TITLE_FADE_START, 1], [MORPH_TITLE_SCALE_FROM, 1], Extrapolation.CLAMP) },
],
};
});
return (
<Animated.Text
onLayout={slot === "anchor" ? onLayout : undefined}
className={cn("text-sm font-medium", className)}
style={[{ color: palette.mutedText }, slot === "overlay" ? fadeStyle : null]}
>
{children}
</Animated.Text>
);
}
export interface UnfoldMenuCloseProps {
children?: React.ReactNode | ((state: { color: string; size: number }) => React.ReactNode);
className?: string;
}
export function UnfoldMenuClose({ children, className }: UnfoldMenuCloseProps) {
const { setOpen, palette, iconSize } = useUnfoldMenu("UnfoldMenuClose");
const state = { color: palette.mutedText, size: iconSize };
const content = typeof children === "function" ? children(state) : (children ?? null);
return (
<Pressable
accessible={true}
accessibilityRole="button"
accessibilityLabel="Close menu"
hitSlop={10}
onPress={() => setOpen(false)}
className={cn("min-h-12 min-w-12 items-center justify-center", className)}
>
<UnfoldMenuTintContext.Provider value={palette.mutedText}>
{content ?? <X size={state.size} color={state.color} strokeWidth={2.5} />}
</UnfoldMenuTintContext.Provider>
</Pressable>
);
}
// ---------------------------------------------------------------------------
// Grid / items
// ---------------------------------------------------------------------------
export interface UnfoldMenuGridProps {
children: React.ReactNode;
columns?: number;
className?: string;
}
export function UnfoldMenuGrid({ children, columns = DEFAULT_COLUMNS, className }: UnfoldMenuGridProps) {
const items = React.Children.toArray(children);
return (
<View className={cn("flex-row flex-wrap", className)}>
{items.map((child, index) => (
<UnfoldMenuGridContext.Provider key={index} value={{ index, count: items.length, columns }}>
{child}
</UnfoldMenuGridContext.Provider>
))}
</View>
);
}
export interface UnfoldMenuItemProps {
children: React.ReactNode;
value?: string;
onPress?: () => void;
disabled?: boolean;
className?: string;
}
export function UnfoldMenuItem({ children, value, onPress, disabled = false, className }: UnfoldMenuItemProps) {
const { select, reveal, palette, reduceMotion } = useUnfoldMenu("UnfoldMenuItem");
const { index, count, columns } = useUnfoldMenuGrid();
const rows = Math.ceil(count / columns);
const col = index % columns;
const row = Math.floor(index / columns);
const maxDistance = Math.hypot((columns - 1) / 2, (rows - 1) / 2) || 1;
const distance = Math.hypot(col - (columns - 1) / 2, row - (rows - 1) / 2);
const start = (distance / maxDistance) * STAGGER_SPAN;
// Items reveal outward from the grid's center, nearest first, each one
// fading and scaling in over its own [start, start + window] slice of the
// shared `reveal` timeline — one shared value drives every item's stagger.
const itemStyle = useAnimatedStyle(() => {
if (reduceMotion) return { opacity: reveal.value, transform: [] };
const t = interpolate(reveal.value, [start, start + ITEM_WINDOW], [0, 1], Extrapolation.CLAMP);
return { opacity: t, transform: [{ scale: ITEM_SCALE_FROM + (1 - ITEM_SCALE_FROM) * t }] };
});
const handlePress = useCallback(() => {
onPress?.();
select(value);
}, [onPress, select, value]);
return (
<Pressable
accessible={true}
accessibilityRole="button"
disabled={disabled}
onPress={handlePress}
className={cn("min-h-12 items-center justify-center px-3 py-[22px]", disabled && "opacity-40", className)}
style={{
width: `${100 / columns}%`,
borderRightWidth: col === columns - 1 ? 0 : 0.5,
borderBottomWidth: row === rows - 1 ? 0 : 0.5,
borderColor: palette.border,
}}
>
<Animated.View className="items-center justify-center gap-2" style={itemStyle}>
<UnfoldMenuTintContext.Provider value={palette.mutedText}>{children}</UnfoldMenuTintContext.Provider>
</Animated.View>
</Pressable>
);
}
export interface UnfoldMenuIconProps {
children: React.ReactNode | ((state: { color: string; size: number }) => React.ReactNode);
className?: string;
}
export function UnfoldMenuIcon({ children, className }: UnfoldMenuIconProps) {
const { palette, iconSize } = useUnfoldMenu("UnfoldMenuIcon");
const color = useUnfoldMenuTint(palette.text);
const morph = useUnfoldMenuMorph();
const content = typeof children === "function" ? children({ color, size: iconSize }) : children;
return (
<Animated.View className={cn("items-center justify-center", className)} style={[{ width: iconSize, height: iconSize }, morph?.iconStyle]}>
{content}
</Animated.View>
);
}
export interface UnfoldMenuLabelProps {
children: React.ReactNode;
className?: string;
}
export function UnfoldMenuLabel({ children, className }: UnfoldMenuLabelProps) {
const { palette } = useUnfoldMenu("UnfoldMenuLabel");
const color = useUnfoldMenuTint(palette.text);
const morph = useUnfoldMenuMorph();
return (
<Animated.Text
numberOfLines={1}
onLayout={morph?.onLabelLayout}
className={cn("text-sm font-medium", className)}
style={[{ color }, morph?.labelStyle]}
>
{children}
</Animated.Text>
);
}

