react-native-velora-sheet
v0.1.0
Published
Lightweight React Native bottom sheet with a measured bottom safe-area inset and a footer that lands exactly on the keyboard (iOS + Android). Ships no UI.
Maintainers
Readme
react-native-velora-sheet
A lightweight React Native bottom sheet that gets the bottom edge right: a safe-area inset measured inside the modal's own window, and a footer region that lands exactly on the keyboard — on iOS, on Android, and inside the modal window where most sheets get this wrong.
It ships no UI. No input, no button, no text, no theme, no colours you have to override. You pass components; the library only guarantees where they sit.
npm install react-native-velora-sheet react-native-safe-area-contextyarn add react-native-velora-sheet react-native-safe-area-contextNo native module. No react-native-reanimated. No react-native-gesture-handler.
Zero runtime dependencies.
Contents
- Why
- Advantages
- Setup
- Quick start
- Anatomy
- Use cases
- Props
- Ref methods
- Hooks and utilities
- How the keyboard handling works
- Why the insets are measured twice
- Limitations
- Troubleshooting
- Full text reference
Why
Most bottom sheets get the top of the sheet right and the bottom wrong. Two things break, over and over:
Content sinks under the home indicator or navigation bar. A sheet's last row — a submit button, a message input, the final list item — ends up half-hidden behind the system bar, because the sheet never measured the inset that applies to its own window.
The input hides behind the keyboard. Or it lifts too far and leaves a strip of dead space above the keys, because the maths mixed screen coordinates with window coordinates. On Android those two origins differ by the status bar, the navigation bar, or both.
Velora fixes exactly those two things and stays out of the way of everything else.
Advantages
No native dependencies. Nothing to link, no reanimated, no gesture-handler,
no pods beyond react-native-safe-area-context. Drops into any project,
including ones that deliberately avoid those libraries. Zero runtime
dependencies of its own.
Correct bottom inset inside a modal. On Android a Modal is a separate
window, so the app-level SafeAreaProvider describes the wrong thing. Velora
nests a provider inside its own modal and reads that window's insets. Verified
on a physical Android 15 device with gesture navigation.
The footer lands on the keyboard, not near it. Measured, not guessed. On a real device the footer's bottom edge and the IME's reported frame top were the same pixel.
Headless. The library renders a backdrop, a drag handle and three positioned regions. Everything visible inside them is yours, so it never fights your design system and there is no theme to override.
Tiny. 170 kB unpacked, one component, 38 props.
Fully typed. Ships TypeScript declarations; verified to resolve under
bundler, node16 and nodenext module resolution.
Works outside a sheet too. The keyboard layer is exported on its own, so you can use the same measured avoidance on an ordinary screen.
Setup
Wrap your app in SafeAreaProvider once, as you normally would:
import { SafeAreaProvider } from 'react-native-safe-area-context';
export default function App() {
return (
<SafeAreaProvider>
<RootNavigator />
</SafeAreaProvider>
);
}That is the whole setup. The sheet nests its own provider internally for the modal window — you do not need to do anything about that.
Quick start
import { useState } from 'react';
import { Button, Text, View } from 'react-native';
import { VeloraSheet } from 'react-native-velora-sheet';
export function Example() {
const [open, setOpen] = useState(false);
return (
<View>
<Button title="Open" onPress={() => setOpen(true)} />
<VeloraSheet
showModal={open}
setShowModal={setOpen}
snapPoints={[60, 90]}
headerComponent={<Text style={{ padding: 16 }}>Title</Text>}
footerComponent={<MyActionBar />}
>
<MyScrollableContent />
</VeloraSheet>
</View>
);
}Anatomy
┌─────────────────────────────┐
│ ▁▁▁▁▁▁▁▁ │ handleComponent — drag target (or null)
│ headerComponent │ fixed; ignores the keyboard
├─────────────────────────────┤
│ │
│ children │ body — flexes, shrinks by the keyboard overlap
│ │
├─────────────────────────────┤
│ footerComponent │ rides the keyboard
│ ▁▁ bottom safe-area inset ▁▁│ applied here, dropped while the keyboard is up
└─────────────────────────────┘Three regions, and they are all yours:
| Region | Behaviour |
|---|---|
| headerComponent | Fixed at the top of the sheet, below the handle. Never moves. |
| children | The body. Flexes to fill the space, and shrinks by the keyboard overlap under the default keyboardBehavior. |
| footerComponent | Pinned at the bottom. Rides the keyboard and carries the bottom safe-area inset. |
With no footerComponent the bottom inset moves to the body, so content
still clears the home indicator.
Use cases
A comments or chat sheet
The body scrolls, the composer stays on the keyboard, and the list stays
anchored to the composer because keyboardBehavior="avoid" shrinks the body
rather than sliding it.
const [text, setText] = useState('');
const canSend = text.trim().length > 0;
<VeloraSheet
showModal={open}
setShowModal={setOpen}
snapPoints={[90]}
headerComponent={<Header title="Comments" onClose={() => setOpen(false)} />}
footerComponent={
<View style={styles.composer}>
<TextInput
style={styles.input}
value={text}
onChangeText={setText}
placeholder="Write a comment…"
multiline
/>
<Pressable onPress={send} disabled={!canSend}>
<SendIcon />
</Pressable>
</View>
}
>
<FlatList
data={comments}
renderItem={renderComment}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
/>
</VeloraSheet>Add inverted to the FlatList for a chat-style feed that grows upward.
A form sheet with a submit button
The button clears the home indicator when the keyboard is closed and sits on the keyboard when it is open — without you writing a single inset calculation.
<VeloraSheet
showModal={open}
setShowModal={setOpen}
snapPoints={[75]}
headerComponent={<Text style={styles.title}>Add address</Text>}
footerComponent={
<Pressable style={styles.cta} onPress={submit}>
<Text style={styles.ctaText}>Save</Text>
</Pressable>
}
>
<ScrollView keyboardShouldPersistTaps="handled">
<TextInput placeholder="Street" />
<TextInput placeholder="City" />
<TextInput placeholder="Postcode" />
</ScrollView>
</VeloraSheet>A filter or sort sheet
No keyboard involved, so switch the handling off and let the sheet size itself to two snap points.
<VeloraSheet
showModal={open}
setShowModal={setOpen}
snapPoints={[45, 85]}
keyboardBehavior="none"
headerComponent={<Text style={styles.title}>Filters</Text>}
footerComponent={
<View style={styles.row}>
<Pressable onPress={reset}><Text>Reset</Text></Pressable>
<Pressable onPress={apply}><Text>Apply</Text></Pressable>
</View>
}
>
<FilterList />
</VeloraSheet>A simple action / share menu
No footer at all — the bottom inset moves to the body automatically.
<VeloraSheet
showModal={open}
setShowModal={setOpen}
snapPoints={[35]}
dragArea="full"
handleComponent={null}
borderRadius={28}
>
<ActionRow icon="share" label="Share" onPress={share} />
<ActionRow icon="link" label="Copy link" onPress={copy} />
<ActionRow icon="flag" label="Report" onPress={report} />
</VeloraSheet>A checkout / payment summary
A sticky total bar over content that does not scroll.
<VeloraSheet
showModal={open}
setShowModal={setOpen}
snapPoints={[80]}
keyboardBehavior="sticky"
footerComponent={<PayBar total={total} onPay={pay} />}
>
<OrderSummary items={items} />
</VeloraSheet>Imperative control
Open and close from anywhere without holding visibility in state.
const sheet = useRef<VeloraSheetRef>(null);
<VeloraSheet ref={sheet} snapPoints={[40, 90]}>
<Content />
</VeloraSheet>
sheet.current?.open();
sheet.current?.snapToIndex(1);
sheet.current?.close();Reading the sheet's own insets from your components
Anything rendered inside the sheet can read the measured inset and the sheet controls.
import { useVeloraSheet, useIsKeyboardVisible } from 'react-native-velora-sheet';
function MyFooter() {
const { safeAreaBottom, insets, close, snapToIndex } = useVeloraSheet();
const keyboardUp = useIsKeyboardVisible();
return <View>{/* … */}</View>;
}Props
Visibility
| Prop | Type | Default | Description |
|---|---|---|---|
| showModal | boolean | — | Controlled visibility. |
| setShowModal | (visible: boolean) => void | — | Called when the sheet opens or closes itself. |
| visible | boolean | — | Alias for showModal. |
| onVisibleChange | (visible: boolean) => void | — | Alias for setShowModal. |
Layout and snap points
| Prop | Type | Default | Description |
|---|---|---|---|
| snapPoints | (number \| string)[] | [90] | Heights as a percentage of the available space: 50 or '50%'. Sorted ascending, so index 0 is always the smallest. |
| initialSnapIndex | number | 0 | Snap index the sheet opens at. |
| topInset | number | 0 | Space reserved above the sheet's maximum height. |
Bottom edge and safe area
| Prop | Type | Default | Description |
|---|---|---|---|
| useSafeArea | boolean | true | Apply the measured bottom safe-area inset. |
| bottomInset | number | — | Override the measured inset with a fixed value. |
| navigationBarTranslucent | boolean | false | Let the Android dialog draw under the navigation bar. |
Keyboard
| Prop | Type | Default | Description |
|---|---|---|---|
| keyboardBehavior | 'avoid' \| 'sticky' \| 'none' | 'avoid' | avoid shrinks the body so a scrollable body stays anchored to the footer. sticky translates only the footer. none disables keyboard handling. |
| keyboardExtraSpace | number | 0 | Extra dp kept above the keyboard. |
| dismissKeyboardOnClose | boolean | true | Dismiss the keyboard as the sheet closes. |
Gestures and dismissal
| Prop | Type | Default | Description |
|---|---|---|---|
| dragArea | 'handle' \| 'full' \| 'none' | 'handle' | Which region responds to the drag gesture. |
| enablePanDownToClose | boolean | true | Swipe down past the smallest snap point to close. |
| closeOnBackdropPress | boolean | true | Tap outside the sheet to close. When false, no invisible layer is mounted, so touches pass through. |
| closeOnBackButton | boolean | true | Android hardware back closes the sheet. |
Slots
| Prop | Type | Description |
|---|---|---|
| children | ReactNode | The body. |
| headerComponent | ReactNode | Fixed above the body. |
| footerComponent | ReactNode | Pinned below the body; rides the keyboard and carries the bottom inset. |
| handleComponent | ReactNode \| null | Replace the drag handle. null removes it. |
| backdropComponent | ReactNode \| null | Replace the dimmed backdrop. null removes it. |
Appearance
| Prop | Type | Default | Description |
|---|---|---|---|
| backgroundColor | string | '#FFFFFF' | Sheet background. |
| backdropColor | string | '#000000' | Backdrop colour. |
| backdropOpacity | number | 0.45 | Backdrop opacity when fully open. |
| borderRadius | number | 16 | Top corner radius. |
| style | ViewStyle | — | The sheet surface. |
| contentContainerStyle | ViewStyle | — | Wraps children only. |
| footerStyle | ViewStyle | — | Wraps footerComponent only. |
| handleStyle | ViewStyle | — | The default handle's container. |
| handleIndicatorStyle | ViewStyle | — | The default handle's bar. |
Animation
| Prop | Type | Default | Description |
|---|---|---|---|
| openDuration | number | 280 | Open animation duration in ms. |
| closeDuration | number | 220 | Close animation duration in ms. |
Callbacks
| Prop | Type | Description |
|---|---|---|
| onOpen | () => void | Fires when the open animation finishes. |
| onClose | () => void | Fires when the close animation finishes. |
| onSnapPointChange | (index: number) => void | Fires when the sheet settles on a snap point. |
Platform and misc
| Prop | Type | Default | Description |
|---|---|---|---|
| supportedOrientations | string[] | all four | iOS only. React Native's Modal defaults to portrait-only; this defaults to all four and lets your app's own orientation lock decide. |
| testID | string | — | Applied to the underlying Modal. |
Ref methods
const sheet = useRef<VeloraSheetRef>(null);| Method | Description |
|---|---|
| open() | Open at initialSnapIndex. |
| close() | Animate closed and notify setShowModal / onClose. |
| snapToIndex(index) | Move to a snap point. Clamped to the available range. |
Hooks and utilities
The keyboard layer is exported on its own and works anywhere, not just inside a sheet.
| Export | Description |
|---|---|
| useVeloraSheet() | Inside a sheet: { insets, safeAreaBottom, close, snapToIndex }. |
| useKeyboard() | Full metrics: { height, screenY, duration, easing, state, isVisible }. |
| useIsKeyboardVisible() | boolean. |
| useKeyboardHeight() | Coverage in dp, measured up from the screen bottom. |
| KeyboardAvoidingContainer | Flex container that shrinks by however much of it the keyboard covers. |
| KeyboardStickyView | Translates its children above the keyboard, native driver. |
| KeyboardSource | The single process-wide keyboard subscription. subscribe() / getMetrics(). |
| parseSnapPercent · snapPointsToHeights · nearestSnapIndex | Pure snap-point helpers. |
import { KeyboardAvoidingContainer } from 'react-native-velora-sheet';
// Same measured avoidance on an ordinary screen
<KeyboardAvoidingContainer style={{ flex: 1 }}>
<MessageList />
<Composer />
</KeyboardAvoidingContainer>How the keyboard handling works
A footer has to land on the keyboard's top edge, and that edge is reported in
screen coordinates while almost every layout calculation reaches for
Dimensions.get('window'). Those two origins are not the same:
| Configuration | window origin vs screen | Error if you mix them |
|---|---|---|
| iOS | identical | none |
| Android, opaque status bar | 24dp down | footer floats above the keys |
| Android, translucent modal | window.height still excludes the nav bar | footer sits too high |
| Android edge-to-edge | identical | none |
Velora never mixes them. screenY — the keyboard's top edge — is the primary
measurement, and every layout decision compares it against a measureInWindow
result, which is in the same coordinate space on both platforms and inside a
modal:
overlap = (container.y + container.height) − keyboard.screenYThat one line is correct in all four cases the library has to survive:
- iOS — the modal window never resizes, so the full overlap is applied.
- Android with
SOFT_INPUT_ADJUST_RESIZE(what React Native sets on every modal dialog) — the OS already lifted the window, the measured bottom moved up with it, the overlap resolves to ~0, and nothing is double-counted. - Android without a resize — the overlap is the whole IME, navigation bar included, and the container absorbs it.
- No keyboard event reaches JavaScript at all — the Android window resize alone still puts the footer in the right place, because the layout is flex-based rather than event-driven.
Why the insets are measured twice
On Android a Modal is a separate window. The app-level SafeAreaProvider
describes the activity, not the dialog, so reading useSafeAreaInsets() outside
the sheet gives the wrong bottom inset — a phantom gap when the dialog already
stops above an opaque navigation bar, or no padding at all when it draws edge to
edge.
VeloraSheet therefore nests a SafeAreaProvider inside its Modal.
react-native-safe-area-context reports only the portion of a system bar that
actually overlaps the provider view, which resolves to:
| | keyboard closed | keyboard open |
|---|---|---|
| iOS | 34 (home indicator) | 0 |
| Android, opaque nav bar | 0 (window already inset) | 0 |
| Android, edge-to-edge | 48 (nav bar) | 0 |
The inset drops to 0 while the keyboard is open because the home indicator and
navigation bar are behind the keyboard by then; keeping it would leave a visible
strip between the footer and the keys.
Limitations
Worth knowing before you choose this over a heavier sheet:
- Gestures run on the JavaScript thread. Velora uses
PanResponderso it can avoid areact-native-gesture-handlerdependency. The animation itself is native-driven, but under heavy JS load dragging can stutter. Libraries built on gesture-handler run the gesture on the UI thread and drag more smoothly. - No scroll-to-drag handoff. Dragging a scrollable body downward does not
hand off to the sheet. This is why
dragAreadefaults to'handle'; use'full'only when the body does not scroll. - Percentage snap points only. There is no content-measured or dynamic sizing.
- Modal-based. The sheet renders inside a React Native
Modal, so nothing can overlap outside it and nested modals behave the way they do on iOS. - No detached, persistent or non-modal presentation modes.
- No reduced-motion handling — animation durations are not shortened when the OS asks for reduced motion.
If you need UI-thread gestures, scroll handoff or dynamic sizing, use a reanimated-based sheet instead. If you want a small dependency-free sheet whose bottom edge is correct, this is the trade Velora makes.
Troubleshooting
The footer sits too high or too low on Android. Check whether your app is
edge-to-edge. With an opaque navigation bar the dialog window is already inset
and the measured bottom inset is correctly 0. Set navigationBarTranslucent
only if you want the sheet to draw under the navigation bar.
Nothing appears when I open the sheet. Make sure a SafeAreaProvider wraps
your app. Without it the nested provider still works, but seeding the first
frame relies on initialWindowMetrics.
Dragging fights my list. Keep dragArea="handle" (the default). 'full'
claims downward drags anywhere on the sheet.
The keyboard covers my input. Confirm keyboardBehavior is not 'none',
and that your input is inside footerComponent rather than in the body.
Tapping outside does not close the sheet. closeOnBackdropPress must be
true, and any custom backdropComponent must not swallow touches.
Full text reference
Every prop, export, behaviour rule, recipe and limitation is also written out
in plain text in llms.txt, shipped inside the package at:
node_modules/react-native-velora-sheet/llms.txtIt is a single self-contained file with no markup, intended to be read directly or pasted into an AI assistant so it can answer questions about this library accurately. Nothing in it is generated at runtime — it is kept in step with the source by hand.
cat node_modules/react-native-velora-sheet/llms.txtPeer dependencies
| Package | Range |
|---|---|
| react | >=18.0.0 |
| react-native | >=0.72.0 |
| react-native-safe-area-context | >=4.0.0 |
License
MIT
