feat: add glass UI component library

GlassCard, GlassButton (with pulse animation), GlassSlider
(gesture-based), GlassBottomSheet (3-state snap), GlassPill
selector, and GlassTabBar with blur backing.
This commit is contained in:
Mathis Pruvot
2026-05-28 11:49:05 +00:00
parent 66e9942eaa
commit 3b177b064e
7 changed files with 709 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
import React from "react";
import { Dimensions, StyleSheet, View } from "react-native";
import { BlurView } from "expo-blur";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
clamp,
} from "react-native-reanimated";
import { colors, glassRadius, layout } from "@/theme";
const SCREEN_HEIGHT = Dimensions.get("window").height;
type SheetState = "peek" | "half" | "full";
const SNAP_POINTS: Record<SheetState, number> = {
peek: SCREEN_HEIGHT - layout.bottomSheetPeek,
half: SCREEN_HEIGHT - layout.bottomSheetHalf,
full: 80,
};
interface GlassBottomSheetProps {
children: React.ReactNode;
initialState?: SheetState;
}
export function GlassBottomSheet({
children,
initialState = "peek",
}: GlassBottomSheetProps) {
const translateY = useSharedValue(SNAP_POINTS[initialState]);
const startY = useSharedValue(0);
const findNearestSnap = (y: number): number => {
const points = Object.values(SNAP_POINTS);
return points.reduce((prev, curr) =>
Math.abs(curr - y) < Math.abs(prev - y) ? curr : prev
);
};
const pan = Gesture.Pan()
.onStart(() => {
startY.value = translateY.value;
})
.onChange((e) => {
translateY.value = clamp(
startY.value + e.translationY,
SNAP_POINTS.full,
SNAP_POINTS.peek
);
})
.onEnd((e) => {
const projected = translateY.value + e.velocityY * 0.1;
const snap = findNearestSnap(projected);
translateY.value = withSpring(snap, {
damping: 25,
stiffness: 200,
velocity: e.velocityY,
});
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.container, animatedStyle]}>
<BlurView intensity={40} tint="dark" style={StyleSheet.absoluteFill} />
<View style={styles.overlay} />
{/* Handle bar */}
<View style={styles.handleContainer}>
<View style={styles.handle} />
</View>
<View style={styles.content}>{children}</View>
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
container: {
position: "absolute",
left: 0,
right: 0,
height: SCREEN_HEIGHT,
borderTopLeftRadius: glassRadius.xl,
borderTopRightRadius: glassRadius.xl,
overflow: "hidden",
borderWidth: 1,
borderBottomWidth: 0,
borderColor: colors.glass.border,
},
overlay: {
...StyleSheet.absoluteFill,
backgroundColor: "rgba(12, 12, 20, 0.60)",
},
handleContainer: {
alignItems: "center",
paddingTop: 12,
paddingBottom: 8,
zIndex: 1,
},
handle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: "rgba(255, 255, 255, 0.25)",
},
content: {
flex: 1,
paddingHorizontal: 20,
zIndex: 1,
},
});

View File

@@ -0,0 +1,139 @@
import React from "react";
import { Pressable, StyleSheet, Text, ViewStyle, ActivityIndicator } from "react-native";
import { BlurView } from "expo-blur";
import * as Haptics from "expo-haptics";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
} from "react-native-reanimated";
import { colors, glassRadius, typography } from "@/theme";
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
interface GlassButtonProps {
label: string;
onPress: () => void;
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
icon?: React.ReactNode;
loading?: boolean;
disabled?: boolean;
pulse?: boolean;
style?: ViewStyle;
}
export function GlassButton({
label,
onPress,
variant = "primary",
size = "md",
icon,
loading = false,
disabled = false,
pulse = false,
style,
}: GlassButtonProps) {
const scale = useSharedValue(1);
const opacity = useSharedValue(1);
// Subtle pulse animation for CTA
React.useEffect(() => {
if (pulse && !disabled) {
opacity.value = withRepeat(
withSequence(
withTiming(0.85, { duration: 1500 }),
withTiming(1, { duration: 1500 })
),
-1,
true
);
}
}, [pulse, disabled]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: disabled ? 0.4 : opacity.value,
}));
const handlePressIn = () => {
scale.value = withSpring(0.96, { damping: 15 });
};
const handlePressOut = () => {
scale.value = withSpring(1, { damping: 15 });
};
const handlePress = () => {
if (disabled || loading) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
};
const heights = { sm: 36, md: 48, lg: 56 };
const fontSizes = { sm: 13, md: 15, lg: 17 };
return (
<AnimatedPressable
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={handlePress}
disabled={disabled || loading}
style={[
styles.container,
{
height: heights[size],
borderRadius: glassRadius.pill,
borderColor:
variant === "primary"
? colors.glass.borderLight
: colors.glass.border,
backgroundColor:
variant === "primary"
? "rgba(255, 255, 255, 0.12)"
: variant === "secondary"
? "rgba(255, 255, 255, 0.06)"
: "transparent",
},
style,
animatedStyle,
]}
>
<BlurView
intensity={variant === "ghost" ? 0 : 20}
tint="dark"
style={StyleSheet.absoluteFill}
/>
{loading ? (
<ActivityIndicator color={colors.text.primary} size="small" />
) : (
<>
{icon}
<Text
style={[
typography.button,
{ fontSize: fontSizes[size] },
icon != null && { marginLeft: 8 },
]}
>
{label}
</Text>
</>
)}
</AnimatedPressable>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 24,
borderWidth: 1,
overflow: "hidden",
},
});

View File

@@ -0,0 +1,67 @@
import React from "react";
import { StyleSheet, View, ViewStyle } from "react-native";
import { BlurView } from "expo-blur";
import { glassSurface, glassIntensity, glassRadius } from "@/theme";
import type { GlassVariant } from "@/theme";
interface GlassCardProps {
children: React.ReactNode;
variant?: GlassVariant;
radius?: keyof typeof glassRadius;
padding?: number;
style?: ViewStyle;
noPadding?: boolean;
}
export function GlassCard({
children,
variant = "medium",
radius = "lg",
padding = 16,
style,
noPadding = false,
}: GlassCardProps) {
return (
<View
style={[
styles.container,
glassSurface(variant),
{ borderRadius: glassRadius[radius] },
style,
]}
>
<BlurView
intensity={glassIntensity[variant]}
tint="dark"
style={[StyleSheet.absoluteFill, { borderRadius: glassRadius[radius] }]}
/>
{/* Top edge highlight for depth illusion */}
<View
style={[
styles.highlight,
{ borderTopLeftRadius: glassRadius[radius], borderTopRightRadius: glassRadius[radius] },
]}
/>
<View style={[styles.content, !noPadding && { padding }]}>{children}</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: "relative",
overflow: "hidden",
},
highlight: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 1,
backgroundColor: "rgba(255, 255, 255, 0.08)",
},
content: {
position: "relative",
zIndex: 1,
},
});

View File

@@ -0,0 +1,89 @@
import React from "react";
import { Pressable, StyleSheet, Text, ViewStyle } from "react-native";
import { BlurView } from "expo-blur";
import * as Haptics from "expo-haptics";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from "react-native-reanimated";
import { colors, glassRadius, typography } from "@/theme";
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
interface GlassPillProps {
label: string;
selected?: boolean;
onPress: () => void;
style?: ViewStyle;
}
export function GlassPill({ label, selected = false, onPress, style }: GlassPillProps) {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
scale.value = withSpring(0.92, { damping: 12 });
setTimeout(() => {
scale.value = withSpring(1, { damping: 12 });
}, 100);
onPress();
};
return (
<AnimatedPressable
onPress={handlePress}
style={[
styles.container,
selected && styles.selected,
style,
animatedStyle,
]}
>
<BlurView
intensity={selected ? 30 : 15}
tint="dark"
style={StyleSheet.absoluteFill}
/>
<Text
style={[
styles.label,
selected && styles.labelSelected,
]}
>
{label}
</Text>
</AnimatedPressable>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: glassRadius.pill,
borderWidth: 1,
borderColor: colors.glass.border,
backgroundColor: "rgba(255, 255, 255, 0.04)",
overflow: "hidden",
marginRight: 8,
},
selected: {
borderColor: colors.glass.borderLight,
backgroundColor: "rgba(255, 255, 255, 0.12)",
},
label: {
...typography.bodySecondary,
fontSize: 13,
position: "relative",
zIndex: 1,
},
labelSelected: {
color: colors.text.primary,
fontWeight: "600",
},
});

View File

@@ -0,0 +1,149 @@
import React from "react";
import { StyleSheet, Text, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
runOnJS,
withSpring,
clamp,
} from "react-native-reanimated";
import { colors, typography } from "@/theme";
interface GlassSliderProps {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
onValueChange: (value: number) => void;
formatValue?: (value: number) => string;
}
export function GlassSlider({
label,
value,
min = 0,
max = 1,
step = 0.01,
onValueChange,
formatValue,
}: GlassSliderProps) {
const trackWidth = useSharedValue(0);
const thumbScale = useSharedValue(1);
const progress = (value - min) / (max - min);
const snap = (v: number) => {
const stepped = Math.round(v / step) * step;
return Math.round(stepped * 100) / 100;
};
const pan = Gesture.Pan()
.onBegin(() => {
thumbScale.value = withSpring(1.3, { damping: 12 });
})
.onChange((e) => {
const normalized = clamp(
(value - min) / (max - min) + e.changeX / trackWidth.value,
0,
1
);
const newValue = snap(min + normalized * (max - min));
runOnJS(onValueChange)(newValue);
})
.onEnd(() => {
thumbScale.value = withSpring(1, { damping: 12 });
});
const thumbAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: thumbScale.value }],
}));
const displayValue = formatValue ? formatValue(value) : `${Math.round(value * 100)}%`;
return (
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={typography.bodySecondary}>{label}</Text>
<Text style={typography.bodySecondary}>{displayValue}</Text>
</View>
<View
style={styles.track}
onLayout={(e) => {
trackWidth.value = e.nativeEvent.layout.width;
}}
>
{/* Track background */}
<View style={styles.trackBg} />
{/* Filled portion */}
<View
style={[
styles.trackFill,
{ width: `${progress * 100}%` },
]}
/>
{/* Thumb */}
<GestureDetector gesture={pan}>
<Animated.View
style={[
styles.thumb,
{ left: `${progress * 100}%` },
thumbAnimatedStyle,
]}
>
<View style={styles.thumbInner} />
</Animated.View>
</GestureDetector>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginVertical: 8,
},
labelRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 10,
},
track: {
height: 32,
justifyContent: "center",
position: "relative",
},
trackBg: {
position: "absolute",
left: 0,
right: 0,
height: 4,
borderRadius: 2,
backgroundColor: "rgba(255, 255, 255, 0.08)",
},
trackFill: {
position: "absolute",
left: 0,
height: 4,
borderRadius: 2,
backgroundColor: "rgba(255, 255, 255, 0.30)",
},
thumb: {
position: "absolute",
width: 24,
height: 24,
marginLeft: -12,
borderRadius: 12,
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderWidth: 1.5,
borderColor: "rgba(255, 255, 255, 0.40)",
alignItems: "center",
justifyContent: "center",
},
thumbInner: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.text.primary,
},
});

View File

@@ -0,0 +1,143 @@
import React from "react";
import { StyleSheet, Text, View, Pressable } from "react-native";
import { BlurView } from "expo-blur";
import { BottomTabBarProps } from "@react-navigation/bottom-tabs";
import * as Haptics from "expo-haptics";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from "react-native-reanimated";
import { colors, glassRadius, layout } from "@/theme";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export function GlassTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const insets = useSafeAreaInsets();
return (
<View style={[styles.container, { paddingBottom: insets.bottom || 12 }]}>
<BlurView intensity={40} tint="dark" style={StyleSheet.absoluteFill} />
<View style={styles.overlay} />
<View style={styles.tabs}>
{state.routes.map((route: typeof state.routes[number], index: number) => {
const { options } = descriptors[route.key];
const label = (options.tabBarLabel ?? options.title ?? route.name) as string;
const isFocused = state.index === index;
const onPress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const event = navigation.emit({
type: "tabPress",
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
return (
<TabItem
key={route.key}
label={label}
focused={isFocused}
icon={options.tabBarIcon}
onPress={onPress}
/>
);
})}
</View>
</View>
);
}
function TabItem({
label,
focused,
icon,
onPress,
}: {
label: string;
focused: boolean;
icon: any;
onPress: () => void;
}) {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<AnimatedPressable
onPress={onPress}
onPressIn={() => {
scale.value = withSpring(0.9, { damping: 12 });
}}
onPressOut={() => {
scale.value = withSpring(1, { damping: 12 });
}}
style={[styles.tab, animatedStyle]}
>
{icon?.({
color: focused ? colors.text.primary : colors.text.tertiary,
size: 22,
focused,
})}
<Text
style={[
styles.tabLabel,
{ color: focused ? colors.text.primary : colors.text.tertiary },
]}
>
{label}
</Text>
{focused && <View style={styles.indicator} />}
</AnimatedPressable>
);
}
const styles = StyleSheet.create({
container: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
borderTopLeftRadius: glassRadius.xl,
borderTopRightRadius: glassRadius.xl,
borderWidth: 1,
borderBottomWidth: 0,
borderColor: colors.glass.border,
overflow: "hidden",
},
overlay: {
...StyleSheet.absoluteFill,
backgroundColor: "rgba(8, 8, 16, 0.65)",
},
tabs: {
flexDirection: "row",
paddingTop: 10,
paddingHorizontal: 16,
zIndex: 1,
},
tab: {
flex: 1,
alignItems: "center",
paddingVertical: 6,
},
tabLabel: {
fontSize: 11,
fontWeight: "500",
marginTop: 4,
letterSpacing: 0.3,
},
indicator: {
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: colors.text.primary,
marginTop: 4,
},
});

View File

@@ -0,0 +1,6 @@
export { GlassCard } from "./GlassCard";
export { GlassButton } from "./GlassButton";
export { GlassSlider } from "./GlassSlider";
export { GlassBottomSheet } from "./GlassBottomSheet";
export { GlassPill } from "./GlassPill";
export { GlassTabBar } from "./GlassTabBar";