Draggable droplet of glass — 1:1 finger tracking, velocity squash-and-stretch, bouncy spring rebound
components/ui/glass-orb/glass-orb.tsx@lumioui/core@latestreact-native-reanimated@*| size? | number | Diameter in pt. Default 80. |
| snapBack? | boolean | Spring back to origin on release (default true). False = stays dropped. |
| bend? | number | Refraction strength 0–1 passed to Glass bend. |
| onDrop? | () => void | |
| className? | string | |
| children? | React.ReactNode |
Draggable droplet of glass — 1:1 finger tracking, velocity squash-and-stretch, bouncy spring rebound
import { GlassOrb } from "@/components/ui/glass-orb/glass-orb";
export function Example() {
return (
<GlassOrb />
);
} import * as React from "react";
import { forwardRef } from "react";
import { PanResponder, View, 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";
/**
* GlassOrb — a draggable droplet of glass. It tracks the finger 1:1 and
* squashes perpendicular to travel proportional to velocity, then
* rebounds home with a bouncy spring on release. The signature "liquid"
* piece of the set.
*
* <GlassOrb size={88} />
* Expo Go compatible (PanResponder — no gesture-handler needed).
*/
export interface GlassOrbProps extends Omit<ViewProps, "className"> {
/** Diameter in pt. Default 80. */
size?: number;
/** Spring back to origin on release (default true). False = stays dropped. */
snapBack?: boolean;
/** Refraction strength 0–1 passed to Glass bend. */
bend?: number;
onDrop?: () => void;
className?: string;
children?: React.ReactNode;
}
export const GlassOrb = forwardRef<React.ElementRef<typeof View>, GlassOrbProps>(
function GlassOrb(
{ size = 80, snapBack = true, bend = 0.6, onDrop, className, children, ...props },
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const tx = useSharedValue(0);
const ty = useSharedValue(0);
const squash = useSharedValue(0); // -1..1 deform amount
const angle = useSharedValue(0); // travel direction
const pan = React.useMemo(
() =>
PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: (_, g) => {
tx.value = g.dx;
ty.value = g.dy;
if (reduce) return;
// velocity in px/ms → deform along the travel axis, cap ~22%
const speed = Math.min(Math.hypot(g.vx, g.vy), 1.6);
squash.value = speed * 0.14;
angle.value = Math.atan2(g.dy, g.dx);
},
onPanResponderRelease: () => {
squash.value = reduce ? 0 : withSpring(0, spring.bouncy);
if (snapBack) {
tx.value = reduce ? 0 : withSpring(0, spring.bouncy);
ty.value = reduce ? 0 : withSpring(0, spring.bouncy);
}
haptics.light();
onDrop?.();
},
onPanResponderTerminate: () => {
squash.value = reduce ? 0 : withSpring(0, spring.bouncy);
if (snapBack) {
tx.value = reduce ? 0 : withSpring(0, spring.bouncy);
ty.value = reduce ? 0 : withSpring(0, spring.bouncy);
}
},
}),
[tx, ty, squash, angle, snapBack, reduce, haptics, onDrop]
);
const style = useAnimatedStyle(() => {
// stretch along travel direction, pinch across it — rotation applies
// squash to the orb's local X axis
const s = squash.value;
return {
transform: [
{ translateX: tx.value },
{ translateY: ty.value },
{ rotate: `${angle.value}rad` },
{ scaleX: 1 + s },
{ scaleY: 1 - s * 0.8 },
{ rotate: `${-angle.value}rad` },
],
};
});
return (
<Animated.View
ref={ref}
{...pan.panHandlers}
accessibilityRole="adjustable"
accessibilityLabel="Draggable glass orb"
style={[{ width: size, height: size }, style]}
className={cn("items-center justify-center", className)}
{...props}
>
<Glass
effect="clear"
bend={bend}
sheen={0.8}
interactive
className="absolute inset-0 rounded-full"
/>
{children}
</Animated.View>
);
}
);
GlassOrb.displayName = "GlassOrb";