Tac UIv1.0.2

Bottom Sheet Layout

A draggable bottom sheet pattern commonly used in mobile apps for contextual content. The sheet slides up from the bottom with snap points and gesture-based interaction.

Basic Bottom Sheet

A bottom sheet overlay with a drag handle that can be pulled up to reveal content. Uses PanResponder and Animated for smooth gesture handling.

tsx
import { useRef, useState } from 'react';
import {
  View, Text, Animated, PanResponder, Dimensions,
  StyleSheet, Pressable, ScrollView,
} from 'react-native';
import { useTacNativeTheme, Button, Card, CardTitle, VStack } from '@tac-ui/native';
import { nativeShadows } from '@tac-ui/tokens/native';

const { height: SCREEN_H } = Dimensions.get('window');
const SNAP_TOP = SCREEN_H * 0.15;
const SNAP_MID = SCREEN_H * 0.5;
const SNAP_BOTTOM = SCREEN_H * 0.85;

export default function BottomSheetScreen() {
  const { theme } = useTacNativeTheme();
  const translateY = useRef(new Animated.Value(SNAP_BOTTOM)).current;
  const lastSnap = useRef(SNAP_BOTTOM);
  const shadow = nativeShadows[theme.mode].lg;

  const panResponder = useRef(
    PanResponder.create({
      onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dy) > 10,
      onPanResponderMove: (_, g) => {
        const newY = lastSnap.current + g.dy;
        const clamped = Math.max(SNAP_TOP, Math.min(SNAP_BOTTOM, newY));
        translateY.setValue(clamped);
      },
      onPanResponderRelease: (_, g) => {
        const currentY = lastSnap.current + g.dy;
        // Find nearest snap point
        const snaps = [SNAP_TOP, SNAP_MID, SNAP_BOTTOM];
        const nearest = snaps.reduce((prev, snap) =>
          Math.abs(snap - currentY) < Math.abs(prev - currentY) ? snap : prev
        );
        lastSnap.current = nearest;
        Animated.spring(translateY, {
          toValue: nearest,
          useNativeDriver: true,
          stiffness: 300,
          damping: 30,
        }).start();
      },
    })
  ).current;

  return (
    <View style={{ flex: 1, backgroundColor: theme.colors.background }}>
      {/* Map or main content behind the sheet */}
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <Text style={{ color: theme.colors.mutedForeground }}>
          Main content (map, feed, etc.)
        </Text>
      </View>

      {/* Bottom Sheet */}
      <Animated.View
        style={[{
          position: 'absolute',
          left: 0, right: 0,
          height: SCREEN_H,
          backgroundColor: theme.colors.surface,
          borderTopLeftRadius: 20,
          borderTopRightRadius: 20,
          transform: [{ translateY }],
          ...shadow,
        }]}
        {...panResponder.panHandlers}
      >
        {/* Drag Handle */}
        <View style={{
          alignItems: 'center',
          paddingVertical: 12,
        }}>
          <View style={{
            width: 36,
            height: 4,
            borderRadius: 2,
            backgroundColor: theme.colors.border,
          }} />
        </View>

        {/* Sheet Content */}
        <ScrollView contentContainerStyle={{ padding: 16 }}>
          <VStack gap="md">
            <Card><CardTitle>Nearby Places</CardTitle></Card>
            <Card><CardTitle>Recent Activity</CardTitle></Card>
            <Card><CardTitle>Favorites</CardTitle></Card>
          </VStack>
        </ScrollView>
      </Animated.View>
    </View>
  );
}

Persistent Bottom Sheet

A bottom sheet that stays visible and snaps between a peeking state and expanded state — common for music players or ride-sharing apps.

tsx
const PEEK_HEIGHT = 80;
const EXPANDED_HEIGHT = SCREEN_H * 0.6;

function PersistentSheet() {
  const { theme } = useTacNativeTheme();
  const translateY = useRef(new Animated.Value(0)).current;
  const [expanded, setExpanded] = useState(false);

  const toggle = () => {
    const toValue = expanded ? 0 : -(EXPANDED_HEIGHT - PEEK_HEIGHT);
    setExpanded(!expanded);
    Animated.spring(translateY, {
      toValue,
      useNativeDriver: true,
      stiffness: 260,
      damping: 30,
    }).start();
  };

  return (
    <View style={{ flex: 1 }}>
      {/* Main content */}
      <ScrollView style={{ flex: 1 }}>
        {/* ... */}
      </ScrollView>

      {/* Persistent Sheet */}
      <Animated.View style={{
        position: 'absolute',
        bottom: 0, left: 0, right: 0,
        height: EXPANDED_HEIGHT,
        transform: [{ translateY: Animated.add(
          translateY,
          EXPANDED_HEIGHT - PEEK_HEIGHT
        ) }],
        backgroundColor: theme.colors.surface,
        borderTopLeftRadius: 16,
        borderTopRightRadius: 16,
      }}>
        <Pressable onPress={toggle}>
          <View style={{
            height: PEEK_HEIGHT,
            flexDirection: 'row',
            alignItems: 'center',
            paddingHorizontal: 16,
          }}>
            <View style={{
              width: 48, height: 48,
              borderRadius: 8,
              backgroundColor: theme.colors.secondary,
            }} />
            <View style={{ marginLeft: 12, flex: 1 }}>
              <Text style={{ fontWeight: '600', color: theme.colors.foreground }}>
                Now Playing
              </Text>
              <Text style={{ fontSize: 13, color: theme.colors.mutedForeground }}>
                Artist - Song Title
              </Text>
            </View>
          </View>
        </Pressable>

        {/* Expanded content */}
        <ScrollView contentContainerStyle={{ padding: 16 }}>
          {/* Full player UI */}
        </ScrollView>
      </Animated.View>
    </View>
  );
}