diff --git a/CLAUDE.md b/CLAUDE.md
index fa3f87a..0748bb6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -76,8 +76,8 @@ to the `Pick` semver-locks it.
```ts
type OpenPayload =
- | { kind: 'inline'; id; groupId; content: ReactNode; scaleBackground?; backdrop?; params? }
- | { kind: 'portal'; id; groupId; scaleBackground?; backdrop?; params? };
+ | { kind: 'inline'; id; groupId; content: ReactNode; scaleBackground?; params? }
+ | { kind: 'portal'; id; groupId; scaleBackground?; params? };
```
`kind` is what callers reason about, `usePortal` is what the renderer checks;
@@ -94,6 +94,13 @@ Key actions:
group's top and the one below is `hidden` — undoing a `switch`.
- `finishClosing(id)` hides if `keepMounted`, removes otherwise.
- `clearGroup` / `clearAll` are teardown: no animation, interceptors skipped.
+- `setBackdrop(id, false | true | BackdropConfig)` is the **only** writer of the
+ record's `backdrop` field — `open()` never touches it, which is what lets a
+ persistent sheet's config survive re-open cycles. `true` means *clear the
+ override*, not a stored flag. The action bails on value-equal writes
+ (`backdropValuesEqual`): adapters re-apply their `backdrop` prop with a fresh
+ object literal on every consumer render, and without the bail each render
+ would wake every store subscriber.
**Modes:** `push` keeps the previous sheet visible, `switch` hides it (restored
on close), `replace` closes it.
@@ -195,6 +202,31 @@ whole stack above arbitrary app chrome — without it any host view with a modes
`animatedIndex`. Do not add a timer or delay gate: deferring the mount drops the
opening frames the adapter already drove, and the backdrop pops in mid-fade.
+The backdrop's *look* is configurable (`BackdropConfig`, a `kind: 'styled' |
+'custom'` union): group default via `backdrop` on the provider, per sheet
+via the `backdrop` prop on the adapter (routed through `useAdapterBackdrop` →
+`setBackdrop`). Resolution is **atomic for the visual choice** — a sheet-level
+config replaces the group's rendering entirely; only `pressToDismiss` resolves
+per field, and styles compose (`[default, group, sheet]`) when both levels are
+`styled`. The adapter prop lands via effect a beat after the backdrop first
+mounts, so it is applied in a *layout* effect: `animatedIndex` starts at `-1`,
+which holds a `styled` backdrop at zero opacity for that frame, but a `custom`
+one owns its own fade and would otherwise paint at full strength before the
+sheet's config replaced it. The guarantee is structural for `styled` and
+contractual for `custom`. A `kind: 'custom'` component owns its own fade
+off `animatedIndex` — the built-in opacity is deliberately not applied on top.
+
+Two selectors read the field, and the split is deliberate: `QueueItem` takes
+`useSheetBackdropEnabled` (a boolean — "render one at all") so restyling does
+not re-render the memoized sheet layer, and only `BottomSheetBackdrop` takes the
+config through `useSheetBackdrop`.
+
+Every shipped adapter exposes `backdrop?: BackdropConfig | false` (via the
+shared `AdapterBackdropProps`) and, where its library draws an overlay of its
+own, forces that overlay off. Re-exposing the underlying prop (gorhom's
+`backdropComponent`, actions-sheet's overlay) would let a second, non-stack-aware
+overlay paint over the manager's.
+
`useSheetRenderData` orders hidden persistent sheets before active ones so React
does not unmount and remount across transitions.
@@ -237,7 +269,8 @@ Public so a third-party adapter reaches parity with the shipped ones:
| `useAdapterRef(forwardedRef)` | resolves the ref context (portal/persistent) or the forwarded one (inline) |
| `useAnimatedIndex()` | the sheet's shared value, `-1` hidden → `0` visible |
| `useBackHandler(id, onBackPress)` | registered only while the sheet is open **and** topmost in its own group |
-| `useSetBackdrop()` | suppress the manager's shared backdrop when the adapter draws its own |
+| `useAdapterBackdrop(id, backdrop)` | applies the adapter's `backdrop?: BackdropConfig \| false` prop; two effects on purpose — value-sync (store bails on equal) and unmount-clear — so fresh JSX literals don't clear-and-rewrite every render |
+| `useSetBackdrop()` | imperative form: `false` suppresses the shared backdrop (adapter draws its own), config restyles it, `true` clears |
| `useSheetPreventDismiss(id)` | whether an interceptor is blocking, so native gestures can be disabled |
**Drive `animatedIndex` continuously.** Setting it discretely in expand/close
@@ -250,7 +283,7 @@ when the show animation ends and `handleClosed` when the hide animation ends.
- Native `scrimColor` / `scrimOpacities` are gated on `modal` sheets on both
platforms. The manager always renders inline, so they can never paint — the
- adapter does not accept them. Use `backdrop: false`.
+ adapter does not accept them. Use the `backdrop` prop (`false` to disable).
- `fullHeight` passes a detent taller than any screen and lets native clamp it.
Do **not** recompute `windowHeight - insets.top` in JS: since 0.16 there is no
JS-provided cap, and a JS estimate ignores that the sheet lives inside the
@@ -333,36 +366,6 @@ Consumer apps need nothing — Metro reads `exports` from package.json.
---
-## File map
-
-```
-src/
-├── index.tsx # public exports (no 3rd-party adapter deps)
-├── testing.ts # → '…/testing'
-├── store/ # store · hooks · helpers · types
-├── bottomSheetCoordinator.ts # store ↔ adapter
-├── refsMap · animatedRegistry · onBeforeCloseRegistry · portalSessionRegistry
-├── adapter.types.ts · portal.types.ts
-│
-├── BottomSheetManager.provider.tsx / .context.tsx
-├── BottomSheet.context.ts · BottomSheetRef.context.ts
-├── BottomSheetDefaultIndex.context.ts # 0 for portal, -1 for persistent
-│
-├── BottomSheetHost · QueueItem · BottomSheetBackdrop · BottomSheetScaleView
-├── BottomSheetPortal ('use no memo') · BottomSheetPersistent
-│
-├── useBottomSheetManager · useBottomSheetControl · useBottomSheetContext
-├── useBottomSheetStatus · useOnBeforeClose · useSheetRenderData
-├── useScaleAnimation · useStableCallback
-├── useAdapterRef · useAnimatedIndex · useBackHandler
-│
-└── adapters/ # one directory per subpath export, no barrel
- ├── gorhom-sheet · custom-modal · react-native-modal · actions-sheet
- └── swmansion/ # + SwmansionKeyboardInset (optional peer)
-```
-
----
-
## Pitfalls
1. Do not memoize by hand — three sanctioned exceptions, listed above.
@@ -377,3 +380,8 @@ src/
8. Do not set `animatedIndex` discretely in an adapter.
9. Do not branch on `isOpen` for "is it on screen" — use `isVisible`.
10. Do not read `params` without `?.`.
+11. Do not drop `setBackdrop`'s value-equality bail, and do not subscribe
+ `QueueItem` to the backdrop *config* — both turn one consumer render into a
+ store write that re-renders the whole sheet layer.
+12. An adapter must never expose its library's own backdrop prop. The manager
+ renders the one backdrop; a second overlay stacks and is not stack-aware.
diff --git a/README.md b/README.md
index 7fd8237..784d535 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ A stack manager for bottom sheets and modals in React Native. Supports `push`, `
- [Imperative vs Portal API](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/api-comparison)
- [Navigation Modes](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/navigation-modes)
- [Scale Animation](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/scale-animation)
+- [Backdrop](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/backdrop)
- [Portal API (Context Preservation)](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/context-preservation)
- [Persistent Sheets](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/persistent-sheets)
- [Type-Safe IDs & Params](https://arekkubaczkowski.github.io/react-native-bottom-sheet-stack/type-safe-ids)
@@ -21,6 +22,7 @@ A stack manager for bottom sheets and modals in React Native. Supports `push`, `
- **Adapter Architecture** - Pluggable adapters for different bottom sheet/modal libraries. Ships with adapters for `@gorhom/bottom-sheet`, `react-native-modal`, `react-native-actions-sheet`, `@swmansion/react-native-bottom-sheet`, and a custom modal. You can also build your own.
- **Stack Navigation** - `push`, `switch`, and `replace` modes for managing multiple sheets
- **Scale Animation** - iOS-style background scaling effect when sheets are stacked
+- **Configurable Backdrop** - Theme the shared stack-aware backdrop per group or per sheet, or replace it with a custom component (e.g. blur)
- **Context Preservation** - Portal-based API that preserves React context in bottom sheets
- **Mixed Stacking** - Bottom sheets and modals coexist in the same stack
- **Persistent Sheets** - Pre-mounted sheets that open instantly and preserve state
diff --git a/docs/docs/adapters.md b/docs/docs/adapters.md
index 6cbeade..3eef5c2 100644
--- a/docs/docs/adapters.md
+++ b/docs/docs/adapters.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 10
+sidebar_position: 11
---
# Library-Agnostic Architecture
diff --git a/docs/docs/api/components.md b/docs/docs/api/components.md
index e2485d0..c98a8ae 100644
--- a/docs/docs/api/components.md
+++ b/docs/docs/api/components.md
@@ -23,6 +23,7 @@ Root provider that manages the bottom sheet stack.
|------|------|----------|-------------|
| `id` | `string` | Yes | Unique identifier for this stack group |
| `scaleConfig` | `ScaleConfig` | No | Scale animation configuration |
+| `backdrop` | `BackdropConfig \| false` | No | The group's default backdrop; `false` disables it for the whole group. A sheet overrides it with the `backdrop` prop on its adapter. See [Backdrop](/backdrop) |
| `children` | `ReactNode` | Yes | App content |
---
diff --git a/docs/docs/api/hooks.md b/docs/docs/api/hooks.md
index a7229c1..79dcc31 100644
--- a/docs/docs/api/hooks.md
+++ b/docs/docs/api/hooks.md
@@ -27,7 +27,8 @@ is built from these. You do not need them to use the library.
| `useAdapterRef` | **Inside adapter only** | Resolve the right ref for inline/portal/persistent mode |
| `useAnimatedIndex` | **Inside adapter only** | The sheet's `animatedIndex` shared value, driving backdrop and scale |
| `useBackHandler` | **Inside adapter only** | Android back button, scoped to the topmost open sheet **of its own group** |
-| `useSetBackdrop` | Anywhere | Returns `setBackdrop(id, boolean)` — suppress the manager's shared backdrop for a sheet that renders its own |
+| `useAdapterBackdrop` | **Inside adapter only** | Applies the adapter's `backdrop` prop (`BackdropConfig \| false`) to the sheet — see [Backdrop](/backdrop) |
+| `useSetBackdrop` | Anywhere | Returns `setBackdrop(id, value)` — `false` suppresses the manager's shared backdrop, a `BackdropConfig` restyles/replaces it, `true` clears the override |
| `useSheetPreventDismiss` | Anywhere | `useSheetPreventDismiss(id)` — whether an interceptor is currently blocking dismissal, so the adapter can disable native gestures |
---
@@ -109,9 +110,10 @@ open(, {
| `groupId` | `string` | context or `'default'` | Group ID for the sheet |
| `mode` | `OpenMode` | `'push'` | Navigation mode |
| `scaleBackground` | `boolean` | `false` | Enable background scaling |
-| `backdrop` | `boolean` | `true` | When `false`, the manager's shared backdrop is not rendered for this sheet. `GorhomSheetAdapter` sets this itself when you pass it a custom `backdropComponent`; the other shipped adapters use the manager's backdrop and never touch it. |
| `params` | `Record` | - | Params for the sheet, readable inside it via `useBottomSheetContext()`. Untyped here — the typed variant lives on `useBottomSheetControl` |
+`backdrop?: boolean` was **removed in v3** — configure it on the adapter (the `backdrop` prop) or on the provider, not per `open()` call. See [Backdrop → Migration](/backdrop#migration-from-v2).
+
`open()` returns the sheet's ID, or **`null`** when the store declined to open it — because the sheet is already on the stack, or another sheet in the group is still animating open. A dev-mode warning explains which.
```tsx
@@ -258,9 +260,10 @@ open({
|--------|------|---------|-------------|
| `mode` | `OpenMode` | `'push'` | Navigation mode |
| `scaleBackground` | `boolean` | `false` | Enable background scaling |
-| `backdrop` | `boolean` | `true` | When `false`, the manager's shared backdrop is not rendered for this sheet. `GorhomSheetAdapter` sets this itself when you pass it a custom `backdropComponent`; the other shipped adapters use the manager's backdrop and never touch it. |
| `params` | `BottomSheetPortalParams` | - | Type-safe params |
+`backdrop?: boolean` was **removed in v3** — configure it on the adapter (the `backdrop` prop) or on the provider, not per `open()` call. See [Backdrop → Migration](/backdrop#migration-from-v2).
+
`useBottomSheetManager().open()` also accepts `params` now, so inline sheets can read them from `useBottomSheetContext()` just like portal sheets.
---
diff --git a/docs/docs/api/types.md b/docs/docs/api/types.md
index 0f7225e..8d35c8b 100644
--- a/docs/docs/api/types.md
+++ b/docs/docs/api/types.md
@@ -240,6 +240,42 @@ const springConfig: ScaleAnimationConfig = {
---
+### BackdropConfig
+
+What the manager's shared backdrop renders — a discriminated union, `kind` first. Set as the group default via `backdrop` on the provider, or per sheet via the `backdrop` prop on an adapter (`false` there disables the backdrop). See [Backdrop](/backdrop).
+
+```tsx
+type BackdropConfig =
+ | {
+ kind: 'styled';
+ style?: StyleProp; // merged over the group's style and the default rgba(0,0,0,0.5)
+ pressToDismiss?: boolean; // default: true
+ }
+ | {
+ kind: 'custom';
+ component: ComponentType;
+ pressToDismiss?: boolean; // default: true
+ };
+```
+
+---
+
+### BackdropComponentProps
+
+Props a `kind: 'custom'` backdrop component receives.
+
+```tsx
+type BackdropComponentProps = {
+ sheetId: string;
+ /** -1 hidden → 0 fully visible (see HIDDEN_ANIMATED_INDEX). */
+ animatedIndex: SharedValue;
+ /** Calls requestClose(sheetId) — onBeforeClose interceptors still run. */
+ close: () => void;
+};
+```
+
+---
+
## Portal Types
### BottomSheetPortalRegistry
diff --git a/docs/docs/backdrop.md b/docs/docs/backdrop.md
new file mode 100644
index 0000000..0406d68
--- /dev/null
+++ b/docs/docs/backdrop.md
@@ -0,0 +1,186 @@
+---
+sidebar_position: 6
+---
+
+# Backdrop
+
+Every sheet gets a shared, stack-aware backdrop rendered by the manager: it sits outside the scale transform, layers correctly under its own sheet and above the one beneath, fades with the sheet's live `animatedIndex`, and closes the sheet on tap through the `onBeforeClose` interceptor path.
+
+By default it is a `rgba(0, 0, 0, 0.5)` scrim. Two levels make it configurable — the sheet's choice wins over the group's:
+
+1. **Group default** — `backdrop` on `BottomSheetManagerProvider`
+2. **Per sheet** — the `backdrop` prop on any shipped adapter (`false` disables it)
+
+## BackdropConfig
+
+A discriminated union — `kind` says what the backdrop renders:
+
+```tsx
+type BackdropConfig =
+ | {
+ kind: 'styled';
+ /** Merged over the group's style and the default rgba(0,0,0,0.5). */
+ style?: StyleProp;
+ /** Tap closes the sheet. Default: true. */
+ pressToDismiss?: boolean;
+ }
+ | {
+ kind: 'custom';
+ /** Replaces the built-in backdrop view entirely. */
+ component: ComponentType;
+ pressToDismiss?: boolean;
+ };
+```
+
+Only the *look* is configurable. Mount timing, z-index/stack handling, and tap routing through `requestClose` (so `onBeforeClose` interceptors still run) stay with the manager in every variant.
+
+## Theming a group
+
+```tsx
+
+ ...
+
+```
+
+`backdrop={false}` on the provider gives the whole group no backdrop. It is the
+same prop name and type as on the adapters — one is the default, the other the
+override.
+
+## Per-sheet configuration
+
+Pass `backdrop` to the adapter, right where the sheet's other visual props live — it works the same in inline, portal, and persistent mode:
+
+```tsx
+// A light scrim for a small action sheet
+
+
+// No backdrop at all
+
+
+// Keep the backdrop, but don't close on tap
+
+```
+
+`backdrop={false}` also removes the layer that blocks touches: taps outside the
+sheet then reach whatever is behind it. `pressToDismiss: false` keeps that shield
+and only stops the tap from closing the sheet.
+
+Resolution is per field for `pressToDismiss`, and **atomic for the visual choice**: a sheet-level config replaces the group's rendering entirely (a group `custom` component never bleeds under a sheet that asked for `styled`). When both levels are `styled`, their styles compose — group over default, sheet over group.
+
+## Custom component (blur, gradients)
+
+`kind: 'custom'` replaces the rendered backdrop with your own component — the common case is a blur:
+
+```tsx
+import { StyleSheet } from 'react-native';
+import { BlurView } from 'expo-blur';
+import Animated, {
+ interpolate,
+ useAnimatedProps,
+ Extrapolation,
+} from 'react-native-reanimated';
+import {
+ HIDDEN_ANIMATED_INDEX,
+ type BackdropComponentProps,
+} from 'react-native-bottom-sheet-stack';
+
+const AnimatedBlur = Animated.createAnimatedComponent(BlurView);
+
+function BlurBackdrop({ animatedIndex }: BackdropComponentProps) {
+ const animatedProps = useAnimatedProps(() => ({
+ intensity: interpolate(
+ animatedIndex.value,
+ [HIDDEN_ANIMATED_INDEX, 0],
+ [0, 40],
+ Extrapolation.CLAMP
+ ),
+ }));
+
+ return (
+
+ );
+}
+
+```
+
+```tsx
+
+```
+
+:::warning Define the component at module scope
+The component is compared by identity. An inline arrow (`component: (p) => `)
+is a new type on every render, which remounts the backdrop and restarts whatever it animates.
+:::
+
+The component receives the sheet's raw `animatedIndex` (`-1` hidden → `0` fully visible, exported as `HIDDEN_ANIMATED_INDEX` → `0`) rather than a pre-computed opacity, so blur intensity, gradients, or anything else can be driven from the sheet's real position on the UI thread — exactly how the built-in backdrop drives its own fade. That also means a custom component owns its fade entirely: render it visible and it will pop in instead of fading.
+
+```tsx
+type BackdropComponentProps = {
+ sheetId: string;
+ animatedIndex: SharedValue;
+ /** Calls requestClose(sheetId) — onBeforeClose interceptors still run. */
+ close: () => void;
+};
+```
+
+Tap-to-dismiss keeps working around a custom component (the manager's own pressable wraps it); use `pressToDismiss: false` to turn it off, or the `close` prop to wire your own gesture.
+
+## Adapter authors
+
+Third-party adapters reach parity with one hook:
+
+```tsx
+import {
+ useAdapterBackdrop,
+ useBottomSheetContext,
+ type BackdropConfig,
+} from 'react-native-bottom-sheet-stack';
+
+function MyAdapter({ backdrop, ...props }: { backdrop?: BackdropConfig | false }) {
+ const { id } = useBottomSheetContext();
+ useAdapterBackdrop(id, backdrop);
+ // ...
+}
+```
+
+`useSetBackdrop` is the imperative escape hatch for what the prop cannot express: `setBackdrop(id, false)` suppresses the shared backdrop (for an adapter that draws its own overlay), `setBackdrop(id, config)` restyles or replaces it, and `setBackdrop(id, true)` **clears** the override so the sheet falls back to the group default.
+
+## Migration from v2
+
+**`backdrop: false` moved from `open()` options to the adapter:**
+
+```tsx
+// v2
+open(, { backdrop: false });
+
+// v3 — in MySheet's JSX
+
+```
+
+The `open()` option is gone because it duplicated per call site what is really a property of the sheet — the adapter prop declares it once and works identically in inline, portal, and persistent mode. It is also what lets a persistent sheet keep its backdrop across close/re-open cycles, since `open()` no longer writes the field at all.
+
+The capability that moves rather than disappears is **per-open variation** — the same sheet opening with a scrim from one flow and without one from another. Drive it from `params`:
+
+```tsx
+function MySheet() {
+ const { params } = useBottomSheetContext<'filters'>();
+ return {/* … */};
+}
+
+open({ params: { bare: true } });
+```
+
+**`GorhomSheetAdapter` no longer accepts gorhom's `backdropComponent`.** The manager always renders the backdrop, so the two can never stack:
+
+```tsx
+// v2
+
+
+// v3 — the same rendering, but stack-aware
+
+```
+
+The replacement is not a like-for-like swap: a `kind: 'custom'` component receives `{ sheetId, animatedIndex, close }` instead of gorhom's `BottomSheetBackdropProps`, and it renders in the manager's backdrop layer — outside the scale transform, correctly z-indexed within the stack.
diff --git a/docs/docs/built-in-adapters/actions-sheet.md b/docs/docs/built-in-adapters/actions-sheet.md
index 342c1c7..452a3a7 100644
--- a/docs/docs/built-in-adapters/actions-sheet.md
+++ b/docs/docs/built-in-adapters/actions-sheet.md
@@ -64,3 +64,7 @@ and backdrop tap stay enabled, because they route through `onBeforeClose` into
the manager's interceptor — which is what produces the confirmation prompt.
Disabling them natively would make the sheet silently undismissable.
:::
+
+### Backdrop
+
+`backdrop` (`BackdropConfig | false`) configures the manager-rendered backdrop for this sheet — a config overrides the group's `backdrop` default, `false` disables it. The library's own overlay stays forced off either way. See [Backdrop](/backdrop).
diff --git a/docs/docs/built-in-adapters/custom-modal.md b/docs/docs/built-in-adapters/custom-modal.md
index b429cb4..9f62e87 100644
--- a/docs/docs/built-in-adapters/custom-modal.md
+++ b/docs/docs/built-in-adapters/custom-modal.md
@@ -40,3 +40,7 @@ open(, { mode: 'push' });
modalControl.open({ mode: 'push' });
// Both are in the stack — closing the modal returns to the bottom sheet
```
+
+### Backdrop
+
+`backdrop` (`BackdropConfig | false`) configures the manager-rendered backdrop for this sheet — a config overrides the group's `backdrop` default, `false` disables it. The library's own overlay stays forced off either way. See [Backdrop](/backdrop).
diff --git a/docs/docs/built-in-adapters/gorhom.md b/docs/docs/built-in-adapters/gorhom.md
index 2ee84e0..0f1c195 100644
--- a/docs/docs/built-in-adapters/gorhom.md
+++ b/docs/docs/built-in-adapters/gorhom.md
@@ -36,7 +36,9 @@ const MySheet = forwardRef((props, ref) => {
## Props
-`GorhomSheetAdapterProps` extends [`BottomSheetProps`](https://gorhom.dev/react-native-bottom-sheet/props) — the full gorhom prop surface is accepted, nothing is omitted from the type. But the manager owns some of it at runtime.
+`GorhomSheetAdapterProps` extends [`BottomSheetProps`](https://gorhom.dev/react-native-bottom-sheet/props) — the full gorhom prop surface is accepted except `backdropComponent`, which the manager owns. Some other props are owned at runtime.
+
+It adds one prop of its own: `backdrop` (`BackdropConfig | false`) — see [Backdrop](#backdrop).
**Managed by the adapter (your value is ignored or wrapped):**
@@ -47,29 +49,34 @@ const MySheet = forwardRef((props, ref) => {
| `onChange` | Wrapped — reports `handleOpened()` at index `>= 0`, then calls yours |
| `onClose` | Wrapped — calls yours, then reports `handleClosed()` |
| `onAnimate` | Wrapped — reports `handleDismiss()` when animating toward `-1`, then calls yours |
+| `backdropComponent` | Forced to render nothing — the manager draws the backdrop. Not accepted by the type; use `backdrop` instead. See [Backdrop](#backdrop) and [Migration](/backdrop#migration-from-v2) |
**Adapter defaults (yours wins):**
| Prop | Default | Note |
|------|---------|------|
| `animationConfigs` | spring — `stiffness: 400`, `damping: 80`, `mass: 0.7` | |
-| `backdropComponent` | a component returning `null` | See [Backdrop](#backdrop) |
| `enablePanDownToClose` | `true` | Forced to `false` while a [`useOnBeforeClose`](/close-interception) interceptor is blocking dismissal, so the interceptor always gets to run |
## Backdrop
-By default this adapter renders gorhom's `backdropComponent` as `null` so the **stack manager's shared backdrop** (`BottomSheetBackdrop`) is used instead. This is recommended — the manager's backdrop is **stack-aware** (correct opacity across stacked sheets, z-index, scale coordination, cascading tap-to-dismiss), which a per-sheet gorhom backdrop is not.
+The **stack manager's shared backdrop** (`BottomSheetBackdrop`) is always the one rendered: gorhom's own `backdropComponent` is forced to render nothing, and is not part of `GorhomSheetAdapterProps`. Two overlays would otherwise stack into a double-dark layer, and only the manager's is **stack-aware** (correct opacity across stacked sheets, z-index, scale coordination, cascading tap-to-dismiss).
-You **can** override it by passing your own `backdropComponent`, but it's **not recommended** unless you specifically need gorhom's backdrop behavior. When you do, the adapter **automatically disables the manager backdrop** for that sheet so the two never stack:
+Configure it with the `backdrop` prop — restyle it, replace it with your own component (blur, gradients), or turn it off entirely, all without losing that stack-aware behavior:
```tsx
-import { BottomSheetBackdrop as GorhomBackdrop } from '@gorhom/bottom-sheet';
+// Restyle
+
+
+// Replace (receives the sheet's live animatedIndex)
+
-
- {/* ... */}
-;
+// None
+
```
+See [Backdrop](/backdrop) for the full API.
+
## When to Use
- You need snap points, scrollable content, keyboard handling
diff --git a/docs/docs/built-in-adapters/react-native-modal.md b/docs/docs/built-in-adapters/react-native-modal.md
index 2b4c0a1..cf5f19f 100644
--- a/docs/docs/built-in-adapters/react-native-modal.md
+++ b/docs/docs/built-in-adapters/react-native-modal.md
@@ -69,3 +69,7 @@ style. The manager's backdrop is faded with `withTiming` over
`animationInTiming` / `animationOutTiming`, so overriding those keeps the
backdrop in step with the modal instead of letting it run ahead.
:::
+
+### Backdrop
+
+`backdrop` (`BackdropConfig | false`) configures the manager-rendered backdrop for this sheet — a config overrides the group's `backdrop` default, `false` disables it. The library's own overlay stays forced off either way. See [Backdrop](/backdrop).
diff --git a/docs/docs/built-in-adapters/swmansion.md b/docs/docs/built-in-adapters/swmansion.md
index 4b33024..a9f7965 100644
--- a/docs/docs/built-in-adapters/swmansion.md
+++ b/docs/docs/built-in-adapters/swmansion.md
@@ -46,7 +46,7 @@ Software Mansion's sheet is **fully controlled**: it exposes no imperative ref,
| Manager action / event | What the adapter does |
| --- | --- |
-| `expand()` | Sets `index` to `expandedIndex` (defaults to the last detent) |
+| `expand()` | Sets `index` to `backdrop`, `expandedIndex` (defaults to the last detent) |
| `close()` | Sets `index` back to the collapsed detent |
| `onSettle(i)` | Settled on a zero-height detent → reports **closed**; anything else → reports **opened** |
| `onIndexChange(i)` | User swiped down to a zero-height detent → reports **dismiss** (re-snaps up when the sheet is non-dismissable) |
@@ -207,7 +207,7 @@ Defaults are chosen so a bare `detached` looks right: `16` horizontally, and the
The sheet uses the **stack manager's shared backdrop** (`BottomSheetBackdrop`), faded from the sheet's live native position via `onPositionChange`. The manager's backdrop is **stack-aware**: it interpolates opacity correctly across stacked sheets, sits at the right z-index, coordinates with the background scale animation, and participates in cascading tap-to-dismiss.
:::info There is no native-scrim option here
-swmansion's `scrimColor` / `scrimOpacities` only apply to **modal** sheets. The manager always renders inline inside its `QueueItem` layer so the sheet's z-index participates in the stack, and the native scrim is gated on `modal` on both platforms — so it would never paint. The adapter therefore does not accept those props. To render no backdrop at all, pass `backdrop: false` when opening the sheet.
+swmansion's `scrimColor` / `scrimOpacities` only apply to **modal** sheets. The manager always renders inline inside its `QueueItem` layer so the sheet's z-index participates in the stack, and the native scrim is gated on `modal` on both platforms — so it would never paint. The adapter therefore does not accept those props. To render no backdrop at all, pass `backdrop={false}` to the adapter; to restyle or replace it, pass a `BackdropConfig` — see [Backdrop](/backdrop).
:::
## Android back button
diff --git a/docs/docs/close-interception.md b/docs/docs/close-interception.md
index 1ea9953..dec679d 100644
--- a/docs/docs/close-interception.md
+++ b/docs/docs/close-interception.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 8
+sidebar_position: 9
---
# Close Interception
diff --git a/docs/docs/context-preservation.md b/docs/docs/context-preservation.md
index 6a74b01..eef0b15 100644
--- a/docs/docs/context-preservation.md
+++ b/docs/docs/context-preservation.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 6
+sidebar_position: 7
---
# Portal-based API
diff --git a/docs/docs/custom-adapters.md b/docs/docs/custom-adapters.md
index 9a897d1..ada0809 100644
--- a/docs/docs/custom-adapters.md
+++ b/docs/docs/custom-adapters.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 12
+sidebar_position: 13
---
# Building Custom Adapters
@@ -346,21 +346,33 @@ bridge Software Mansion's native sheet. Note that `animatedIndex` is driven
reports the end of an animation and would therefore snap the backdrop to its
final value one animation late.
-### Suppressing the manager backdrop
+### Backdrop
-If your adapter renders a backdrop of its own, suppress the manager's shared one so the two don't stack into a double-dark overlay:
+The manager draws the one shared, stack-aware backdrop for every sheet. Your adapter should **force its library's own overlay off** — two would stack, and only the manager's is stack-aware — and expose a `backdrop` prop so consumers can configure the manager's:
```tsx
-import { useSetBackdrop, useSheetPreventDismiss } from 'react-native-bottom-sheet-stack';
-
-const setBackdrop = useSetBackdrop();
-useEffect(() => {
- if (!hasOwnBackdrop) return;
- setBackdrop(id, false);
- return () => setBackdrop(id, true);
-}, [id, hasOwnBackdrop, setBackdrop]);
+import {
+ useAdapterBackdrop,
+ type AdapterBackdropProps,
+} from 'react-native-bottom-sheet-stack';
+
+interface MyAdapterProps extends AdapterBackdropProps {
+ children: React.ReactNode;
+}
+
+function MyAdapter({ backdrop, children }: MyAdapterProps) {
+ const { id } = useBottomSheetContext();
+ useAdapterBackdrop(id, backdrop);
+ // ...
+}
```
+That is the whole contract, and it is what all five shipped adapters do. `AdapterBackdropProps` supplies the `backdrop?: BackdropConfig | false` prop and its documentation; see [Backdrop](/backdrop) for what consumers can pass.
+
+Do **not** hand-roll the effect. A single effect keyed on the prop clears and rewrites the store on every consumer render, because a JSX object literal is a fresh object each time — `useAdapterBackdrop` splits the value sync from the unmount cleanup precisely to avoid that.
+
+`useSetBackdrop` remains the imperative escape hatch: `setBackdrop(id, false)` suppresses the shared backdrop, `setBackdrop(id, true)` clears the override.
+
`useSheetPreventDismiss(id)` reports whether a `useOnBeforeClose` interceptor is currently blocking dismissal, so you can disable your library's native swipe/tap gestures while it is.
### Libraries Without Separate Dismiss/Close Phases
diff --git a/docs/docs/intro.md b/docs/docs/intro.md
index a87853c..3908341 100644
--- a/docs/docs/intro.md
+++ b/docs/docs/intro.md
@@ -18,6 +18,7 @@ A **library-agnostic** stack manager for bottom sheets and modals in React Nativ
- **Library-Agnostic** — Pluggable [adapter architecture](/adapters) works with any bottom sheet or modal library
- **Stack Navigation** — `push`, `switch`, and `replace` modes for managing multiple sheets
- **Scale Animation** — iOS-style background scaling effect when sheets are stacked
+- **Configurable Backdrop** - Theme the shared stack-aware backdrop per group or per sheet, or replace it with a custom component (e.g. blur)
- **Context Preservation** — Portal-based API that preserves React context in bottom sheets
- **Close Interception** — [`useOnBeforeClose`](/close-interception) to confirm or prevent sheet dismissal
- **Cascading Close** — `closeAll()` with staggered animation, respecting interceptors
diff --git a/docs/docs/persistent-sheets.md b/docs/docs/persistent-sheets.md
index 1bdc2e7..60cab98 100644
--- a/docs/docs/persistent-sheets.md
+++ b/docs/docs/persistent-sheets.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 7
+sidebar_position: 8
---
# Persistent Sheets
diff --git a/docs/docs/type-safe-ids.md b/docs/docs/type-safe-ids.md
index 03ebbe8..8e8df72 100644
--- a/docs/docs/type-safe-ids.md
+++ b/docs/docs/type-safe-ids.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 9
+sidebar_position: 10
---
# Type-Safe Portal IDs & Params
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index 8b508f1..92187df 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -7,6 +7,7 @@ const sidebars: SidebarsConfig = {
'api-comparison',
'navigation-modes',
'scale-animation',
+ 'backdrop',
'context-preservation',
'persistent-sheets',
'close-interception',
diff --git a/example/package.json b/example/package.json
index 9979669..84f81d6 100644
--- a/example/package.json
+++ b/example/package.json
@@ -15,6 +15,7 @@
"@react-native-clipboard/clipboard": "^1.16.3",
"@swmansion/react-native-bottom-sheet": "0.16.2",
"expo": "^54.0.31",
+ "expo-blur": "~15.0.8",
"expo-dev-client": "~6.0.13",
"expo-linking": "~8.0.11",
"expo-status-bar": "~2.0.1",
diff --git a/example/src/components/Sheet.tsx b/example/src/components/Sheet.tsx
index 184df5d..3fc7fbb 100644
--- a/example/src/components/Sheet.tsx
+++ b/example/src/components/Sheet.tsx
@@ -7,6 +7,7 @@ import type { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/typ
import { forwardRef, useCallback, useMemo, type ReactNode } from 'react';
import { View, type StyleProp, type ViewStyle } from 'react-native';
import { GorhomSheetAdapter } from '../../../src/adapters/gorhom-sheet';
+import type { BackdropConfig } from 'react-native-bottom-sheet-stack';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, sharedStyles } from '../styles/theme';
@@ -18,6 +19,7 @@ interface SheetProps {
enableDynamicSizing?: boolean;
scrollable?: boolean;
style?: StyleProp;
+ backdrop?: BackdropConfig | false;
}
export const Sheet = forwardRef(
@@ -29,6 +31,7 @@ export const Sheet = forwardRef(
enableDynamicSizing = true,
scrollable = false,
style,
+ backdrop,
},
ref
) => {
@@ -89,6 +92,7 @@ export const Sheet = forwardRef(
ref={ref}
handleComponent={renderHandle}
backgroundStyle={backgroundStyle}
+ backdrop={backdrop}
>
{scrollable ? (
@@ -107,6 +111,7 @@ export const Sheet = forwardRef(
ref={ref}
handleComponent={renderHandle}
backgroundStyle={backgroundStyle}
+ backdrop={backdrop}
>
{scrollable ? {children} : children}
diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx
index b63eec7..5c7db40 100644
--- a/example/src/screens/HomeScreen.tsx
+++ b/example/src/screens/HomeScreen.tsx
@@ -10,6 +10,7 @@ import { DemoCard, FeatureItem } from '../components';
import {
ActionsSheetDemoContent,
AdapterComparisonContent,
+ BackdropDemo,
ContextComparisonSheet,
ContextSheetPortal,
ForceCloseDemo,
@@ -201,9 +202,16 @@ export function HomeScreen() {
Lifecycle & Groups
+ open(, { scaleBackground: true })}
+ />
+
open(, { scaleBackground: true })}
/>
diff --git a/example/src/sheets/BackdropSheets.tsx b/example/src/sheets/BackdropSheets.tsx
new file mode 100644
index 0000000..70f0b92
--- /dev/null
+++ b/example/src/sheets/BackdropSheets.tsx
@@ -0,0 +1,157 @@
+import type { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
+import { BlurView } from 'expo-blur';
+import { forwardRef } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import {
+ HIDDEN_ANIMATED_INDEX,
+ useBottomSheetContext,
+ useBottomSheetManager,
+ type BackdropComponentProps,
+} from 'react-native-bottom-sheet-stack';
+import Animated, {
+ Extrapolation,
+ interpolate,
+ useAnimatedProps,
+} from 'react-native-reanimated';
+
+import { Badge, Button, SecondaryButton, Sheet } from '../components';
+import { colors, sharedStyles } from '../styles/theme';
+
+/**
+ * The three things a backdrop config can be, stacked on top of each other so
+ * the resolution rules are visible rather than described.
+ *
+ * The provider sets no group default here, so every sheet below shows its own
+ * `backdrop` prop against the built-in `rgba(0,0,0,0.5)` scrim.
+ */
+
+const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
+
+/**
+ * Defined at module scope on purpose: the config is compared by component
+ * identity, so an inline arrow would be a new type every render — remounting
+ * the backdrop and restarting the blur each time.
+ */
+function BlurBackdrop({ animatedIndex }: BackdropComponentProps) {
+ // Driven from the sheet's live position rather than a pre-computed opacity,
+ // so the blur ramps with the sheet on open, close and drag-to-dismiss.
+ const animatedProps = useAnimatedProps(() => ({
+ intensity: interpolate(
+ animatedIndex.value,
+ [HIDDEN_ANIMATED_INDEX, 0],
+ [0, 60],
+ Extrapolation.CLAMP
+ ),
+ }));
+
+ return (
+
+ );
+}
+
+export const BackdropDemo = forwardRef((_, ref) => {
+ const { open } = useBottomSheetManager();
+ const { close } = useBottomSheetContext();
+
+ return (
+
+
+ Backdrop
+
+ This sheet passes no `backdrop` prop, so it gets the built-in
+ `rgba(0,0,0,0.5)` scrim. Push the others on top to compare — each one
+ keeps the same stack-aware layering and tap-to-dismiss.
+
+
+
+
+
+ );
+});
+
+BackdropDemo.displayName = 'BackdropDemo';
+
+export const BlurBackdropSheet = forwardRef((_, ref) => {
+ const { close } = useBottomSheetContext();
+
+ return (
+
+
+ Blur
+
+ An `expo-blur` view replaces the scrim entirely. It reads the sheet's
+ `animatedIndex` itself, so the blur ramps 0 → 60 with the sheet instead
+ of popping in — drag the sheet down slowly to see it follow.
+
+
+
+ );
+});
+
+BlurBackdropSheet.displayName = 'BlurBackdropSheet';
+
+export const TintedBackdropSheet = forwardRef((_, ref) => {
+ const { close } = useBottomSheetContext();
+
+ return (
+
+
+ Tinted scrim
+
+ Only the style changes — the manager still owns the fade, the z-index
+ and the tap. A style is merged over the default, so a brand tint needs
+ one property, not a whole component.
+
+
+
+ );
+});
+
+TintedBackdropSheet.displayName = 'TintedBackdropSheet';
+
+export const StubbornBackdropSheet = forwardRef(
+ (_, ref) => {
+ const { close } = useBottomSheetContext();
+
+ return (
+
+
+ Tap does nothing
+
+ The backdrop still blocks touches from reaching the sheet underneath —
+ it just no longer closes this one. That is the difference from{' '}
+ {'`backdrop={false}`'}, which removes the layer entirely.
+
+
+
+ );
+ }
+);
+
+StubbornBackdropSheet.displayName = 'StubbornBackdropSheet';
diff --git a/example/src/sheets/ForceCloseSheets.tsx b/example/src/sheets/ForceCloseSheets.tsx
index 9fa665b..2fc1197 100644
--- a/example/src/sheets/ForceCloseSheets.tsx
+++ b/example/src/sheets/ForceCloseSheets.tsx
@@ -80,11 +80,9 @@ export const ForceCloseDemo = forwardRef((_, ref) => {
onPress={destroyAll}
/>
- open(, { mode: 'push', backdrop: false })
- }
+ onPress={() => open(, { mode: 'push' })}
/>
((_, ref) => {
const { close } = useBottomSheetContext();
return (
-
-
+
+ No backdrop
- The manager rendered no dim layer for this sheet, so the one below stays
- at full brightness. Everything else — stacking, scale, close — is
+ The adapter opted out of the manager's dim layer, so the sheet below
+ stays at full brightness. Everything else — stacking, scale, close — is
unchanged.
diff --git a/example/src/sheets/index.ts b/example/src/sheets/index.ts
index 3e169f4..d093eb4 100644
--- a/example/src/sheets/index.ts
+++ b/example/src/sheets/index.ts
@@ -30,5 +30,11 @@ export {
} from './CloseInterceptionSheets';
export { PartialCloseDemo } from './PartialCloseSheets';
export { ForceCloseDemo, NoBackdropSheet } from './ForceCloseSheets';
+export {
+ BackdropDemo,
+ BlurBackdropSheet,
+ TintedBackdropSheet,
+ StubbornBackdropSheet,
+} from './BackdropSheets';
export { GroupASheet, GroupBSheet } from './GroupIsolationSheets';
export { StatusDemoPanel, StatusDemoSheet } from './SheetStatusSheets';
diff --git a/jest.setup.ts b/jest.setup.ts
index 7614860..aa3fb95 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -12,6 +12,9 @@
* narrower scope instead of relying on this.
*/
jest.mock('react-native-reanimated', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+
const makeMutable = (initial: T) => {
let current = initial;
return {
@@ -33,7 +36,13 @@ jest.mock('react-native-reanimated', () => {
return {
__esModule: true,
- default: {},
+ // `Animated.View` passes through to a plain RN `View`, so a component that
+ // renders one (the backdrop, `ScaleWrapper`) can be rendered in a test and
+ // its resolved styles asserted on.
+ default: {
+ View: (props: Record) =>
+ React.createElement(View, props),
+ },
makeMutable,
// Timing/spring helpers resolve to their target value: tests assert where a
// value lands, not how it travels.
diff --git a/src/BottomSheetBackdrop.tsx b/src/BottomSheetBackdrop.tsx
index d176490..b76501c 100644
--- a/src/BottomSheetBackdrop.tsx
+++ b/src/BottomSheetBackdrop.tsx
@@ -5,7 +5,10 @@ import Animated, {
useAnimatedStyle,
} from 'react-native-reanimated';
import { getAnimatedIndex, HIDDEN_ANIMATED_INDEX } from './animatedRegistry';
+import { resolveBackdrop } from './backdrop.resolve';
+import { useBottomSheetManagerContext } from './BottomSheetManager.context';
import { requestClose } from './bottomSheetCoordinator';
+import { useSheetBackdrop } from './store';
interface BottomSheetBackdropProps {
sheetId: string;
@@ -13,6 +16,8 @@ interface BottomSheetBackdropProps {
export function BottomSheetBackdrop({ sheetId }: BottomSheetBackdropProps) {
const animatedIndex = getAnimatedIndex(sheetId);
+ const { backdrop: groupBackdrop } = useBottomSheetManagerContext();
+ const storedBackdrop = useSheetBackdrop(sheetId);
if (!animatedIndex) {
throw new Error('animatedIndex must be defined in BottomSheetBackdrop');
@@ -32,14 +37,38 @@ export function BottomSheetBackdrop({ sheetId }: BottomSheetBackdropProps) {
return { opacity };
});
+ const backdrop = resolveBackdrop(storedBackdrop, groupBackdrop);
+ const close = () => requestClose(sheetId);
+
+ // The Pressable stays even with tap-to-dismiss off — a backdrop blocks
+ // touches from reaching the content beneath it either way. (`backdrop={false}`
+ // removes the shield too; that is the difference between the two.)
return (
requestClose(sheetId)}
+ onPress={backdrop.pressToDismiss ? close : undefined}
>
-
+ {backdrop.kind === 'custom' ? (
+ // A custom component owns its own fade off `animatedIndex`; applying
+ // the built-in opacity on top would double-fade it.
+
+ ) : (
+
+ )}
);
}
diff --git a/src/BottomSheetManager.context.tsx b/src/BottomSheetManager.context.tsx
index f3d38b2..4119381 100644
--- a/src/BottomSheetManager.context.tsx
+++ b/src/BottomSheetManager.context.tsx
@@ -1,9 +1,11 @@
import React from 'react';
+import type { BackdropConfig } from './backdrop.types';
import type { ScaleConfig } from './useScaleAnimation';
export interface BottomSheetManagerContextValue {
groupId: string;
scaleConfig?: ScaleConfig;
+ backdrop?: BackdropConfig | false;
}
export const BottomSheetManagerContext =
diff --git a/src/BottomSheetManager.provider.tsx b/src/BottomSheetManager.provider.tsx
index deeffc0..48270dc 100644
--- a/src/BottomSheetManager.provider.tsx
+++ b/src/BottomSheetManager.provider.tsx
@@ -2,19 +2,29 @@ import { type PropsWithChildren } from 'react';
import { PortalProvider } from 'react-native-teleport';
import { BottomSheetManagerContext } from './BottomSheetManager.context';
+import type { BackdropConfig } from './backdrop.types';
import type { ScaleConfig } from './useScaleAnimation';
interface ProviderProps extends PropsWithChildren {
id: string;
scaleConfig?: ScaleConfig;
+ /**
+ * The group's default backdrop; `false` gives the whole group no backdrop.
+ *
+ * Same name and type as the adapters' `backdrop` prop, which overrides it per
+ * sheet — the sheet's visual choice wins atomically; when both are `styled`
+ * the styles compose, and `pressToDismiss` resolves per field.
+ */
+ backdrop?: BackdropConfig | false;
}
export function BottomSheetManagerProvider({
id,
scaleConfig,
+ backdrop,
children,
}: ProviderProps) {
- const value = { groupId: id, scaleConfig };
+ const value = { groupId: id, scaleConfig, backdrop };
return (
diff --git a/src/QueueItem.tsx b/src/QueueItem.tsx
index 8d00833..915c418 100644
--- a/src/QueueItem.tsx
+++ b/src/QueueItem.tsx
@@ -6,8 +6,10 @@ import { PortalHost } from 'react-native-teleport';
import { cleanupAnimatedIndex, getAnimatedIndex } from './animatedRegistry';
import { BottomSheetContext } from './BottomSheet.context';
+import { isBackdropEnabled } from './backdrop.resolve';
+import { useBottomSheetManagerContext } from './BottomSheetManager.context';
import {
- useSheetBackdrop,
+ useSheetBackdropOverride,
useSheetContent,
useSheetPortalSession,
useSheetUsePortal,
@@ -31,7 +33,11 @@ export const QueueItem = memo(function QueueItem({
const content = useSheetContent(id);
const usePortal = useSheetUsePortal(id);
const portalSession = useSheetPortalSession(id);
- const backdrop = useSheetBackdrop(id);
+ const { backdrop: groupBackdrop } = useBottomSheetManagerContext();
+ const backdropEnabled = isBackdropEnabled(
+ useSheetBackdropOverride(id),
+ groupBackdrop
+ );
const { width, height } = useSafeAreaFrame();
@@ -59,7 +65,7 @@ export const QueueItem = memo(function QueueItem({
return (
<>
- {isActive && backdrop !== false && (
+ {isActive && backdropEnabled && (
+ store().setBackdrop(id, value);
+
+const backdropOf = (id: string) => store().sheetsById[id]?.backdrop;
+
+const styled = (
+ backgroundColor: string,
+ rest?: Partial>
+): BackdropConfig => ({ kind: 'styled', style: { backgroundColor }, ...rest });
+
+describe('setBackdrop', () => {
+ beforeEach(() => {
+ store().open(portal('a'));
+ });
+
+ it('stores a config, false, and clears on true', () => {
+ const config = styled('red');
+
+ setBackdrop('a', config);
+ expect(backdropOf('a')).toBe(config);
+
+ setBackdrop('a', false);
+ expect(backdropOf('a')).toBe(false);
+
+ setBackdrop('a', true);
+ expect(backdropOf('a')).toBeUndefined();
+ });
+
+ // The boolean suppress/restore cycle an adapter drives (`false` while it
+ // draws its own backdrop, `true` to hand it back) must round-trip to "no
+ // override", not to a stored `true`.
+ it('round-trips the boolean suppress/restore cycle', () => {
+ setBackdrop('a', false);
+ setBackdrop('a', true);
+ expect(backdropOf('a')).toBeUndefined();
+ });
+
+ // Adapters re-apply their `backdrop` prop with a fresh object literal on
+ // every consumer render; without value equality each render would wake every
+ // store subscriber.
+ it('does not notify subscribers for a value-equal config', () => {
+ const listener = jest.fn();
+ const unsubscribe = useBottomSheetStore.subscribe(listener);
+
+ setBackdrop('a', { kind: 'styled', style: { backgroundColor: 'red' } });
+ setBackdrop('a', { kind: 'styled', style: { backgroundColor: 'red' } });
+ setBackdrop('a', true);
+ setBackdrop('a', true);
+
+ // One write for the config, one for the clear — the value-equal repeats
+ // must not produce a state change.
+ expect(listener).toHaveBeenCalledTimes(2);
+ unsubscribe();
+ });
+
+ it('notifies subscribers when the config actually changes', () => {
+ const listener = jest.fn();
+ const unsubscribe = useBottomSheetStore.subscribe(listener);
+
+ setBackdrop('a', styled('red'));
+ setBackdrop('a', styled('blue'));
+ setBackdrop('a', { ...styled('blue'), pressToDismiss: false });
+
+ expect(listener).toHaveBeenCalledTimes(3);
+ unsubscribe();
+ });
+
+ it('distinguishes custom configs by component identity', () => {
+ const listener = jest.fn();
+ const A = () => null;
+ const B = () => null;
+
+ const unsubscribe = useBottomSheetStore.subscribe(listener);
+ setBackdrop('a', { kind: 'custom', component: A });
+ setBackdrop('a', { kind: 'custom', component: A });
+ setBackdrop('a', { kind: 'custom', component: B });
+
+ expect(listener).toHaveBeenCalledTimes(2);
+ unsubscribe();
+ });
+
+ // Walking only one style's keys returned `true` here — the count matched
+ // because the explicit `undefined` counted as a key — and the restyle was
+ // silently dropped.
+ it('sees a change when one style carries an explicit undefined', () => {
+ setBackdrop('a', {
+ kind: 'styled',
+ style: { backgroundColor: 'red', opacity: undefined },
+ });
+ setBackdrop('a', {
+ kind: 'styled',
+ style: { backgroundColor: 'red', borderRadius: 20 },
+ });
+
+ expect(backdropOf('a')).toMatchObject({
+ style: { backgroundColor: 'red', borderRadius: 20 },
+ });
+ });
+
+ it('treats an explicit undefined as the absence of the key', () => {
+ const listener = jest.fn();
+ setBackdrop('a', { kind: 'styled', style: { backgroundColor: 'red' } });
+
+ const unsubscribe = useBottomSheetStore.subscribe(listener);
+ setBackdrop('a', {
+ kind: 'styled',
+ style: { backgroundColor: 'red', opacity: undefined },
+ });
+
+ expect(listener).not.toHaveBeenCalled();
+ unsubscribe();
+ });
+
+ it('ignores writes for unknown sheets', () => {
+ const listener = jest.fn();
+ const unsubscribe = useBottomSheetStore.subscribe(listener);
+
+ setBackdrop('missing', false);
+
+ expect(listener).not.toHaveBeenCalled();
+ unsubscribe();
+ });
+});
+
+describe('backdrop across the sheet lifecycle', () => {
+ // `open()` no longer carries a backdrop field, so a persistent sheet's
+ // adapter-set config must survive a full close/re-open cycle untouched.
+ it('survives a persistent sheet re-open', () => {
+ store().mount({ id: 'p', groupId: 'g1' });
+ const config = styled('red');
+ setBackdrop('p', config);
+
+ store().open(portal('p'));
+ store().markOpen('p');
+ store().startClosing('p');
+ store().finishClosing('p');
+ expect(statusOf('p')).toBe('hidden');
+
+ store().open(portal('p'));
+ expect(backdropOf('p')).toBe(config);
+ });
+});
+
+describe('useAdapterBackdrop', () => {
+ beforeEach(() => {
+ store().open(portal('a'));
+ });
+
+ it('applies the prop value and clears it on unmount', () => {
+ const config = styled('red');
+ const { unmount, rerender } = renderHook(
+ ({ value }: { value: BackdropConfig | false | undefined }) =>
+ useAdapterBackdrop('a', value),
+ { initialProps: { value: config as BackdropConfig | false | undefined } }
+ );
+
+ expect(backdropOf('a')).toBe(config);
+
+ rerender({ value: false });
+ expect(backdropOf('a')).toBe(false);
+
+ unmount();
+ expect(backdropOf('a')).toBeUndefined();
+ });
+
+ // The hook's whole reason to exist. A single effect with a cleanup would
+ // clear-and-rewrite here, because a consumer's JSX rebuilds the literal on
+ // every render.
+ it('does not touch the store when the prop is re-created value-equal', () => {
+ const value = (): BackdropConfig => styled('red');
+ const { rerender } = renderHook(
+ ({ v }: { v: BackdropConfig }) => useAdapterBackdrop('a', v),
+ { initialProps: { v: value() } }
+ );
+
+ const listener = jest.fn();
+ const unsubscribe = useBottomSheetStore.subscribe(listener);
+ rerender({ v: value() });
+ rerender({ v: value() });
+
+ expect(listener).not.toHaveBeenCalled();
+ unsubscribe();
+ });
+
+ // Removing the prop must fall the sheet back to the group default, not
+ // freeze the last value.
+ it('clears the override when the prop becomes undefined', () => {
+ const { rerender } = renderHook(
+ ({ value }: { value: BackdropConfig | false | undefined }) =>
+ useAdapterBackdrop('a', value),
+ { initialProps: { value: false as BackdropConfig | false | undefined } }
+ );
+ expect(backdropOf('a')).toBe(false);
+
+ rerender({ value: undefined });
+ expect(backdropOf('a')).toBeUndefined();
+ });
+});
+
+describe('backdrop enablement', () => {
+ beforeEach(() => {
+ store().open(portal('a'));
+ });
+
+ // `QueueItem` is memoized because every host render rebuilds its children;
+ // subscribing it to the config would re-render the whole sheet layer on
+ // every restyle.
+ it('re-renders on on/off but not on restyle', () => {
+ let renders = 0;
+ renderHook(() => {
+ renders += 1;
+ return useSheetBackdropOverride('a');
+ });
+ const afterMount = renders;
+
+ act(() => setBackdrop('a', styled('red')));
+ act(() => setBackdrop('a', styled('blue')));
+ const afterRestyle = renders;
+
+ act(() => setBackdrop('a', false));
+
+ expect(afterRestyle).toBe(afterMount + 1); // 'inherit' → 'own', then flat
+ expect(renders).toBe(afterRestyle + 1); // 'own' → 'off'
+ });
+
+ it('answers inherit from the group, and lets the sheet override it', () => {
+ expect(isBackdropEnabled('inherit', undefined)).toBe(true);
+ expect(isBackdropEnabled('inherit', false)).toBe(false);
+ expect(isBackdropEnabled('own', false)).toBe(true);
+ expect(isBackdropEnabled('off', undefined)).toBe(false);
+ });
+});
+
+describe('BottomSheetBackdrop rendering', () => {
+ const renderBackdrop = (groupConfig?: BackdropConfig | false) =>
+ render(, {
+ wrapper: ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ ),
+ });
+
+ const press = async (result: ReturnType) => {
+ await act(async () => {
+ fireEvent.press(result.getByTestId('bottom-sheet-backdrop-a'));
+ });
+ };
+
+ beforeEach(() => {
+ store().open(portal('a'));
+ store().markOpen('a');
+ });
+
+ /** The resolved style of the scrim itself — the Pressable's only child. */
+ const scrimStyle = (result: ReturnType) => {
+ const scrim = result.getByTestId('bottom-sheet-backdrop-a').children[0];
+ if (typeof scrim === 'string') throw new Error('expected the scrim view');
+ return StyleSheet.flatten(
+ scrim.props.style as Parameters[0]
+ );
+ };
+
+ it('keeps the default scrim with no config anywhere', () => {
+ expect(scrimStyle(renderBackdrop())).toMatchObject({
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ });
+ });
+
+ it('layers group style over the default', () => {
+ expect(scrimStyle(renderBackdrop(styled('red')))).toMatchObject({
+ backgroundColor: 'red',
+ });
+ });
+
+ it('composes sheet style over group style when both are styled', () => {
+ act(() => setBackdrop('a', styled('blue')));
+ expect(scrimStyle(renderBackdrop(styled('red')))).toMatchObject({
+ backgroundColor: 'blue',
+ });
+ });
+
+ // The manager drives the fade off `animatedIndex`; a config style carrying
+ // its own `opacity` must restyle the scrim without replacing that fade.
+ it('keeps the animated opacity over a static one in the config', () => {
+ jest.mocked(useAnimatedStyle).mockReturnValueOnce({ opacity: 0.99 });
+ act(() =>
+ setBackdrop('a', {
+ kind: 'styled',
+ style: { backgroundColor: 'black', opacity: 0.3 },
+ })
+ );
+
+ expect(scrimStyle(renderBackdrop())).toMatchObject({
+ backgroundColor: 'black',
+ opacity: 0.99,
+ });
+ });
+
+ it('renders a custom component with the sheet id and live animated index', () => {
+ const received: BackdropComponentProps[] = [];
+ const Custom = (props: BackdropComponentProps) => {
+ received.push(props);
+ return null;
+ };
+
+ act(() => setBackdrop('a', { kind: 'custom', component: Custom }));
+ renderBackdrop(styled('red'));
+
+ expect(received[0]?.sheetId).toBe('a');
+ expect(received[0]?.animatedIndex).toBe(getAnimatedIndex('a'));
+ expect(typeof received[0]?.close).toBe('function');
+ });
+
+ // The visual choice is atomic: a sheet-level styled config must fully
+ // replace a group-level custom component, not layer under it.
+ it('does not render the group custom component when the sheet asks for styled', () => {
+ const Custom = jest.fn(() => null);
+
+ act(() => setBackdrop('a', styled('blue')));
+ renderBackdrop({ kind: 'custom', component: Custom });
+
+ expect(Custom).not.toHaveBeenCalled();
+ });
+
+ it('closes on press through the interceptor path', async () => {
+ const result = renderBackdrop();
+
+ await press(result);
+
+ expect(statusOf('a')).toBe('closing');
+ });
+
+ it('does not close when an interceptor refuses', async () => {
+ setOnBeforeClose('a', ({ onCancel }) => onCancel());
+ const result = renderBackdrop();
+
+ await press(result);
+
+ expect(statusOf('a')).toBe('open');
+ });
+
+ it('ignores presses when pressToDismiss is false', async () => {
+ act(() => setBackdrop('a', { ...styled('blue'), pressToDismiss: false }));
+ const result = renderBackdrop();
+
+ await press(result);
+
+ expect(statusOf('a')).toBe('open');
+ });
+
+ // `pressToDismiss` resolves per field: a sheet that only restyles must still
+ // inherit the group's decision to disable tap-to-dismiss.
+ it('falls back to the group pressToDismiss when the sheet does not set it', async () => {
+ act(() => setBackdrop('a', styled('blue')));
+ const result = renderBackdrop({
+ ...styled('red'),
+ pressToDismiss: false,
+ });
+
+ await press(result);
+
+ expect(statusOf('a')).toBe('open');
+ });
+});
diff --git a/src/adapter.types.ts b/src/adapter.types.ts
index e1cc946..d0815e0 100644
--- a/src/adapter.types.ts
+++ b/src/adapter.types.ts
@@ -1,5 +1,7 @@
import type { RefObject } from 'react';
+import type { BackdropConfig } from './backdrop.types';
+
/**
* Minimal ref interface for controlling a sheet/modal/overlay.
* Every adapter must implement these two methods.
@@ -33,3 +35,21 @@ export interface SheetAdapterEvents {
}
export type SheetRef = RefObject;
+
+/**
+ * The backdrop prop every shipped adapter exposes; mix it into a third-party
+ * adapter's props so it reads the same way.
+ *
+ * The manager always draws the one shared, stack-aware backdrop, so an adapter
+ * never re-exposes its own library's overlay — two would stack, and only the
+ * manager's participates in the stack. This is an adapter's only backdrop knob.
+ * Apply it with `useAdapterBackdrop(id, backdrop)`.
+ */
+export interface AdapterBackdropProps {
+ /**
+ * This sheet's backdrop: a `BackdropConfig` overrides the group's `backdrop`
+ * default, `false` disables it (and with it the layer that blocks touches
+ * from reaching whatever sits beneath the sheet).
+ */
+ backdrop?: BackdropConfig | false;
+}
diff --git a/src/adapters/actions-sheet/ActionsSheetAdapter.tsx b/src/adapters/actions-sheet/ActionsSheetAdapter.tsx
index 8386a14..912ea94 100644
--- a/src/adapters/actions-sheet/ActionsSheetAdapter.tsx
+++ b/src/adapters/actions-sheet/ActionsSheetAdapter.tsx
@@ -6,9 +6,13 @@ import type {
ActionSheetRef,
} from 'react-native-actions-sheet';
-import type { SheetAdapterRef } from '../../adapter.types';
+import type {
+ AdapterBackdropProps,
+ SheetAdapterRef,
+} from '../../adapter.types';
import { useSheetPreventDismiss } from '../../store';
import { createSheetEventHandlers } from '../../bottomSheetCoordinator';
+import { useAdapterBackdrop } from '../../useAdapterBackdrop';
import { useAdapterRef } from '../../useAdapterRef';
import { useAnimatedIndex } from '../../useAnimatedIndex';
import { useBottomSheetContext } from '../../useBottomSheetContext';
@@ -35,14 +39,15 @@ const ActionSheet = require('react-native-actions-sheet')
*/
export interface ActionsSheetAdapterProps
extends Omit<
- ActionSheetProps,
- | 'isModal'
- | 'defaultOverlayOpacity'
- | 'onOpen'
- | 'onClose'
- | 'onBeforeClose'
- | 'children'
- > {
+ ActionSheetProps,
+ | 'isModal'
+ | 'defaultOverlayOpacity'
+ | 'onOpen'
+ | 'onClose'
+ | 'onBeforeClose'
+ | 'children'
+ >,
+ AdapterBackdropProps {
children: React.ReactNode;
}
@@ -62,13 +67,20 @@ export const ActionsSheetAdapter = React.forwardRef<
ActionsSheetAdapterProps
>(
(
- { children, openAnimationConfig, closeAnimationConfig, ...sheetProps },
+ {
+ children,
+ openAnimationConfig,
+ closeAnimationConfig,
+ backdrop,
+ ...sheetProps
+ },
forwardedRef
) => {
const { id } = useBottomSheetContext();
const ref = useAdapterRef(forwardedRef);
const animatedIndex = useAnimatedIndex();
const preventDismiss = useSheetPreventDismiss(id);
+ useAdapterBackdrop(id, backdrop);
const actionSheetRef = useRef(null);
diff --git a/src/adapters/custom-modal/CustomModalAdapter.tsx b/src/adapters/custom-modal/CustomModalAdapter.tsx
index 611ca60..1918fbd 100644
--- a/src/adapters/custom-modal/CustomModalAdapter.tsx
+++ b/src/adapters/custom-modal/CustomModalAdapter.tsx
@@ -9,8 +9,12 @@ import Animated, {
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
-import type { SheetAdapterRef } from '../../adapter.types';
+import type {
+ AdapterBackdropProps,
+ SheetAdapterRef,
+} from '../../adapter.types';
import { createSheetEventHandlers } from '../../bottomSheetCoordinator';
+import { useAdapterBackdrop } from '../../useAdapterBackdrop';
import { useAdapterRef } from '../../useAdapterRef';
import { useAnimatedIndex } from '../../useAnimatedIndex';
import { useBackHandler } from '../../useBackHandler';
@@ -20,7 +24,7 @@ const ANIMATION_DURATION = 300;
const ZOOM_INITIAL_SCALE = 0.85;
-export interface ModalAdapterProps {
+export interface ModalAdapterProps extends AdapterBackdropProps {
children: React.ReactNode;
contentContainerStyle?: StyleProp;
}
@@ -28,10 +32,11 @@ export interface ModalAdapterProps {
export const CustomModalAdapter = React.forwardRef<
SheetAdapterRef,
ModalAdapterProps
->(({ children, contentContainerStyle }, forwardedRef) => {
+>(({ children, contentContainerStyle, backdrop }, forwardedRef) => {
const { id } = useBottomSheetContext();
const ref = useAdapterRef(forwardedRef);
const animatedIndex = useAnimatedIndex();
+ useAdapterBackdrop(id, backdrop);
const [rendered, setRendered] = useState(false);
const [open, setOpen] = useState(false);
diff --git a/src/adapters/gorhom-sheet/GorhomSheetAdapter.tsx b/src/adapters/gorhom-sheet/GorhomSheetAdapter.tsx
index 5f372c1..b1e6ec6 100644
--- a/src/adapters/gorhom-sheet/GorhomSheetAdapter.tsx
+++ b/src/adapters/gorhom-sheet/GorhomSheetAdapter.tsx
@@ -3,20 +3,35 @@ import BottomSheetOriginal, {
type BottomSheetProps,
} from '@gorhom/bottom-sheet';
import type { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
-import React, { useEffect, useImperativeHandle, useRef } from 'react';
+import React, { useImperativeHandle, useRef } from 'react';
import { useAnimatedReaction } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
-import type { SheetAdapterRef } from '../../adapter.types';
-import { useSetBackdrop, useSheetPreventDismiss } from '../../store';
+import type {
+ AdapterBackdropProps,
+ SheetAdapterRef,
+} from '../../adapter.types';
+import { useSheetPreventDismiss } from '../../store';
import { createSheetEventHandlers } from '../../bottomSheetCoordinator';
import { useBottomSheetDefaultIndex } from '../../BottomSheetDefaultIndex.context';
+import { useAdapterBackdrop } from '../../useAdapterBackdrop';
import { useAdapterRef } from '../../useAdapterRef';
import { useAnimatedIndex } from '../../useAnimatedIndex';
import { useBackHandler } from '../../useBackHandler';
import { useBottomSheetContext } from '../../useBottomSheetContext';
-export interface GorhomSheetAdapterProps extends BottomSheetProps {}
+/**
+ * Props for {@link GorhomSheetAdapter}.
+ *
+ * Forwards the full prop surface of `@gorhom/bottom-sheet`, except the props
+ * the stack manager owns — among them `backdropComponent`, which is forced to
+ * render nothing: the manager draws the one shared, stack-aware backdrop for
+ * every sheet, and a per-sheet gorhom backdrop would stack a second overlay on
+ * top of it. Configure it through {@link backdrop} instead.
+ */
+export interface GorhomSheetAdapterProps
+ extends Omit,
+ AdapterBackdropProps {}
const nullBackdrop = () => null;
@@ -31,7 +46,7 @@ export const GorhomSheetAdapter = React.forwardRef<
onChange,
onClose,
enablePanDownToClose = true,
- backdropComponent = nullBackdrop,
+ backdrop,
animatedIndex: externalAnimatedIndex,
...props
},
@@ -42,19 +57,10 @@ export const GorhomSheetAdapter = React.forwardRef<
const contextAnimatedIndex = useAnimatedIndex();
const defaultIndex = useBottomSheetDefaultIndex();
const preventDismiss = useSheetPreventDismiss(id);
- const setBackdrop = useSetBackdrop();
+ useAdapterBackdrop(id, backdrop);
const gorhomRef = useRef(null);
- // Passing a custom backdrop means this sheet owns its backdrop, so suppress
- // the manager's shared one to avoid stacking two into a double-dark overlay.
- const usesCustomBackdrop = backdropComponent !== nullBackdrop;
- useEffect(() => {
- if (!usesCustomBackdrop) return;
- setBackdrop(id, false);
- return () => setBackdrop(id, true);
- }, [id, usesCustomBackdrop, setBackdrop]);
-
const { handleDismiss, handleOpened, handleClosed } =
createSheetEventHandlers(id);
@@ -128,7 +134,9 @@ export const GorhomSheetAdapter = React.forwardRef<
onChange={wrappedOnChange}
onClose={wrappedOnClose}
onAnimate={wrappedOnAnimate}
- backdropComponent={backdropComponent}
+ // The manager owns the backdrop; gorhom's own must render nothing so
+ // the two never stack into a double-dark overlay.
+ backdropComponent={nullBackdrop}
enablePanDownToClose={preventDismiss ? false : enablePanDownToClose}
>
{children}
diff --git a/src/adapters/react-native-modal/ReactNativeModalAdapter.tsx b/src/adapters/react-native-modal/ReactNativeModalAdapter.tsx
index 593b3d7..00652a7 100644
--- a/src/adapters/react-native-modal/ReactNativeModalAdapter.tsx
+++ b/src/adapters/react-native-modal/ReactNativeModalAdapter.tsx
@@ -3,9 +3,13 @@ import { withTiming } from 'react-native-reanimated';
import type { ModalProps } from 'react-native-modal';
-import type { SheetAdapterRef } from '../../adapter.types';
+import type {
+ AdapterBackdropProps,
+ SheetAdapterRef,
+} from '../../adapter.types';
import { useSheetPreventDismiss } from '../../store';
import { createSheetEventHandlers } from '../../bottomSheetCoordinator';
+import { useAdapterBackdrop } from '../../useAdapterBackdrop';
import { useAdapterRef } from '../../useAdapterRef';
import { useAnimatedIndex } from '../../useAnimatedIndex';
import { useBottomSheetContext } from '../../useBottomSheetContext';
@@ -39,18 +43,19 @@ export interface ReactNativeModalAdapterProps
// required and fills them from `defaultProps` — as a consumer-facing type
// every one of them is optional.
extends Partial<
- Omit<
- ModalProps,
- | 'isVisible'
- | 'coverScreen'
- | 'hasBackdrop'
- | 'onModalShow'
- | 'onModalHide'
- | 'onBackButtonPress'
- | 'onSwipeComplete'
- | 'children'
- >
- > {
+ Omit<
+ ModalProps,
+ | 'isVisible'
+ | 'coverScreen'
+ | 'hasBackdrop'
+ | 'onModalShow'
+ | 'onModalHide'
+ | 'onBackButtonPress'
+ | 'onSwipeComplete'
+ | 'children'
+ >
+ >,
+ AdapterBackdropProps {
children: React.ReactNode;
}
@@ -76,6 +81,7 @@ export const ReactNativeModalAdapter = React.forwardRef<
children,
animationInTiming = DEFAULT_ANIMATION_IN_TIMING,
animationOutTiming = DEFAULT_ANIMATION_OUT_TIMING,
+ backdrop,
...modalProps
},
forwardedRef
@@ -84,6 +90,7 @@ export const ReactNativeModalAdapter = React.forwardRef<
const ref = useAdapterRef(forwardedRef);
const animatedIndex = useAnimatedIndex();
const preventDismiss = useSheetPreventDismiss(id);
+ useAdapterBackdrop(id, backdrop);
const [isVisible, setIsVisible] = useState(false);
const { handleDismiss, handleOpened, handleClosed } =
diff --git a/src/adapters/swmansion/SwmansionSheetAdapter.tsx b/src/adapters/swmansion/SwmansionSheetAdapter.tsx
index 5fe78f7..69b85f9 100644
--- a/src/adapters/swmansion/SwmansionSheetAdapter.tsx
+++ b/src/adapters/swmansion/SwmansionSheetAdapter.tsx
@@ -22,10 +22,14 @@ import type {
PositionChangeEventData,
} from '@swmansion/react-native-bottom-sheet';
-import type { SheetAdapterRef } from '../../adapter.types';
+import type {
+ AdapterBackdropProps,
+ SheetAdapterRef,
+} from '../../adapter.types';
import { useBottomSheetDefaultIndex } from '../../BottomSheetDefaultIndex.context';
import { useSheetPreventDismiss } from '../../store';
import { createSheetEventHandlers } from '../../bottomSheetCoordinator';
+import { useAdapterBackdrop } from '../../useAdapterBackdrop';
import { useAdapterRef } from '../../useAdapterRef';
import { useAnimatedIndex } from '../../useAnimatedIndex';
import { useBackHandler } from '../../useBackHandler';
@@ -88,8 +92,8 @@ export interface SwmansionHandleConfig {
* `BottomSheetBackdrop`, faded from the sheet's live native position. The native
* swmansion scrim is not an option here: it is gated on `modal` sheets, and the
* manager always renders inline inside its `QueueItem` layer so the sheet's
- * z-index participates in the stack. Use `backdrop: false` on `open()` if you
- * want no backdrop at all.
+ * z-index participates in the stack. Use the {@link backdrop} prop to restyle
+ * or replace it, or `backdrop={false}` for no backdrop at all.
*
* On top of the native surface the adapter layers a set of **opt-in
* conveniences** ({@link handle}, {@link fullHeight}, {@link fillContent},
@@ -98,14 +102,15 @@ export interface SwmansionHandleConfig {
* `` behaves like the raw native sheet.
*/
export interface SwmansionSheetAdapterProps
- extends Omit<
- BottomSheetProps,
- | 'index'
- | 'animateIn'
- | 'onPositionChange'
- | 'wrapNativeView'
- | 'onIndexChange'
- > {
+ extends AdapterBackdropProps,
+ Omit<
+ BottomSheetProps,
+ | 'index'
+ | 'animateIn'
+ | 'onPositionChange'
+ | 'wrapNativeView'
+ | 'onIndexChange'
+ > {
/**
* Index into `detents` the sheet expands to when opened.
*
@@ -339,6 +344,7 @@ export const SwmansionSheetAdapter = React.forwardRef<
(
{
children,
+ backdrop,
detents: detentsProp,
expandedIndex,
onIndexChange,
@@ -363,6 +369,7 @@ export const SwmansionSheetAdapter = React.forwardRef<
const animatedIndex = useAnimatedIndex();
const preventDismiss = useSheetPreventDismiss(id);
const insets = useSafeAreaInsets();
+ useAdapterBackdrop(id, backdrop);
// Forced on natively and compensated here instead: the native subtraction
// is derived from where the host sits in the window, so an ancestor
diff --git a/src/backdrop.equality.ts b/src/backdrop.equality.ts
new file mode 100644
index 0000000..5612cfd
--- /dev/null
+++ b/src/backdrop.equality.ts
@@ -0,0 +1,41 @@
+import { StyleSheet } from 'react-native';
+
+import type { BackdropConfig } from './backdrop.types';
+
+/**
+ * Value equality for a sheet's backdrop override.
+ *
+ * Adapters re-apply their `backdrop` prop from an effect and a JSX object
+ * literal is fresh on every consumer render, so `setBackdrop` compares by value
+ * and skips the write — otherwise a consumer re-render would patch the store
+ * and re-render `BottomSheetBackdrop` each time.
+ *
+ * Styles are compared as flattened JSON, which is key-order sensitive: a
+ * literal whose key order varied between renders would cost one redundant
+ * write, never a wrong render. Comparing key-by-key instead is not worth it —
+ * it has to reach for `JSON.stringify` on nested values (`transform`,
+ * `shadowOffset`) anyway, and walking only one side's keys silently returns
+ * `true` when one style carries an explicit `undefined` where the other
+ * carries a real value, which *would* be a wrong render.
+ *
+ * Lives beside the backdrop rather than in `store/helpers.ts`: those are pure
+ * stack operations, free of React Native imports.
+ */
+export function backdropValuesEqual(
+ a: BackdropConfig | false | undefined,
+ b: BackdropConfig | false | undefined
+): boolean {
+ if (a === b) return true;
+ if (!a || !b) return false;
+ if (a.kind !== b.kind || a.pressToDismiss !== b.pressToDismiss) return false;
+ if (a.kind === 'custom' && b.kind === 'custom') {
+ return a.component === b.component;
+ }
+ if (a.kind === 'styled' && b.kind === 'styled') {
+ return (
+ JSON.stringify(StyleSheet.flatten(a.style)) ===
+ JSON.stringify(StyleSheet.flatten(b.style))
+ );
+ }
+ return false;
+}
diff --git a/src/backdrop.resolve.ts b/src/backdrop.resolve.ts
new file mode 100644
index 0000000..f3338a0
--- /dev/null
+++ b/src/backdrop.resolve.ts
@@ -0,0 +1,68 @@
+import type { ComponentType } from 'react';
+import type { StyleProp, ViewStyle } from 'react-native';
+
+import type { BackdropComponentProps, BackdropConfig } from './backdrop.types';
+
+/**
+ * What a sheet's own record says about its backdrop, as a stable primitive.
+ *
+ * `QueueItem` is memoized because every host render rebuilds its children, so
+ * it must not subscribe to the config object — this tri-state lets it re-render
+ * only when the backdrop is switched on or off, never when it is restyled.
+ */
+export type SheetBackdropOverride = 'off' | 'own' | 'inherit';
+
+export function backdropOverrideOf(
+ backdrop: BackdropConfig | false | undefined
+): SheetBackdropOverride {
+ if (backdrop === false) return 'off';
+ return backdrop === undefined ? 'inherit' : 'own';
+}
+
+/** Whether the manager should render a backdrop for this sheet at all. */
+export function isBackdropEnabled(
+ override: SheetBackdropOverride,
+ groupBackdrop: BackdropConfig | false | undefined
+): boolean {
+ if (override !== 'inherit') return override === 'own';
+ return groupBackdrop !== false;
+}
+
+export type ResolvedBackdrop = { pressToDismiss: boolean } & (
+ | { kind: 'custom'; component: ComponentType }
+ | { kind: 'styled'; styles: StyleProp[] }
+);
+
+/**
+ * Folds the group default and the sheet's override into what to render.
+ *
+ * The visual choice is atomic — a sheet-level config replaces the group's
+ * rendering entirely, so a group's custom component never bleeds under a sheet
+ * that asked for a styled scrim. Only `pressToDismiss` resolves per field, and
+ * styles compose only where both levels are `styled`.
+ */
+export function resolveBackdrop(
+ stored: BackdropConfig | false | undefined,
+ groupBackdrop: BackdropConfig | false | undefined
+): ResolvedBackdrop {
+ // `false` at either level means "no backdrop", which `QueueItem` already
+ // gates on. Treating it as "no config" here keeps this resolver total.
+ const sheet = stored === false ? undefined : stored;
+ const group = groupBackdrop === false ? undefined : groupBackdrop;
+
+ const pressToDismiss = sheet?.pressToDismiss ?? group?.pressToDismiss ?? true;
+ const visual = sheet ?? group;
+
+ if (visual?.kind === 'custom') {
+ return { kind: 'custom', component: visual.component, pressToDismiss };
+ }
+
+ return {
+ kind: 'styled',
+ styles: [
+ group?.kind === 'styled' ? group.style : undefined,
+ sheet?.kind === 'styled' ? sheet.style : undefined,
+ ],
+ pressToDismiss,
+ };
+}
diff --git a/src/backdrop.types.ts b/src/backdrop.types.ts
new file mode 100644
index 0000000..36597c6
--- /dev/null
+++ b/src/backdrop.types.ts
@@ -0,0 +1,54 @@
+import type { ComponentType } from 'react';
+import type { StyleProp, ViewStyle } from 'react-native';
+import type { SharedValue } from 'react-native-reanimated';
+
+/**
+ * Props handed to a `kind: 'custom'` backdrop component.
+ *
+ * The component owns its own fade: it receives the sheet's live
+ * `animatedIndex` rather than a pre-computed opacity, so blur intensity, a
+ * gradient, or anything else can be driven from the sheet's real position on
+ * the UI thread — exactly the way the built-in backdrop drives its opacity.
+ * Interpolate from `HIDDEN_ANIMATED_INDEX` (-1, hidden) to `0` (fully
+ * visible).
+ */
+export interface BackdropComponentProps {
+ sheetId: string;
+ /** The sheet's shared value: -1 hidden → 0 fully visible. */
+ animatedIndex: SharedValue;
+ /**
+ * Closes the sheet via `requestClose`, so `onBeforeClose` interceptors
+ * still run. The manager's own tap-to-dismiss (`pressToDismiss`) already
+ * calls this; the prop exists for components that add their own gestures.
+ */
+ close: () => void;
+}
+
+/**
+ * What the shared backdrop renders, as a discriminated union — `kind` is what
+ * callers reason about, mirroring `OpenPayload`. A config only changes the
+ * *look*: mount timing, z-index/stack handling and tap routing through
+ * `requestClose` stay with the manager either way.
+ *
+ * Levels, most specific winning atomically per visual choice:
+ * 1. `backdrop` on `BottomSheetManagerProvider` — the group default.
+ * 2. The `backdrop` prop on an adapter — per sheet; `false` disables.
+ *
+ * When both levels are `styled`, their styles compose (group under sheet);
+ * `pressToDismiss` always resolves per field.
+ */
+export type BackdropConfig =
+ | {
+ kind: 'styled';
+ /** Merged over the group's style and the default `rgba(0,0,0,0.5)`. */
+ style?: StyleProp;
+ /** Whether tapping the backdrop closes the sheet. Default: `true`. */
+ pressToDismiss?: boolean;
+ }
+ | {
+ kind: 'custom';
+ /** Replaces the built-in backdrop view entirely. */
+ component: ComponentType;
+ /** Whether tapping the backdrop closes the sheet. Default: `true`. */
+ pressToDismiss?: boolean;
+ };
diff --git a/src/index.tsx b/src/index.tsx
index d81ff28..fc92ead 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -13,6 +13,7 @@ export {
// Adapter types
export type {
+ AdapterBackdropProps,
SheetAdapterRef,
SheetAdapterEvents,
SheetRef,
@@ -29,16 +30,25 @@ export {
requestClose,
closeAllAnimated,
} from './bottomSheetCoordinator';
+export { useAdapterBackdrop } from './useAdapterBackdrop';
export { useAdapterRef } from './useAdapterRef';
export { useAnimatedIndex } from './useAnimatedIndex';
export { useBackHandler } from './useBackHandler';
/**
- * `useSetBackdrop` suppresses the manager's shared backdrop for a sheet — for
- * adapters that render their own and would otherwise stack two.
+ * `useSetBackdrop` sets a sheet's backdrop override imperatively: `false`
+ * suppresses the manager's shared backdrop (for adapters that render their
+ * own and would otherwise stack two), a `BackdropConfig` restyles or replaces
+ * it, `true` clears the override. Adapters exposing a `backdrop` prop should
+ * prefer `useAdapterBackdrop`, which handles the effect plumbing.
* `useSheetPreventDismiss` reports whether an `onBeforeClose` interceptor is
* blocking dismissal, so the adapter can disable its native gestures.
*/
export { useSetBackdrop, useSheetPreventDismiss } from './store';
+/**
+ * The animated index of a fully hidden sheet (`-1`) — the low end of the
+ * interpolation range a custom backdrop component fades across.
+ */
+export { HIDDEN_ANIMATED_INDEX } from './animatedRegistry';
// Hooks
export { useBottomSheetManager } from './useBottomSheetManager';
@@ -57,6 +67,7 @@ export {
export { useOnBeforeClose } from './useOnBeforeClose';
// Types
+export type { BackdropConfig, BackdropComponentProps } from './backdrop.types';
export type { ScaleConfig, ScaleAnimationConfig } from './useScaleAnimation';
export type {
BottomSheetStatus,
diff --git a/src/store/hooks.ts b/src/store/hooks.ts
index c34dc0b..69230cf 100644
--- a/src/store/hooks.ts
+++ b/src/store/hooks.ts
@@ -1,4 +1,5 @@
import { shallow } from 'zustand/shallow';
+import { backdropOverrideOf } from '../backdrop.resolve';
import { getGroupStack } from './helpers';
import { useBottomSheetStore } from './store';
@@ -22,9 +23,30 @@ export const useSheetUsePortal = (id: string) =>
export const useSheetKeepMounted = (id: string) =>
useBottomSheetStore((state) => state.sheetsById[id]?.keepMounted);
+/**
+ * The sheet's backdrop override, for resolving what to render.
+ *
+ * Returns an object without `shallow` on purpose: `setBackdrop` bails on
+ * value-equal writes, so the stored config's identity is already stable across
+ * the re-applications an adapter's effect performs.
+ */
export const useSheetBackdrop = (id: string) =>
useBottomSheetStore((state) => state.sheetsById[id]?.backdrop);
+/**
+ * What the sheet's own record says about its backdrop, as a stable primitive.
+ *
+ * Separate from {@link useSheetBackdrop} so `QueueItem` — memoized precisely
+ * because every host render rebuilds its children — never subscribes to the
+ * config object, and so re-renders only when the backdrop is switched on or
+ * off, not whenever it is restyled. Pair it with the group's `backdrop` via
+ * `isBackdropEnabled`, since `'inherit'` is answered by the group.
+ */
+export const useSheetBackdropOverride = (id: string) =>
+ useBottomSheetStore((state) =>
+ backdropOverrideOf(state.sheetsById[id]?.backdrop)
+ );
+
export const useSheetPortalSession = (id: string) =>
useBottomSheetStore((state) => state.sheetsById[id]?.portalSession);
diff --git a/src/store/store.ts b/src/store/store.ts
index 17bbc90..50732d6 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -14,6 +14,7 @@ import {
withGroupStack,
} from './helpers';
import { ensureAnimatedIndex, resetAnimatedIndex } from '../animatedRegistry';
+import { backdropValuesEqual } from '../backdrop.equality';
import { getNextPortalSession } from '../portalSessionRegistry';
import type {
BottomSheetState,
@@ -122,7 +123,6 @@ export const useBottomSheetStore = create(
status: 'opening',
scaleBackground:
sheet.scaleBackground ?? existingSheet.scaleBackground,
- backdrop: sheet.backdrop ?? existingSheet.backdrop,
params: sheet.params ?? existingSheet.params,
}
: { ...fields, status: 'opening', portalSession };
@@ -201,8 +201,24 @@ export const useBottomSheetStore = create(
setPreventDismiss: (id, prevent) =>
set((state) => patchSheet(state, id, { preventDismiss: prevent })),
+ // `true` clears the override (back to the group default) rather than
+ // storing a truthy flag — the old boolean restore keeps its meaning now
+ // that the field can also hold a config. The equality bail matters:
+ // adapters re-apply their `backdrop` prop with a fresh object literal on
+ // every consumer render, and without it each render would wake every
+ // subscriber of the store.
setBackdrop: (id, backdrop) =>
- set((state) => patchSheet(state, id, { backdrop })),
+ set((state) => {
+ const sheet = state.sheetsById[id];
+ if (!sheet) return state;
+
+ const next = backdrop === true ? undefined : backdrop;
+ if (backdropValuesEqual(sheet.backdrop, next)) return state;
+
+ return {
+ sheetsById: updateSheet(state.sheetsById, id, { backdrop: next }),
+ };
+ }),
clearGroup: (groupId) =>
set((state) => {
diff --git a/src/store/types.ts b/src/store/types.ts
index 61274cb..108b97a 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -1,5 +1,7 @@
import { type ReactNode } from 'react';
+import type { BackdropConfig } from '../backdrop.types';
+
export type BottomSheetStatus = 'opening' | 'open' | 'closing' | 'hidden';
export type OpenMode = 'push' | 'switch' | 'replace';
@@ -16,7 +18,13 @@ export interface BottomSheetState {
content?: ReactNode;
status: BottomSheetStatus;
scaleBackground?: boolean;
- backdrop?: boolean;
+ /**
+ * Per-sheet backdrop override, written only by `setBackdrop` (the adapters'
+ * `backdrop` prop routes through it) — never by `open()`, so it survives
+ * re-open cycles of a persistent sheet. `false` disables the backdrop,
+ * `undefined` falls back to the group's `backdrop` default.
+ */
+ backdrop?: BackdropConfig | false;
usePortal?: boolean;
params?: Record;
keepMounted?: boolean;
@@ -52,7 +60,6 @@ interface OpenPayloadBase {
id: string;
groupId: string;
scaleBackground?: boolean;
- backdrop?: boolean;
params?: Record;
}
@@ -181,7 +188,13 @@ export interface BottomSheetStoreActions {
finishClosing(id: string): void;
updateParams(id: string, params: Record | undefined): void;
setPreventDismiss(id: string, prevent: boolean): void;
- setBackdrop(id: string, backdrop: boolean): void;
+ /**
+ * Sets the sheet's backdrop override: `false` = no backdrop, a
+ * {@link BackdropConfig} = custom look, `true` = clear the override and fall
+ * back to the group default. Widened from the old boolean signature, so
+ * adapters that only ever suppress/restore keep working unchanged.
+ */
+ setBackdrop(id: string, backdrop: boolean | BackdropConfig): void;
clearGroup(groupId: string): void;
clearAll(): void;
mount(sheet: MountPayload): void;
diff --git a/src/useAdapterBackdrop.ts b/src/useAdapterBackdrop.ts
new file mode 100644
index 0000000..61fe19e
--- /dev/null
+++ b/src/useAdapterBackdrop.ts
@@ -0,0 +1,43 @@
+import { useLayoutEffect } from 'react';
+
+import type { BackdropConfig } from './backdrop.types';
+import { useSetBackdrop } from './store';
+
+/**
+ * Applies an adapter's `backdrop` prop to the sheet's store record, where the
+ * manager's shared backdrop reads it.
+ *
+ * Split into a value-sync effect and an unmount cleanup on purpose: a single
+ * effect keyed on `backdrop` would clear-and-rewrite on every fresh object
+ * literal a consumer passes in JSX, waking store subscribers twice per
+ * render. The sync effect instead writes through `setBackdrop`, which bails
+ * on value equality, and the cleanup runs only when the adapter is really
+ * going away.
+ *
+ * Layout effects rather than passive ones because the write lands a commit
+ * after the backdrop first renders: until it does, the sheet inherits the
+ * group default. For a `styled` group default that is invisible — the fade
+ * holds it at zero opacity on that frame — but a `custom` one owns its fade
+ * and would otherwise paint at full strength for a frame before the sheet's
+ * own config replaces it.
+ *
+ * Public for third-party adapters — pair it with a
+ * `backdrop?: BackdropConfig | false` prop to reach parity with the shipped
+ * ones.
+ */
+export function useAdapterBackdrop(
+ id: string,
+ backdrop: BackdropConfig | false | undefined
+): void {
+ const setBackdrop = useSetBackdrop();
+
+ useLayoutEffect(() => {
+ // `undefined` still writes, as a clear: removing the prop must fall the
+ // sheet back to the group default, not freeze the last value.
+ setBackdrop(id, backdrop ?? true);
+ }, [id, backdrop, setBackdrop]);
+
+ useLayoutEffect(() => {
+ return () => setBackdrop(id, true);
+ }, [id, setBackdrop]);
+}
diff --git a/src/useBottomSheetControl.ts b/src/useBottomSheetControl.ts
index a5849f9..d1fc901 100644
--- a/src/useBottomSheetControl.ts
+++ b/src/useBottomSheetControl.ts
@@ -15,7 +15,6 @@ import { getSheetRef, setSheetRef } from './refsMap';
interface BaseOpenOptions {
mode?: OpenMode;
scaleBackground?: boolean;
- backdrop?: boolean;
params?: TParams;
}
@@ -78,7 +77,6 @@ export function useBottomSheetControl(
id,
groupId,
scaleBackground: options?.scaleBackground,
- backdrop: options?.backdrop,
params: options?.params as Record,
},
options?.mode
diff --git a/src/useBottomSheetManager.tsx b/src/useBottomSheetManager.tsx
index c68d644..337cd2c 100644
--- a/src/useBottomSheetManager.tsx
+++ b/src/useBottomSheetManager.tsx
@@ -27,7 +27,6 @@ export const useBottomSheetManager = () => {
groupId?: string;
mode?: OpenMode;
scaleBackground?: boolean;
- backdrop?: boolean;
params?: Record;
} = {}
): string | null => {
@@ -48,7 +47,6 @@ export const useBottomSheetManager = () => {
groupId,
content: contentWithRef,
scaleBackground: options.scaleBackground,
- backdrop: options.backdrop,
params: options.params,
},
options.mode
diff --git a/yarn.lock b/yarn.lock
index 2d2a7a7..95a292d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7112,6 +7112,17 @@ __metadata:
languageName: node
linkType: hard
+"expo-blur@npm:~15.0.8":
+ version: 15.0.8
+ resolution: "expo-blur@npm:15.0.8"
+ peerDependencies:
+ expo: "*"
+ react: "*"
+ react-native: "*"
+ checksum: 3ddffcfb65692d52f63abf7bff247fcc85ead491f148e4b28aac02c0c04cb6b28f03933906f34c783a5d5ea41fde5959dabf9bee53dac54328fb70662cfca72e
+ languageName: node
+ linkType: hard
+
"expo-constants@npm:~18.0.12, expo-constants@npm:~18.0.13":
version: 18.0.13
resolution: "expo-constants@npm:18.0.13"
@@ -12339,6 +12350,7 @@ __metadata:
"@swmansion/react-native-bottom-sheet": 0.16.2
babel-plugin-module-resolver: ^5.0.2
expo: ^54.0.31
+ expo-blur: ~15.0.8
expo-dev-client: ~6.0.13
expo-linking: ~8.0.11
expo-status-bar: ~2.0.1