A smartphone showing a native bottom sheet, with a gray-blue British shorthair cat resting in the background.

True Sheet: The Bottom Sheet That Lets the OS Do the Work

The Gray Cat
The Gray Cat
0 views

Bottom sheets are everywhere in modern mobile apps: the share menu that slides up from the bottom, the half-screen panel that shows song details, the form that pops up to collect a quick bit of input. For years the React Native community has reached for clever JavaScript implementations that animate a plain view into position, and those libraries work well. But they are reimplementing something the operating system already knows how to do, and small differences in feel, blur, and keyboard behavior tend to leak through.

True Sheet takes the opposite approach. Instead of drawing a sheet in JavaScript, it asks iOS and Android to present their own native sheets. On iOS it builds on UISheetPresentationController, the system sheet that arrived in iOS 15. On Android it uses the platform's BottomSheetBehavior. The result is a component that feels exactly like the sheets users already see in their settings app, because it is the same machinery. The README sums up the philosophy as "zero JavaScript hacks."

Why Native Matters Here

When the OS owns the sheet, a long list of hard problems simply disappear. Native gestures behave the way users expect because they are the real gestures. Blur is genuine system blur rather than an approximation. Keyboard avoidance happens automatically, so a text input never hides behind the keyboard. Accessibility comes along for the ride: VoiceOver and TalkBack already understand these sheets, including the grabber handle at the top.

There is also a pleasant consequence for your dependency tree. True Sheet is built on React Native's New Architecture as a Turbo Module plus a Fabric component, and it keeps its required JavaScript dependencies tiny. The popular JavaScript-based alternatives hard-require both Reanimated and Gesture Handler before you can show a single sheet. With True Sheet, Reanimated and React Navigation integration are optional add-ons. If all you want is a sheet, you do not have to pull in an animation runtime to get one.

That power comes with one firm requirement worth naming up front: True Sheet version 3 needs the New Architecture (Fabric) enabled, React Native 0.81 or newer, and on the Expo side, SDK 54 or newer. If your project is still on the legacy architecture, this is a hard gate rather than a soft suggestion. Plan for it before you adopt.

Getting It Into Your Project

For Expo projects, the install is a single command:

npx expo install @lodev09/react-native-true-sheet

For a bare React Native app, add the package and install the iOS pods:

yarn add @lodev09/react-native-true-sheet
cd ios && pod install

There is no manual native linking beyond pod install; autolinking handles the rest. The one thing you must confirm is that the New Architecture is switched on. Once that is in place, you are ready to present your first sheet.

Your First Sheet

The core of True Sheet is the TrueSheet component plus a ref you use to drive it. You call present to open it and dismiss to close it. Here is a complete, minimal example:

import { useRef } from "react"
import { View, Button } from "react-native"
import { TrueSheet } from "@lodev09/react-native-true-sheet"

export const App = () => {
  const sheet = useRef<TrueSheet>(null)

  const present = async () => {
    await sheet.current?.present()
  }

  const dismiss = async () => {
    await sheet.current?.dismiss()
  }

  return (
    <View>
      <Button onPress={present} title="Present" />
      <TrueSheet ref={sheet} detents={["auto", 1]}>
        <Button onPress={dismiss} title="Dismiss" />
      </TrueSheet>
    </View>
  )
}

Notice the await on present and dismiss. These methods return promises that resolve when the native animation finishes, not when it merely starts. That means you can reliably run code the moment a sheet is fully on screen or fully gone, with no guesswork about timing. It is a small detail that quietly removes a whole category of flaky UI bugs.

Detents: The Heart of the API

The detents prop is the concept you will use most. A detent is a stop position the sheet can rest at, and you pass an array of them. Each entry is either the string "auto", which sizes the sheet to its content, or a number from 0 to 1 representing a fraction of the screen. A value of 0.5 is half height, 1 is full height.

<TrueSheet detents={["auto", 0.8, 1]} />

There are two rules to remember. You can supply at most three detents, and you should sort them from smallest to largest. With detents defined, you control which one the sheet opens at and animate between them at runtime:

// Open at the first detent (index 0)
await sheet.current?.present()

// Open directly at the largest detent (index 2)
await sheet.current?.present(2)

// Later, grow the sheet to the middle detent
await sheet.current?.resize(1)

present takes an optional detent index and an optional animated flag, so present(0, false) snaps open instantly with no animation. resize animates the open sheet to a different stop. Together with dismiss, these three methods cover the vast majority of what you will ever ask a sheet to do.

Dressing the Sheet

True Sheet exposes a broad set of props for appearance and behavior. You can tune the background, corner radius, and the grabber handle at the top, enable native blur on iOS, and control whether the user can drag or dismiss the sheet at all.

<TrueSheet
  ref={sheet}
  detents={["auto", 0.8, 1]}
  backgroundColor="#696969"
  cornerRadius={20}
  grabber
  grabberOptions={{ width: 48, height: 6, topMargin: 10, color: "#FF0000" }}
  dimmed
  dismissible
  draggable
>
  <View />
</TrueSheet>

A few of these deserve a callout. dismissible controls whether a swipe down or a tap on the backdrop closes the sheet; set it to false for a sheet the user must act on. draggable decides whether the user can drag between detents at all. dimmed toggles the dark backdrop behind the sheet. On Android, elevation controls the shadow. On iOS, backgroundBlur gives you real system blur. Because these map to native properties, they look and behave like the platform's own sheets rather than a styled approximation.

For sheets with a lot of content, you can also pin a header and a footer component so they stay in place while the body scrolls, and enable scrollable so a FlatList or ScrollView inside the sheet hands gestures off correctly. The native layer arbitrates between scrolling the list and dragging the sheet, which is exactly the kind of fiddly handoff that is painful to get right in pure JavaScript.

Driving Sheets From Anywhere

Refs are great when the sheet lives near the code that opens it, but sometimes you want to trigger a sheet from a completely different part of the app. For that, give the sheet a name and use the static methods on TrueSheet. No ref required.

<TrueSheet name="filters" detents={["auto", 1]}>
  {/* filter controls */}
</TrueSheet>
// Anywhere else in your app:
await TrueSheet.present("filters")
await TrueSheet.dismiss("filters")
await TrueSheet.dismissAll()

This pattern shines for global UI like a single share sheet or a confirmation panel that many screens reach for. dismissAll is handy when you need to clear every open sheet at once, for example on logout. When you stack sheets on top of each other, dismissStack closes only the ones layered above a given sheet, and dismiss closes a sheet along with anything on top of it.

If you are targeting the web build of your app, the static methods are replaced by a useTrueSheet() hook, which keeps the same mental model in a form that fits React's web rendering.

Listening to the Lifecycle

True Sheet emits a rich set of lifecycle events, around fifteen of them, which is invaluable for analytics or for coordinating other UI as the sheet moves. The presentation events onWillPresent and onDidPresent bracket the opening animation, and onWillDismiss and onDidDismiss bracket the closing one. Most events carry a detent payload describing where the sheet is.

<TrueSheet
  ref={sheet}
  detents={["auto", 0.8, 1]}
  onDidPresent={({ index, position }) => {
    console.log("Sheet open at detent", index, "position", position)
  }}
  onDetentChange={({ index }) => {
    console.log("Now resting at detent", index)
  }}
  onDidDismiss={() => {
    console.log("Sheet closed")
  }}
>
  <View />
</TrueSheet>

onDetentChange fires whether the user dragged to a new stop or your code called resize, so it is the single place to react to "where is the sheet now." For continuous tracking there is onPositionChange, which streams the sheet's position as it moves and includes a realtime flag, making it a natural fit for Reanimated-style worklet animations that follow the sheet. When sheets stack and unstack, the focus events onWillFocus, onDidFocus, onWillBlur, and onDidBlur let you react as layers come and go. On Android, onBackPress gives you a hook into the hardware back button.

Reaching Beyond Phones

A couple of features push past the standard phone bottom sheet. On tablets, True Sheet supports native side sheets through the anchor prop, which accepts left, center, or right along with an anchorOffset. That lets a sheet slide in from the edge on a larger screen rather than always rising from the bottom, matching how tablet apps present supplementary panels. There is also a detached mode with a detachedOffset for a floating sheet that sits away from the screen edges.

True Sheet keeps pace with the platforms it sits on, too. It supports iOS 26's Liquid Glass material and the new scroll edge effects, so sheets adopt the latest system look without extra work on your part. And as of version 3.11.0, the web renderer is built on vaul with full feature parity, which means the same sheet code can serve your web build as well as your native apps.

How It Compares

The elephant in the room is @gorhom/bottom-sheet, the incumbent with far more stars and downloads. It is a mature, deeply customizable, JavaScript-based implementation built on Reanimated and Gesture Handler, and crucially it still works on the legacy architecture and supports an unlimited number of snap points. If you need old-architecture support, the largest ecosystem, or pixel-level styling control, it remains an excellent choice.

True Sheet trades some of that breadth for authenticity. It gives you a real OS sheet with native feel, blur, keyboard handling, and accessibility, plus a much lighter required dependency footprint, at the cost of requiring the New Architecture and capping you at three detents. React Native's built-in Modal with presentationStyle="pageSheet" sits at the simple end: zero dependencies, but no detents, no grabber control, and a weak Android story. True Sheet lands in the sweet spot for teams that want genuinely native sheets without rebuilding the OS in JavaScript.

A note on honesty: the project is young, created in early 2024, and led by a single maintainer, Jovanni Lo. There is also some drift between the documentation site, which still references older version 2 prop names, and the version 3 README. Lead with the detents, present, dismiss, and resize surface shown here, since that is the stable v3 API. The signals of health are strong, though, with an open-issue count near zero and roughly monthly releases through 2026.

Wrapping Up

True Sheet makes a clear and confident bet: the operating system already builds excellent bottom sheets, so the best React Native library is the one that gets out of the way and presents them. That bet pays off in native gestures, real blur, free accessibility, automatic keyboard handling, and a refreshingly small set of required dependencies. The detents model gives you precise control over stop positions, the imperative methods and named static API make sheets easy to drive from anywhere, and the lifecycle events let you wire the sheet into the rest of your app.

If your project is on the New Architecture and you want sheets that feel indistinguishable from the platform's own, True Sheet is well worth a look. The cost of entry is the Fabric requirement; the reward is bottom sheets you do not have to fight.