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)
);
};