BottomSheet

Draggable sheet anchored to the bottom of the screen.

A sheet anchored to the bottom of the screen, dragged and dismissed with a finger.

It mounts through a portal and unmounts after its exit animation, so a closed sheet costs nothing.

Use it when the content is a task of its own — picking, filtering, confirming. For a panel that stays next to the control that opened it, use Popover; for something that covers the app from an edge, use Drawer.

Installation

BottomSheet ships with the library — no separate install.

import { BottomSheet, Button, Input, Item, Label, RadioGroup, Separator, Slider, Switch, Text } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add bottom-sheet

Usage

<BottomSheet>
  <BottomSheet.Trigger>
    <Button variant="outline">Share</Button>
  </BottomSheet.Trigger>
  <BottomSheet.Content>
    <Text size="lg" weight="semibold">Share project</Text>
    <Input placeholder="https://panelui.dev/p/xK2f9" />
    <Button>Copy link</Button>
  </BottomSheet.Content>
</BottomSheet>

Composition

<BottomSheet>
  <BottomSheet.Trigger>…</BottomSheet.Trigger>
  <BottomSheet.Content>
    <BottomSheet.Header title="…" />   {/* stays put, and clears the close button */}
    <BottomSheet.Body>…</BottomSheet.Body>     {/* scrolls */}
    <BottomSheet.Footer>…</BottomSheet.Footer> {/* pinned */}
  </BottomSheet.Content>
</BottomSheet>
  • BottomSheet.Trigger — Clones its child and opens the sheet on press.
  • BottomSheet.Content — The sheet surface. Renders through a portal above everything else.
  • BottomSheet.Header — A heading that stays put while the body scrolls. Reserves the corner the close button sits in.
  • BottomSheet.Body — The scrolling part. Hands the sheet its scroll position, which is what lets the two gestures share a drag.
  • BottomSheet.Footer — A row pinned below the body, for the action the sheet is asking about.

Examples

Uncontrolled, with a trigger

BottomSheet.Trigger clones its child and adds the press handler, so any pressable works as the trigger.

<BottomSheet>
  <BottomSheet.Trigger>
    <Button variant="outline">Share</Button>
  </BottomSheet.Trigger>
  <BottomSheet.Content>
    <Text size="lg" weight="semibold" className="mb-3">Share this page</Text>
    <Input placeholder="https://panelui.dev/p/xK2f9" />
    <Button className="mt-3">Copy link</Button>
  </BottomSheet.Content>
</BottomSheet>

Controlled

Needed whenever something other than the trigger opens or closes it — a deep link, a failed request, a confirmation.

const [open, setOpen] = useState(false);

<BottomSheet open={open} onOpenChange={setOpen}>
  <BottomSheet.Content>
    <Text size="lg" weight="semibold">Delete project?</Text>
    <View className="mt-4 gap-2">
      <Button variant="destructive" onPress={confirmDelete}>Delete</Button>
      <Button variant="ghost" onPress={() => setOpen(false)}>Cancel</Button>
    </View>
  </BottomSheet.Content>
</BottomSheet>

A detached sheet

Floated clear of all four screen edges. For a short, decisive sheet — a confirmation, a rating — where a full-width drawer is more sheet than the question needs.

<BottomSheet>
  <BottomSheet.Trigger>
    <Button variant="outline">Rate your order</Button>
  </BottomSheet.Trigger>
  <BottomSheet.Content detached>
    <Text size="lg" weight="semibold" className="mb-1">
      Rate your order
    </Text>
    <Text size="sm" muted className="mb-4">
      How was the delivery?
    </Text>
    <View className="flex-row gap-2 pb-2">
      <Button variant="outline" className="flex-1">Bad</Button>
      <Button variant="outline" className="flex-1">Fine</Button>
      <Button className="flex-1">Great</Button>
    </View>
  </BottomSheet.Content>
</BottomSheet>

A frosted backdrop

blur replaces the dim with a frost, so the screen behind recedes without disappearing. Falls back to the dim when expo-blur is not installed.

<BottomSheet.Content detached blur>
  <Text size="lg" weight="semibold" className="mb-1">
    Move to trash
  </Text>
  <Text size="sm" muted className="mb-4">
    The file stays recoverable for 30 days.
  </Text>
  <View className="flex-row gap-2 pb-2">
    <Button variant="outline" className="flex-1">Cancel</Button>
    <Button variant="destructive" className="flex-1">Move to trash</Button>
  </View>
</BottomSheet.Content>

A sheet you cannot dismiss by tapping out

dismissible={false} removes the backdrop press. The drag-to-dismiss gesture still works, so use it for "are you sure", not for anything you must not escape.

<BottomSheet.Content dismissible={false}>
  <Text size="lg" weight="semibold">Finish setting up</Text>
  <Button className="mt-4" onPress={complete}>Continue</Button>
</BottomSheet.Content>

A sheet that is given its height

A sheet of a handful of rows should size to them. Past that, size fixes the height instead, and the sheet is clamped to leave the status bar clear rather than running under it.

<BottomSheet.Content size="full">
  <BottomSheet.Header title="Filters" description="3 applied · 384 results" />

  <BottomSheet.Body contentContainerClassName="gap-6 pb-6">
    {brands.map((brand) => (
      <Item key={brand.id} onPress={() => toggle(brand.id)}>
        <Item.Content>
          <Item.Title>{brand.name}</Item.Title>
        </Item.Content>
      </Item>
    ))}
  </BottomSheet.Body>

  <BottomSheet.Footer className="flex-row">
    <Button variant="outline" className="flex-1" onPress={reset}>Reset</Button>
    <Button className="flex-[2]">Show 384 results</Button>
  </BottomSheet.Footer>
</BottomSheet.Content>

A list that scrolls without dismissing the sheet

A plain ScrollView and the sheet’s drag are strangers, so the first to activate takes the touch and every downward swipe is a coin toss. BottomSheet.Body reports where it is scrolled to, so the list keeps the drag until it reaches its top and the sheet takes over from there.

<BottomSheet.Content size="half">
  <BottomSheet.Header title="Choose a country" />
  <BottomSheet.Body contentContainerClassName="pb-4">
    {countries.map((country) => (
      <Item key={country}>
        <Item.Content>
          <Item.Title>{country}</Item.Title>
        </Item.Content>
      </Item>
    ))}
  </BottomSheet.Body>
</BottomSheet.Content>

Versions

Full height

Filters are the honest case for the height: there are more of them than fits, and the button you came to press is the last thing you want to have to scroll to.

<BottomSheet.Content size="full">
  <BottomSheet.Header title="Filters" description="3 applied · 384 results" />

  <BottomSheet.Body contentContainerClassName="gap-6 pb-6">
    <RadioGroup value={sort} onValueChange={setSort}>…</RadioGroup>
    <Separator />
    <Slider value={budget} onValueChange={setBudget} min={20} max={500} step={10} />
    <Separator />
    <Item>
      <Item.Content><Item.Title>In stock only</Item.Title></Item.Content>
      <Item.Actions>
        <Switch value={inStock} onValueChange={setInStock} />
      </Item.Actions>
    </Item>
  </BottomSheet.Body>

  <BottomSheet.Footer className="flex-row">
    <Button variant="outline" className="flex-1" onPress={reset}>Reset</Button>
    <Button className="flex-[2]">Show 384 results</Button>
  </BottomSheet.Footer>
</BottomSheet.Content>

API Reference

BottomSheet

PropTypeDefaultDescription
openboolean
onOpenChange(open: boolean) => void
defaultOpenbooleanfalse
nativebooleanPresent the platform's own sheet instead of this one, so it gets the system's detents, scroll interaction and dismiss gesture. Requires the optional @expo/ui package; without it this prop does nothing. Theme tokens do not apply to the sheet chrome — the platform draws the container, so BottomSheet.Content's className and its drag handle are ignored. The content inside is still yours.
snapPoints('half' | 'full' | { fraction: number } | { height: number })[]Heights the native sheet can rest at. Omit to size to the content. { fraction } and { height } are iOS-only; Android snaps them to the nearest of half / full.
nativeBackgroundboolean | stringPaint the native sheet a solid colour instead of the material the platform draws it in by default. The platform's sheet is translucent — on iOS 26 that is Liquid Glass — and what is behind it shows through. That is right for a sheet laid over content worth glimpsing and wrong for one that is a surface of the app's own, where the app's ground shifting under it reads as a mistake. true uses the theme's popover surface, so the sheet matches the rest of the app in both schemes. A string paints that colour exactly. It only reaches the platform's sheet, so it does nothing without native. On iOS below 16.4 the sheet keeps its material.

BottomSheet.Content

PropTypeDefaultDescription
classNamestring
dismissiblebooleantrueTap on the backdrop closes the sheet. Default true.
showClosebooleantrueShow a close button in the top trailing corner — the right in a left-to-right app, the left in a right-to-left one. On by default for the styled sheet; ignored by the native sheet, which has its own dismiss affordances.
showGrabberbooleantrueShow the drag handle at the top of the sheet. On by default, because a sheet that can be dragged should say so. Turn it off when the sheet draws its own — a component wrapping this one to give the surface a material of its own has to put the handle on that material, and a handle floating above it belongs to nothing.
detachedbooleanfalseFloat the sheet clear of the screen edges instead of docking it to the bottom, so it reads as a card laid over the app rather than a drawer pulled out of it. All four corners round and the bottom border comes back, since a floating sheet has four real edges where a docked one has three. Ignored by the native sheet, which the platform positions itself.
blurbooleanfalseFrost the screen behind the sheet instead of dimming it, so what is behind stays legible as shape and colour while losing its detail. Needs the optional expo-blur; without it this dims, rather than failing. Someone who has Reduce Transparency switched on gets an opaque backdrop instead, which is the whole point of the setting.
size'auto' | 'half' | 'full''auto'How tall the sheet opens. auto sizes to the content, which is right for a sheet that is a handful of rows. half and full fix the height instead, for content that has to be given the room rather than allowed to ask for it — a list, a form, a document. Either way the sheet is clamped to leave the status bar clear, so full is as tall as the screen allows rather than as tall as the screen. On the native sheet this maps onto the platform's detents.

BottomSheet.Header

PropTypeDefaultDescription
classNamestring
titleReactNodeHeading for the sheet. Strings are wrapped; anything else is drawn as given.
descriptionReactNodeA line under the title, for what the sheet is asking.

BottomSheet.Body

PropTypeDefaultDescription
classNamestring

BottomSheet.Footer

PropTypeDefaultDescription
classNamestring

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Control it with open / onOpenChange, or leave it uncontrolled with defaultOpen.

Detached and docked

A docked sheet is continuous with the bottom of the screen — three real edges, and no line along a fourth that is not there. detached lifts it clear of all four instead, so it reads as a card laid over the app rather than a drawer pulled out of it: every corner rounds and the bottom border comes back. The gap it leaves already clears the home indicator, so it takes plain padding rather than stacking the safe-area inset on top of the margin.

How tall the sheet is

size decides. auto, the default, sizes to the content, which is right for a sheet of a handful of rows. half and full fix the height instead, for content that has to be given the room rather than allowed to ask for it — a list, a form, a document.

Either way the sheet is clamped to leave the status bar and the notch clear, so full is as tall as the screen allows rather than as tall as the screen. It is not the whole screen by design: a sheet reaching the top has nothing behind it to read as laid over, and the gap is what says the app is still there underneath.

On the native sheet size maps onto the platform’s own detents, so it keeps the system’s snapping. An explicit snapPoints on the root is the finer control and wins.

Scrolling inside a sheet

Use BottomSheet.Body rather than a bare ScrollView. Both gestures want the same downward drag, and with no relationship between them whichever activates first takes the touch outright — so either the list never scrolls or the sheet never drags. Body reports its scroll position to the sheet, which holds off until the list has run out: pull down on a list at its top and the sheet comes with you, pull down anywhere else and the list scrolls.

The backdrop

The screen behind dims by default. blur frosts it instead, which keeps what is behind legible as shape and colour while losing its detail. It needs the optional expo-blur; without it the sheet dims, rather than failing — a blur you cannot draw is better shown as a darkened screen than as a crash.

Native rendering

Pass native to render the platform's own sheet instead — SwiftUI on iOS, Jetpack Compose on Android. It needs the optional @expo/ui package and is a silent no-op without it.

Theme tokens do not apply in native mode: the platform draws the control with its own colours and metrics, so className and most styling props are ignored. Only the chrome is the platform's — the content inside stays yours and stays themed. Adds a snapPoints prop for the detents the sheet rests at.

See Native rendering for the full prop-by-prop breakdown.

The native sheet's surface

The platform draws its sheet in a translucent material — on iOS 26 that is Liquid Glass — and what is behind it shows through. That is right for a sheet laid over content worth glimpsing, and wrong for one that is a surface of the app's own, where the ground shifting under it reads as a mistake.

nativeBackground paints it solid instead. true uses the theme's popover surface, so the sheet matches the rest of the app in both schemes; a string paints that colour exactly.

It reaches the sheet's own chrome — the grabber's strip at the top and the safe-area inset at the bottom — which a background on the content stops short of. It needs native, since only the platform's sheet has a material to drop, and on iOS below 16.4 the sheet keeps that material.

A close button sits in the top-right by default. Drop it with showClose={false} when the sheet is already dismissible by drag or backdrop and the corner X would be clutter. The native sheet ignores it — the platform provides its own affordances.

It is drawn above the content, so anything full-width at the top of a sheet passes underneath it rather than over it. BottomSheet.Header already leaves the corner free; a heading written by hand should keep clear of it too.

In native mode the sheet content is given a minimum height matching its first detent. A hosted box shorter than the sheet is centred in it by the platform, which is why short content would otherwise float in the middle of a half-height sheet instead of starting at the top.

Focus after closing

On the web, closing returns keyboard focus to the element that had it before the overlay opened. Nested overlays return to the still-open parent first. If that element was removed or disabled while the overlay was open, it is skipped rather than focusing a stale control. Native screen-reader containment remains the platform's accessibilityViewIsModal behaviour.

Closing, and opening again straight away

The sheet stays in the tree until its own exit animation has put it away, then unmounts from that animation's completion. Reopening while it is still leaving catches the same sheet on its way down rather than starting a second one over it, so a close followed immediately by an open is one continuous movement.

A native sheet is the platform's to present, and the platform will not present one while the previous is still dismissing — it drops the request rather than queueing it. A present that arrives during a dismissal is therefore held and made again once the platform reports the old sheet has gone.

Public exports

Values: BottomSheet

Types: BottomSheetProps, BottomSheetContentProps, BottomSheetHeaderProps, BottomSheetBodyProps, BottomSheetFooterProps

On this page