Liquid-glass button cluster — adjacent chambers melt into one slab (UIKit union regions on iOS 26, merged refractive slab on web) with swelling selection
components/ui/glass-cluster/glass-cluster.tsx@lumioui/core@latestreact-native-reanimated@*| items | GlassClusterItem[] | |
| value? | number | Selected index — uncontrolled when omitted. |
| defaultValue? | number | |
| onChange? | (index: number) => void | |
| spacing? | number | Merge distance between chambers — passes to GlassContainer spacing. |
| className? | string |
Liquid-glass button cluster — adjacent chambers melt into one slab (UIKit union regions on iOS 26, merged refractive slab on web) with swelling selection
import { GlassCluster } from "@/components/ui/glass-cluster/glass-cluster";
export function Example() {
return (
<GlassCluster />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, type ViewProps } from "react-native";
import { 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";
/**
* GlassCluster — a row of glass pills that share one liquid slab: items
* close enough to each other melt into a single refractive blob (UIKit
* union regions on iOS 26, one merged slab on web). Selecting an item
* makes its chamber swell inside the slab.
*
* <GlassCluster items={[{icon:"play"},{icon:"pause"}]} value={i} onChange={setI} />
* Expo Go compatible.
*/
export interface GlassClusterItem {
icon?: IconName;
label?: string;
accessibilityLabel?: string;
}
export interface GlassClusterProps extends Omit<ViewProps, "className"> {
items: GlassClusterItem[];
/** Selected index — uncontrolled when omitted. */
value?: number;
defaultValue?: number;
onChange?: (index: number) => void;
/** Merge distance between chambers — passes to GlassContainer spacing. */
spacing?: number;
className?: string;
}
const ClusterItem = React.memo(function ClusterItem({
item,
selected,
onPress,
}: {
item: GlassClusterItem;
selected: boolean;
onPress: () => void;
}) {
const reduce = useReducedMotion();
const scale = useSharedValue(1);
React.useEffect(() => {
scale.value = reduce
? selected
? 1.08
: 1
: withSpring(selected ? 1.08 : 1, spring.pop);
}, [selected, reduce, scale]);
const grow = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Glass
interactive
className={cn(
"items-center justify-center rounded-full",
item.label ? "h-11 px-4" : "h-11 w-11",
selected && "bg-primary/15"
)}
>
<AnimatedPressable
onPress={onPress}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityLabel={item.accessibilityLabel ?? item.label}
className="h-full w-full flex-row items-center justify-center gap-1.5 px-2"
style={grow}
>
{item.icon ? (
<Icon
name={item.icon}
size="sm"
color={selected ? "primary" : "foreground"}
/>
) : null}
{item.label ? (
<Text
variant="small"
className={cn("font-medium", selected && "text-primary")}
>
{item.label}
</Text>
) : null}
</AnimatedPressable>
</Glass>
);
});
export const GlassCluster = forwardRef<
React.ElementRef<typeof View>,
GlassClusterProps
>(function GlassCluster(
{ items, value, defaultValue = 0, onChange, spacing = 10, className, ...props },
ref
) {
const [internal, setInternal] = React.useState(defaultValue);
const selected = value ?? internal;
const haptics = useHaptic();
return (
<GlassContainer
ref={ref}
spacing={spacing}
effect="regular"
className={cn(
"flex-row items-center gap-2 self-start rounded-full p-1.5",
className
)}
{...props}
>
{items.map((item, i) => (
<ClusterItem
key={item.label ?? item.icon ?? i}
item={item}
selected={i === selected}
onPress={() => {
haptics.selection();
if (value === undefined) setInternal(i);
onChange?.(i);
}}
/>
))}
</GlassContainer>
);
});
GlassCluster.displayName = "GlassCluster";