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-sheetOr install the whole package and import it
pnpm add orn-ui
import { BottomSheet } from 'orn-ui/bottom-sheet';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.
| Name | Type | Default | Description |
|---|---|---|---|
visible | boolean | — | — |
onClose | () => void | — | — |
title? | string | — | With a title, the header appears, with its close button. |
footer? | ReactNode | — | Sheet 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? | boolean | true | The content scrolls when it doesn't fit. |
draggable? | boolean | true | Dragging the sheet down closes it. |
maxHeight? | number | 0.9 | Fraction of the screen (0-1) or pixels. |
closeAccessibilityLabel? | string | Close | — |
containerStyle? | ViewStyle | — | — |
contentStyle? | ViewStyle | — | — |