shadimage
tetra-ui
uibottom-sheetsource: ok · hit

Bottom Sheet

Interactive bottom sheet built on Expo UI with native snap points, gestures, and keyboard support.

npx shadcn add "https://tetra-ui.com/r/bottom-sheet.json"
free

Where it goes

kanban

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@expo/uireact-native-reanimatedreact-native-keyboard-controllerreact-native-safe-area-context

Registry dependencies

@tetra-ui/button@tetra-ui/icons@tetra-ui/slot@tetra-ui/slots

Files8 · source cached 10/8/2026, 8:05:42 PM

ui/bottom-sheet/index.ts
export * from "./bottom-sheet";
export * from "./bottom-sheet-common";
ui/bottom-sheet/bottom-sheet-types.ts
import type { SnapPoint } from "@expo/ui";
import type { PressableProps, ViewProps } from "react-native";

export type BottomSheetProps = {
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  children: React.ReactNode;
};

export type BottomSheetContentProps = ViewProps & {
  children: React.ReactNode;
  snapPoints?: SnapPoint[];
  showDragIndicator?: boolean;
  className?: string;
};

export type BottomSheetBodyProps = {
  children: React.ReactNode;
  className?: string;
};

export type BottomSheetTriggerProps = PressableProps & {
  asChild?: boolean;
};

export type BottomSheetCloseProps = PressableProps & {
  asChild?: boolean;
};

export type BottomSheetFooterProps = ViewProps;
ui/bottom-sheet/bottom-sheet-slots.ts
import { createSlots } from "@/lib/slots";

export const bottomSheetSlots = createSlots<"footer" | "scroll">({
  errorMessage: "BottomSheet parts must be rendered inside BottomSheetContent",
});
ui/bottom-sheet/bottom-sheet-context.ts
import { createContext, useContext } from "react";

type BottomSheetContextValue = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  fitToContents: boolean;
  /** Android: measured Compose sheet width in dp. Omitted on other platforms. */
  sheetWidth?: number;
};

export const BottomSheetContext = createContext<BottomSheetContextValue | null>(
  null
);

export const useBottomSheetContext = () => {
  const context = useContext(BottomSheetContext);
  if (!context) {
    throw new Error("useBottomSheetContext must be used within an BottomSheet");
  }
  return context;
};
ui/bottom-sheet/bottom-sheet-common.tsx
import { useCallback, useMemo, useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { cn } from "@/lib/utils";
import { Button, ButtonIcon } from "@/components/ui/button";
import { XIcon } from "@/components/ui/icons";
import { Slot } from "@/components/ui/slot";
import {
  BottomSheetContext,
  useBottomSheetContext,
} from "./bottom-sheet-context";
import { bottomSheetSlots } from "./bottom-sheet-slots";
import type {
  BottomSheetCloseProps,
  BottomSheetProps,
  BottomSheetTriggerProps,
} from "./bottom-sheet-types";

// Components
export const BottomSheet = ({
  open: openProp,
  onOpenChange: onOpenChangeProp,
  children,
}: BottomSheetProps) => {
  const [internalOpen, setInternalOpen] = useState(openProp ?? false);

  const isControlled = openProp !== undefined;
  const open = isControlled ? openProp : internalOpen;

  const onOpenChange = useCallback(
    (nextOpen: boolean) => {
      if (!isControlled) {
        setInternalOpen(nextOpen);
      }
      onOpenChangeProp?.(nextOpen);
    },
    [isControlled, onOpenChangeProp]
  );

  const ctx = useMemo(
    () => ({
      fitToContents: true,
      onOpenChange,
      open,
    }),
    [open, onOpenChange]
  );

  return (
    <BottomSheetContext.Provider value={ctx}>
      {children}
    </BottomSheetContext.Provider>
  );
};

export const BottomSheetTrigger = ({
  asChild,
  ...props
}: BottomSheetTriggerProps) => {
  const { onOpenChange } = useBottomSheetContext();
  const Comp = asChild ? Slot.Pressable : Pressable;
  return <Comp {...props} onPress={() => onOpenChange(true)} />;
};

export const BottomSheetClose = ({
  asChild,
  ...props
}: BottomSheetCloseProps) => {
  const { onOpenChange } = useBottomSheetContext();
  const Comp = asChild ? Slot.Pressable : Pressable;
  return <Comp {...props} onPress={() => onOpenChange(false)} />;
};

export const BottomSheetBody = ({
  className,
  ...props
}: React.ComponentProps<typeof View>) => {
  const { fitToContents } = useBottomSheetContext();

  return (
    <View
      className={cn(
        fitToContents ? undefined : "min-h-0 flex-1",
        "px-4",
        className
      )}
      {...props}
    />
  );
};

export const BottomSheetScrollView = ({
  className,
  contentContainerClassName,
  ...props
}: React.ComponentProps<typeof ScrollView>) => {
  const { fitToContents } = useBottomSheetContext();

  return (
    <bottomSheetSlots.Fill name="scroll" passthrough>
      <ScrollView
        automaticallyAdjustContentInsets={false}
        automaticallyAdjustsScrollIndicatorInsets={false}
        className={cn(
          fitToContents ? undefined : "min-h-0 ios:flex-1",
          className
        )}
        contentContainerClassName={cn(
          "grow-0 px-4 pb-4",
          contentContainerClassName
        )}
        contentInsetAdjustmentBehavior="never"
        nestedScrollEnabled
        {...props}
      />
    </bottomSheetSlots.Fill>
  );
};

BottomSheetScrollView.displayName = "BottomSheetScrollView";

export const BottomSheetHeader = ({
  className,
  children,
  ...props
}: React.ComponentProps<typeof View>) => {
  return (
    <View
      className={cn(
        "shrink-0 flex-row items-center gap-1 px-4 ios:pt-8 pt-4.5 pb-4",
        className
      )}
      {...props}
    >
      {children}
      <BottomSheetClose asChild>
        <Button className="ml-auto" size="icon" variant="link">
          <ButtonIcon className="text-muted-foreground">
            <XIcon />
          </ButtonIcon>
        </Button>
      </BottomSheetClose>
    </View>
  );
};

BottomSheetHeader.displayName = "BottomSheetHeader";

export const BottomSheetTitle = ({
  className,
  ...props
}: React.ComponentProps<typeof Text>) => (
  <Text
    className={cn(
      "font-semibold text-foreground text-xl leading-none",
      className
    )}
    {...props}
  />
);
ui/bottom-sheet/bottom-sheet.tsx
import type {
  BottomSheetContentProps,
  BottomSheetFooterProps,
} from "./bottom-sheet-types";

export const BottomSheetContent = (_: BottomSheetContentProps) => {
  return null;
};

export const BottomSheetFooter = (_: BottomSheetFooterProps) => {
  return null;
};
ui/bottom-sheet/bottom-sheet.ios.tsx
import type { SnapPoint } from "@expo/ui";
import {
  BottomSheet as BottomSheetPrimitive,
  Group as GroupPrimitive,
  Host as HostPrimitive,
  RNHostView,
} from "@expo/ui/swift-ui";
import {
  frame,
  ignoreSafeArea,
  type ModifierConfig,
  type PresentationDetent,
  presentationBackground,
  presentationDetents,
  presentationDragIndicator,
  presentationSizing,
} from "@expo/ui/swift-ui/modifiers";
import { useMemo } from "react";
import { useWindowDimensions, View } from "react-native";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, {
  Extrapolation,
  interpolate,
  useAnimatedStyle,
} from "react-native-reanimated";
import {
  SafeAreaInsetsContext,
  useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
import { cn } from "@/lib/utils";
import {
  BottomSheetContext,
  useBottomSheetContext,
} from "./bottom-sheet-context";
import { bottomSheetSlots } from "./bottom-sheet-slots";
import type {
  BottomSheetContentProps,
  BottomSheetFooterProps,
} from "./bottom-sheet-types";

// Constants
const BOTTOM_SHEET_PADDING = 16;
const ZERO_INSETS = { bottom: 0, left: 0, right: 0, top: 0 };

// Components
export const BottomSheetContent = (props: BottomSheetContentProps) => {
  return (
    <bottomSheetSlots.Provider>
      <BottomSheetContentView {...props} />
    </bottomSheetSlots.Provider>
  );
};

const BottomSheetContentView = ({
  showDragIndicator = true,
  snapPoints,
  className,
  children,
  style,
  ...props
}: BottomSheetContentProps) => {
  const { open, onOpenChange } = useBottomSheetContext();
  const backgroundColor = useCSSVariable("--color-background") as string;
  const hasSnapPoints = Boolean(snapPoints && snapPoints.length > 0);
  const hasFooter = bottomSheetSlots.useHasSlot("footer");
  const hasScrollView = bottomSheetSlots.useHasSlot("scroll");
  // Fitted sheets size to the RN view. A ScrollView reports its full content
  // height, which pushes the header off-screen. Bound scrollable sheets instead.
  const fitToContents = !(hasSnapPoints || hasScrollView);
  const extendScrollToBottomEdge = hasScrollView && !hasFooter;

  const sheetContext = useMemo(
    () => ({
      fitToContents,
      onOpenChange,
      open,
    }),
    [fitToContents, onOpenChange, open]
  );

  const { height: windowHeight, width: windowWidth } = useWindowDimensions();
  const { top: topInset } = useSafeAreaInsets();
  const maxFitToContentsHeight = windowHeight - topInset;

  const contentModifiers: ModifierConfig[] = [
    frame({
      alignment: "topLeading",
      maxWidth: Number.POSITIVE_INFINITY,
    }),
    presentationBackground(backgroundColor),
    presentationDragIndicator(showDragIndicator ? "visible" : "hidden"),
  ];

  if (hasSnapPoints) {
    contentModifiers.push(
      presentationDetents(snapPoints?.map(snapPointToDetent) || [])
    );
    if (extendScrollToBottomEdge) {
      contentModifiers.push(
        ignoreSafeArea({ edges: "bottom", regions: "container" })
      );
    }
  } else if (hasScrollView) {
    contentModifiers.push(
      presentationDetents([{ height: maxFitToContentsHeight }])
    );
    if (extendScrollToBottomEdge) {
      contentModifiers.push(
        ignoreSafeArea({ edges: "bottom", regions: "container" })
      );
    }
  } else {
    contentModifiers.push(presentationSizing("fitted"));
  }

  const content = (
    <View
      className={cn("flex-col", className)}
      style={[
        fitToContents
          ? { maxHeight: maxFitToContentsHeight, width: windowWidth }
          : {
              flexGrow: 1,
              height: 0,
            },
        style,
      ]}
      {...props}
    >
      {children}
    </View>
  );

  return (
    <BottomSheetContext.Provider value={sheetContext}>
      <HostPrimitive
        pointerEvents="none"
        style={{ position: "absolute", width: windowWidth }}
      >
        <BottomSheetPrimitive
          fitToContents={fitToContents}
          isPresented={open}
          onIsPresentedChange={onOpenChange}
        >
          <GroupPrimitive modifiers={contentModifiers}>
            <RNHostView matchContents={fitToContents}>
              {extendScrollToBottomEdge ? (
                <SafeAreaInsetsContext.Provider value={ZERO_INSETS}>
                  {content}
                </SafeAreaInsetsContext.Provider>
              ) : (
                content
              )}
            </RNHostView>
          </GroupPrimitive>
        </BottomSheetPrimitive>
      </HostPrimitive>
    </BottomSheetContext.Provider>
  );
};

export const BottomSheetFooter = ({
  className,
  style,
  ...props
}: BottomSheetFooterProps) => {
  const { bottom } = useSafeAreaInsets();
  const { progress: keyboardProgress } = useReanimatedKeyboardAnimation();

  const animatedStyle = useAnimatedStyle(
    () => ({
      paddingBottom: interpolate(
        keyboardProgress.value,
        [0, 1],
        [bottom ? 0 : BOTTOM_SHEET_PADDING, BOTTOM_SHEET_PADDING],
        Extrapolation.CLAMP
      ),
    }),
    [bottom]
  );

  return (
    <bottomSheetSlots.Fill name="footer" passthrough>
      <Animated.View
        className={cn(
          "shrink-0 flex-col gap-2 border-border border-t bg-background px-4 pt-4",
          className
        )}
        style={[animatedStyle, style]}
        {...props}
      />
    </bottomSheetSlots.Fill>
  );
};

BottomSheetFooter.displayName = "BottomSheetFooter";

// Utils
const snapPointToDetent = (snapPoint: SnapPoint): PresentationDetent => {
  if (snapPoint === "half") {
    return "medium";
  }
  if (snapPoint === "full") {
    return "large";
  }
  return snapPoint;
};
ui/bottom-sheet/bottom-sheet.android.tsx
import type { SnapPoint } from "@expo/ui";
import {
  Box,
  Column,
  Host,
  ModalBottomSheet,
  type ModalBottomSheetRef,
  RNHostView,
} from "@expo/ui/jetpack-compose";
import {
  background,
  clip,
  fillMaxHeight,
  fillMaxWidth,
  height,
  imePadding,
  type ModifierConfig,
  onSizeChanged,
  padding,
  Shapes,
  weight,
  width,
} from "@expo/ui/jetpack-compose/modifiers";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Keyboard, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
import { cn } from "@/lib/utils";
import {
  BottomSheetContext,
  useBottomSheetContext,
} from "./bottom-sheet-context";
import { bottomSheetSlots } from "./bottom-sheet-slots";
import type {
  BottomSheetContentProps,
  BottomSheetFooterProps,
} from "./bottom-sheet-types";

// Constants
const BOTTOM_SHEET_PADDING = 16;
const DRAG_HANDLE_WIDTH = 38;
const DRAG_HANDLE_HEIGHT = 6;

// Components
const BottomSheetDragHandle = () => {
  const handleColor = useCSSVariable("--color-muted-foreground") as string;

  return (
    <ModalBottomSheet.DragHandle>
      <Column
        horizontalAlignment="center"
        modifiers={[fillMaxWidth(), padding(0, 10, 0, 0)]}
      >
        <Box
          modifiers={[
            width(DRAG_HANDLE_WIDTH),
            height(DRAG_HANDLE_HEIGHT),
            clip(Shapes.Circle),
            background(handleColor),
          ]}
        />
      </Column>
    </ModalBottomSheet.DragHandle>
  );
};

export const BottomSheetContent = (props: BottomSheetContentProps) => {
  return (
    <bottomSheetSlots.Provider>
      <BottomSheetContentView {...props} />
    </bottomSheetSlots.Provider>
  );
};

const BottomSheetContentView = ({
  showDragIndicator = true,
  snapPoints,
  className,
  children,
  style,
  ...props
}: BottomSheetContentProps) => {
  const { open, onOpenChange } = useBottomSheetContext();

  const sheetRef = useRef<ModalBottomSheetRef>(null);
  const [visible, setVisible] = useState(open);

  const backgroundColor = useCSSVariable("--color-background") as string;

  const { width: windowWidth } = useWindowDimensions();
  const hasFooter = bottomSheetSlots.useHasSlot("footer");
  const [sheetWidth, setSheetWidth] = useState(0);

  const hasSnapPoints = Boolean(snapPoints && snapPoints.length > 0);
  const fitToContents = !hasSnapPoints;
  const sheetContext = useMemo(
    () => ({
      fitToContents,
      onOpenChange,
      open,
      sheetWidth,
    }),
    [fitToContents, onOpenChange, open, sheetWidth]
  );

  const handleSheetSizeChanged = useCallback(
    ({ width: nextWidth }: { width: number; height: number }) => {
      setSheetWidth((current) => (current === nextWidth ? current : nextWidth));
    },
    []
  );

  useEffect(() => {
    if (open) {
      setVisible(true);
      return;
    }

    let cancelled = false;
    sheetRef.current?.hide().then(() => {
      if (!cancelled) {
        setVisible(false);
      }
    });

    return () => {
      cancelled = true;
    };
  }, [open]);

  const handleDismiss = useCallback(() => {
    onOpenChange(false);
  }, [onOpenChange]);

  const contentModifiers = useMemo(() => {
    const modifiers: ModifierConfig[] = [
      fillMaxWidth(),
      onSizeChanged(handleSheetSizeChanged),
    ];

    if (shouldFillMaxHeight(snapPoints)) {
      modifiers.push(fillMaxHeight(0.95));
    }

    if (hasFooter) {
      modifiers.push(imePadding());
    }

    return modifiers;
  }, [handleSheetSizeChanged, hasFooter, snapPoints]);

  useEffect(() => {
    if (!(open && hasFooter)) {
      return;
    }

    const showSub = Keyboard.addListener("keyboardDidShow", () => {
      sheetRef.current?.expand();
    });

    return () => {
      showSub.remove();
    };
  }, [hasFooter, open]);

  if (!visible) {
    return null;
  }

  return (
    <BottomSheetContext.Provider value={sheetContext}>
      <Host
        pointerEvents="none"
        style={{ position: "absolute", width: windowWidth }}
      >
        <ModalBottomSheet
          containerColor={backgroundColor}
          onDismissRequest={handleDismiss}
          ref={sheetRef}
          showDragHandle={false}
          skipPartiallyExpanded={shouldSkipPartiallyExpanded(snapPoints)}
        >
          {showDragIndicator ? <BottomSheetDragHandle /> : null}

          <Column modifiers={contentModifiers}>
            <Column modifiers={fitToContents ? undefined : [weight(1)]}>
              <RNHostView matchContents={fitToContents}>
                <View
                  className={cn("flex-col", className)}
                  {...props}
                  style={[
                    fitToContents
                      ? sheetWidth > 0
                        ? { width: sheetWidth }
                        : undefined
                      : { flexGrow: 1, height: 0 },
                    style,
                  ]}
                >
                  {children}
                </View>
              </RNHostView>
            </Column>

            <bottomSheetSlots.Outlet name="footer" />
          </Column>
        </ModalBottomSheet>
      </Host>
    </BottomSheetContext.Provider>
  );
};

export const BottomSheetFooter = ({
  className,
  style,
  children,
  ...props
}: BottomSheetFooterProps) => {
  const { bottom: safeAreaBottom } = useSafeAreaInsets();
  const { sheetWidth = 0 } = useBottomSheetContext();

  return (
    <bottomSheetSlots.Fill name="footer">
      <RNHostView
        matchContents
        modifiers={[padding(0, 0, 0, safeAreaBottom + BOTTOM_SHEET_PADDING)]}
      >
        <View
          className={cn(
            "flex w-full flex-col gap-2 border-border border-t bg-background px-4 pt-4",
            className
          )}
          style={[
            {
              paddingBottom: BOTTOM_SHEET_PADDING,
              ...(sheetWidth > 0 ? { width: sheetWidth } : null),
            },
            style,
          ]}
          {...props}
        >
          {children}
        </View>
      </RNHostView>
    </bottomSheetSlots.Fill>
  );
};

BottomSheetFooter.displayName = "BottomSheetFooter";

// Utils
const shouldSkipPartiallyExpanded = (
  snapPoints: SnapPoint[] | undefined
): boolean => {
  if (!snapPoints || snapPoints.length === 0) {
    return false;
  }

  return !snapPoints.some(
    (snapPoint) =>
      snapPoint === "half" ||
      (typeof snapPoint === "object" &&
        "fraction" in snapPoint &&
        snapPoint.fraction < 1) ||
      (typeof snapPoint === "object" && "height" in snapPoint)
  );
};

const shouldFillMaxHeight = (snapPoints: SnapPoint[] | undefined): boolean => {
  if (!snapPoints || snapPoints.length === 0) {
    return false;
  }

  return snapPoints.some(
    (snapPoint) =>
      snapPoint === "full" ||
      (typeof snapPoint === "object" &&
        "fraction" in snapPoint &&
        snapPoint.fraction >= 1)
  );
};

More from tetra-ui

Browse all components in this library →