Liquid-glass dock whose selection blob stretches and reshapes as it glides between items — the iOS 26 tab-blob morph
components/ui/glass-dock-morph/glass-dock-morph.tsx@lumioui/core@latestreact-native-reanimated@*| items | GlassDockMorphItem[] | |
| value? | number | |
| defaultValue? | number | |
| onChange? | (index: number) => void | |
| className? | string |
Liquid-glass dock whose selection blob stretches and reshapes as it glides between items — the iOS 26 tab-blob morph
import { GlassDockMorph } from "@/components/ui/glass-dock-morph/glass-dock-morph";
export function Example() {
return (
<GlassDockMorph />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, type LayoutChangeEvent, type ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { cn, spring, useHaptic, useReducedMotion } from "@lumioui/core";
import { Glass } from "../glass";
import { Icon, type IconName } from "../icon";
import { Text } from "../text";
import { AnimatedPressable } from "@lumioui/core";
/**
* GlassDockMorph — a liquid-glass dock whose selection blob stretches and
* reshapes as it glides between items, like the iOS 26 tab blob. The blob
* is a second Glass chamber inside the dock's slab so the two melt
* together wherever they overlap.
*
* <GlassDockMorph items={[{icon:"home"},{icon:"search"}]} value={i} onChange={setI} />
* Expo Go compatible.
*/
export interface GlassDockMorphItem {
icon: IconName;
label?: string;
accessibilityLabel?: string;
}
export interface GlassDockMorphProps extends Omit<ViewProps, "className"> {
items: GlassDockMorphItem[];
value?: number;
defaultValue?: number;
onChange?: (index: number) => void;
className?: string;
}
export const GlassDockMorph = forwardRef<
React.ElementRef<typeof View>,
GlassDockMorphProps
>(function GlassDockMorph(
{ items, value, defaultValue = 0, onChange, className, ...props },
ref
) {
const [internal, setInternal] = React.useState(defaultValue);
const selected = value ?? internal;
const haptics = useHaptic();
const reduce = useReducedMotion();
// Per-item centers/widths measured on layout; the blob interpolates
// between them instead of snapping — that stretch is the "morph".
const layouts = React.useRef<{ x: number; w: number }[]>([]);
const blobX = useSharedValue(0);
const blobW = useSharedValue(56);
React.useEffect(() => {
const l = layouts.current[selected];
if (!l) return;
const x = l.x;
const w = l.w;
if (reduce) {
blobX.value = x;
blobW.value = w;
} else {
blobX.value = withSpring(x, spring.indicator);
blobW.value = withSpring(w, spring.indicator);
}
}, [selected, reduce, blobX, blobW]);
const blobStyle = useAnimatedStyle(() => ({
transform: [{ translateX: blobX.value }],
width: blobW.value,
}));
const onItemLayout = (i: number) => (e: LayoutChangeEvent) => {
layouts.current[i] = { x: e.nativeEvent.layout.x, w: e.nativeEvent.layout.width };
if (i === selected) {
const l = layouts.current[i];
blobX.value = l.x;
blobW.value = l.w;
}
};
return (
<Glass
ref={ref}
effect="regular"
className={cn(
"flex-row items-center self-center rounded-full p-1.5",
className
)}
{...props}
>
<Animated.View
pointerEvents="none"
style={blobStyle}
className="absolute bottom-1.5 left-0 top-1.5 overflow-hidden rounded-full"
>
<Glass effect="clear" sheen={0.7} className="h-full w-full rounded-full" />
<View className="absolute inset-0 rounded-full bg-primary/10" />
</Animated.View>
{items.map((item, i) => (
<AnimatedPressable
key={item.label ?? item.icon}
onLayout={onItemLayout(i)}
onPress={() => {
haptics.selection();
if (value === undefined) setInternal(i);
onChange?.(i);
}}
accessibilityRole="button"
accessibilityState={{ selected: i === selected }}
accessibilityLabel={item.accessibilityLabel ?? item.label}
className="h-14 min-w-14 items-center justify-center gap-0.5 px-4"
>
<Icon
name={item.icon}
size="md"
color={i === selected ? "primary" : "mutedForeground"}
/>
{item.label ? (
<Text
variant="muted"
className={cn(
"text-[10px]",
i === selected && "font-semibold text-primary"
)}
>
{item.label}
</Text>
) : null}
</AnimatedPressable>
))}
</Glass>
);
});
GlassDockMorph.displayName = "GlassDockMorph";