orn-ui
Organism

BottomSheet

Requires <UIProvider> — Getting Started →

Installation

Install just this component (copies the source into your project, no npm dependency)

npx orn-ui add bottom-sheet

Or install the whole package and import it

pnpm add orn-ui
import { BottomSheet } from 'orn-ui/bottom-sheet';
Depends on:core (theme + icons)Typographyicon-buttonpressable-scaletransition

Usage

  • when to use — Actions or secondary content over the current screen, without leaving it.
  • visible / onClose — Controlled: it closes on the backdrop and on gesture, always through `onClose`.
  • title / footer — With a title the header appears with its close button. `footerPlacement` decides where the footer lives: fixed, at the end of the scroll, or hidden while the keyboard is up.
  • draggable / maxHeight — `draggable={false}` for a sheet that only closes by button or backdrop. `maxHeight` as a 0-1 fraction of the screen, or in pixels.
Demo clip not recorded yet — see MEDIA.md

Variants

bare — handle only

<>
  <Button title="Open sheet" onPress={() => setBare(true)} />
  <BottomSheet visible={bare} onClose={() => setBare(false)}>
    <Title style={{ marginBottom: 8 }}>Bottom sheet</Title>
    <Body>Drag the handle down, or tap outside, to dismiss.</Body>
  </BottomSheet>
</>

title + footer — a form in a sheet

<>
  <Button title="Open form" onPress={() => setForm(true)} />
  <BottomSheet
    visible={form}
    onClose={() => setForm(false)}
    title="Add a note"
    footer={
      <FormActions
        primaryLabel="Save"
        onPrimaryPress={() => setForm(false)}
        secondaryLabel="Cancel"
        onSecondaryPress={() => setForm(false)}
      />
    }
  >
    <Input label="Note" placeholder="Anything worth remembering" value={note} onChangeText={setNote} />
  </BottomSheet>
</>

long content — scrolls up to maxHeight

<>
  <Button title="Open long sheet" onPress={() => setLong(true)} />
  <BottomSheet visible={long} onClose={() => setLong(false)} title="Twelve rows">
    <View style={{ gap: 12 }}>
      {LONG.map((row) => (
        <Body key={row}>{row}</Body>
      ))}
    </View>
  </BottomSheet>
</>

footerPlacement="hide-with-keyboard" — the form keeps that row

<>
  <Button title="Open long form" onPress={() => setHiding(true)} />
  <BottomSheet
    visible={hiding}
    onClose={() => setHiding(false)}
    title="Long form"
    footerPlacement="hide-with-keyboard"
    footer={
      <FormActions
        primaryLabel="Save"
        onPrimaryPress={() => setHiding(false)}
        secondaryLabel="Cancel"
        onSecondaryPress={() => setHiding(false)}
      />
    }
  >
    <View style={{ gap: 12 }}>
      {['Name', 'Company', 'Email', 'Phone', 'Address'].map((label) => (
        <Input key={label} label={label} value={note} onChangeText={setNote} />
      ))}
    </View>
  </BottomSheet>
</>

draggable={false} + maxHeight={0.4}

<>
  <Button title="Open fixed sheet" onPress={() => setFixed(true)} />
  <BottomSheet
    visible={fixed}
    onClose={() => setFixed(false)}
    title="No dragging"
    draggable={false}
    maxHeight={0.4}
  >
    <Body>Dismiss with the close button or by tapping outside.</Body>
  </BottomSheet>
</>
Full demo source
const variants: VariantDef[] = [
  {
    label: 'bare — handle only',
    content: (
      <>
        <Button title="Open sheet" onPress={() => setBare(true)} />
        <BottomSheet visible={bare} onClose={() => setBare(false)}>
          <Title style={{ marginBottom: 8 }}>Bottom sheet</Title>
          <Body>Drag the handle down, or tap outside, to dismiss.</Body>
        </BottomSheet>
      </>
    ),
  },
  {
    label: 'title + footer — a form in a sheet',
    content: (
      <>
        <Button title="Open form" onPress={() => setForm(true)} />
        <BottomSheet
          visible={form}
          onClose={() => setForm(false)}
          title="Add a note"
          footer={
            <FormActions
              primaryLabel="Save"
              onPrimaryPress={() => setForm(false)}
              secondaryLabel="Cancel"
              onSecondaryPress={() => setForm(false)}
            />
          }
        >
          <Input label="Note" placeholder="Anything worth remembering" value={note} onChangeText={setNote} />
        </BottomSheet>
      </>
    ),
  },
  {
    label: 'long content — scrolls up to maxHeight',
    content: (
      <>
        <Button title="Open long sheet" onPress={() => setLong(true)} />
        <BottomSheet visible={long} onClose={() => setLong(false)} title="Twelve rows">
          <View style={{ gap: 12 }}>
            {LONG.map((row) => (
              <Body key={row}>{row}</Body>
            ))}
          </View>
        </BottomSheet>
      </>
    ),
  },
  {
    label: 'footerPlacement="hide-with-keyboard" — the form keeps that row',
    content: (
      <>
        <Button title="Open long form" onPress={() => setHiding(true)} />
        <BottomSheet
          visible={hiding}
          onClose={() => setHiding(false)}
          title="Long form"
          footerPlacement="hide-with-keyboard"
          footer={
            <FormActions
              primaryLabel="Save"
              onPrimaryPress={() => setHiding(false)}
              secondaryLabel="Cancel"
              onSecondaryPress={() => setHiding(false)}
            />
          }
        >
          <View style={{ gap: 12 }}>
            {['Name', 'Company', 'Email', 'Phone', 'Address'].map((label) => (
              <Input key={label} label={label} value={note} onChangeText={setNote} />
            ))}
          </View>
        </BottomSheet>
      </>
    ),
  },
  {
    label: 'draggable={false} + maxHeight={0.4}',
    content: (
      <>
        <Button title="Open fixed sheet" onPress={() => setFixed(true)} />
        <BottomSheet
          visible={fixed}
          onClose={() => setFixed(false)}
          title="No dragging"
          draggable={false}
          maxHeight={0.4}
        >
          <Body>Dismiss with the close button or by tapping outside.</Body>
        </BottomSheet>
      </>
    ),
  },
];
return <VariantList variants={variants} />;

Props

Also accepts testID, forwarded to the root node. It exists for end-to-end tests (Maestro drives the app by the accessibility tree) and has no effect on how the component looks or behaves.

NameTypeDefaultDescription
visibleboolean
onClose() => void
title?stringWith a title, the header appears, with its close button.
footer?ReactNodeSheet actions. Where they live is decided by `footerPlacement`.
footerPlacement?'fixed' | 'scroll' | 'hide-with-keyboard'fixed'fixed' anchors it to the bottom of the sheet; 'scroll' leaves it at the end of the content; 'hide-with-keyboard' hides it while typing, which in a long form gives that strip back to the focused field.
scrollable?booleantrueThe content scrolls when it doesn't fit.
draggable?booleantrueDragging the sheet down closes it.
maxHeight?number0.9Fraction of the screen (0-1) or pixels.
closeAccessibilityLabel?stringClose
containerStyle?ViewStyle
contentStyle?ViewStyle