From 3b177b064e09ca17acfa9fa8d6539410e283fbbe Mon Sep 17 00:00:00 2001 From: Mathis Pruvot Date: Thu, 28 May 2026 11:49:05 +0000 Subject: [PATCH] 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. --- src/components/glass/GlassBottomSheet.tsx | 116 +++++++++++++++++ src/components/glass/GlassButton.tsx | 139 ++++++++++++++++++++ src/components/glass/GlassCard.tsx | 67 ++++++++++ src/components/glass/GlassPill.tsx | 89 +++++++++++++ src/components/glass/GlassSlider.tsx | 149 ++++++++++++++++++++++ src/components/glass/GlassTabBar.tsx | 143 +++++++++++++++++++++ src/components/glass/index.ts | 6 + 7 files changed, 709 insertions(+) create mode 100644 src/components/glass/GlassBottomSheet.tsx create mode 100644 src/components/glass/GlassButton.tsx create mode 100644 src/components/glass/GlassCard.tsx create mode 100644 src/components/glass/GlassPill.tsx create mode 100644 src/components/glass/GlassSlider.tsx create mode 100644 src/components/glass/GlassTabBar.tsx create mode 100644 src/components/glass/index.ts diff --git a/src/components/glass/GlassBottomSheet.tsx b/src/components/glass/GlassBottomSheet.tsx new file mode 100644 index 0000000..b20f8a0 --- /dev/null +++ b/src/components/glass/GlassBottomSheet.tsx @@ -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 = { + 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 ( + + + + + {/* Handle bar */} + + + + {children} + + + ); +} + +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, + }, +}); diff --git a/src/components/glass/GlassButton.tsx b/src/components/glass/GlassButton.tsx new file mode 100644 index 0000000..ce39e47 --- /dev/null +++ b/src/components/glass/GlassButton.tsx @@ -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 ( + + + {loading ? ( + + ) : ( + <> + {icon} + + {label} + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 24, + borderWidth: 1, + overflow: "hidden", + }, +}); diff --git a/src/components/glass/GlassCard.tsx b/src/components/glass/GlassCard.tsx new file mode 100644 index 0000000..d256178 --- /dev/null +++ b/src/components/glass/GlassCard.tsx @@ -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 ( + + + {/* Top edge highlight for depth illusion */} + + {children} + + ); +} + +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, + }, +}); diff --git a/src/components/glass/GlassPill.tsx b/src/components/glass/GlassPill.tsx new file mode 100644 index 0000000..28da93c --- /dev/null +++ b/src/components/glass/GlassPill.tsx @@ -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 ( + + + + {label} + + + ); +} + +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", + }, +}); diff --git a/src/components/glass/GlassSlider.tsx b/src/components/glass/GlassSlider.tsx new file mode 100644 index 0000000..6d280da --- /dev/null +++ b/src/components/glass/GlassSlider.tsx @@ -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 ( + + + {label} + {displayValue} + + { + trackWidth.value = e.nativeEvent.layout.width; + }} + > + {/* Track background */} + + {/* Filled portion */} + + {/* Thumb */} + + + + + + + + ); +} + +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, + }, +}); diff --git a/src/components/glass/GlassTabBar.tsx b/src/components/glass/GlassTabBar.tsx new file mode 100644 index 0000000..f83547f --- /dev/null +++ b/src/components/glass/GlassTabBar.tsx @@ -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 ( + + + + + {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 ( + + ); + })} + + + ); +} + +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 ( + { + 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, + })} + + {label} + + {focused && } + + ); +} + +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, + }, +}); diff --git a/src/components/glass/index.ts b/src/components/glass/index.ts new file mode 100644 index 0000000..b3d111a --- /dev/null +++ b/src/components/glass/index.ts @@ -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";