From 158cae0b2af2d1e24308d6540d16a83cd5708966 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 15:44:38 +0000 Subject: [PATCH 1/5] feat!: customizable backdrop (styled config or custom component) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #45. The shared stack-aware backdrop keeps its behavior (mount timing, animatedIndex-driven fade, z-index handling, requestClose on tap) but what it renders is now configurable at two levels, most specific winning: - Group default: `backdropConfig` on `BottomSheetManagerProvider`. - Per sheet: a `backdrop?: BackdropConfig | false` prop on every shipped adapter, applied via the new public `useAdapterBackdrop` hook so third-party adapters reach parity. `BackdropConfig` is a discriminated union (`kind: 'styled' | 'custom'`), mirroring `OpenPayload` — styled configs merge styles over the group and default, custom components receive `{ sheetId, animatedIndex, close }` and own their fade; `pressToDismiss` resolves per field. `setBackdrop` widens compatibly to `boolean | BackdropConfig` (`true` clears the override) and bails on value-equal writes so fresh JSX object literals don't wake store subscribers every render. BREAKING CHANGE: `backdrop?: boolean` is removed from `open()` options on `useBottomSheetManager` and `useBottomSheetControl`. Pass `backdrop={false}` (or a config) to the sheet's adapter instead — it works identically across inline, portal, and persistent modes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TY6uDERCua6Z4UrwU1wUjk --- CLAUDE.md | 28 +- README.md | 2 + docs/docs/api/components.md | 1 + docs/docs/api/hooks.md | 9 +- docs/docs/api/types.md | 36 ++ docs/docs/backdrop.md | 142 +++++++ docs/docs/built-in-adapters/gorhom.md | 4 +- docs/docs/built-in-adapters/swmansion.md | 2 +- docs/sidebars.ts | 1 + example/src/components/Sheet.tsx | 5 + example/src/sheets/ForceCloseSheets.tsx | 14 +- src/BottomSheetBackdrop.tsx | 54 ++- src/BottomSheetManager.context.tsx | 2 + src/BottomSheetManager.provider.tsx | 10 +- src/__tests__/backdrop.test.tsx | 349 ++++++++++++++++++ .../actions-sheet/ActionsSheetAdapter.tsx | 17 +- .../custom-modal/CustomModalAdapter.tsx | 10 +- .../gorhom-sheet/GorhomSheetAdapter.tsx | 39 +- .../ReactNativeModalAdapter.tsx | 10 + .../swmansion/SwmansionSheetAdapter.tsx | 15 +- src/backdrop.types.ts | 54 +++ src/index.tsx | 14 +- src/store/helpers.ts | 52 +++ src/store/store.ts | 20 +- src/store/types.ts | 19 +- src/useAdapterBackdrop.ts | 36 ++ src/useBottomSheetControl.ts | 2 - src/useBottomSheetManager.tsx | 2 - 28 files changed, 903 insertions(+), 46 deletions(-) create mode 100644 docs/docs/backdrop.md create mode 100644 src/__tests__/backdrop.test.tsx create mode 100644 src/backdrop.types.ts create mode 100644 src/useAdapterBackdrop.ts diff --git a/CLAUDE.md b/CLAUDE.md index fa3f87a..d17f593 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,18 @@ 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 `backdropConfig` 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; that is safe *because* `animatedIndex` starts at `-1` and cannot move +until the coordinator has the adapter's ref, so the swap happens while the +backdrop is still transparent. A `kind: 'custom'` component owns its own fade +off `animatedIndex` — the built-in opacity is deliberately not applied on top. + `useSheetRenderData` orders hidden persistent sheets before active ones so React does not unmount and remount across transitions. @@ -237,7 +256,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 +270,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 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/api/components.md b/docs/docs/api/components.md index e2485d0..3d2c6db 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 | +| `backdropConfig` | `BackdropConfig` | No | The group's default backdrop; 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..73acd98 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` | +The backdrop is configured on the adapter (the `backdrop` prop) or on the provider (`backdropConfig`), not per `open()` call — see [Backdrop](/backdrop). + `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 | +The backdrop is configured on the adapter (the `backdrop` prop) or on the provider (`backdropConfig`), not per `open()` call — see [Backdrop](/backdrop). + `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..baf8f4e 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 `backdropConfig` 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 default rgba(0,0,0,0.5) + pressToDismiss?: boolean; // default: true + } + | { + kind: 'custom'; + component: ComponentType; + pressToDismiss?: boolean; + }; +``` + +--- + +### 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..2b502f7 --- /dev/null +++ b/docs/docs/backdrop.md @@ -0,0 +1,142 @@ +--- +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** — `backdropConfig` 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 + + ... + +``` + +## 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 + +``` + +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 { 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 ( + + ); +} + + +``` + +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, type BackdropConfig } from 'react-native-bottom-sheet-stack'; + +function MyAdapter({ backdrop, ...props }: { backdrop?: BackdropConfig | false }) { + const { id } = useBottomSheetContext(); + useAdapterBackdrop(id, backdrop); + // ... +} +``` + +`useSetBackdrop` remains for imperative control: `setBackdrop(id, false)` suppresses the shared backdrop (what `GorhomSheetAdapter` does when you hand it a custom `backdropComponent`), `setBackdrop(id, config)` restyles it, `setBackdrop(id, true)` clears the override. + +## 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 only worked for inline sheets and 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. diff --git a/docs/docs/built-in-adapters/gorhom.md b/docs/docs/built-in-adapters/gorhom.md index 2ee84e0..28b8592 100644 --- a/docs/docs/built-in-adapters/gorhom.md +++ b/docs/docs/built-in-adapters/gorhom.md @@ -60,7 +60,9 @@ const MySheet = forwardRef((props, ref) => { 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. -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: +To restyle or replace the manager's backdrop — or turn it off — pass the `backdrop` prop (`BackdropConfig | false`), which keeps all of the stack-aware behavior; see [Backdrop](/backdrop). + +You **can** also override it by passing your own gorhom `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 (an explicit `backdrop` prop outranks that inference — don't pass both): ```tsx import { BottomSheetBackdrop as GorhomBackdrop } from '@gorhom/bottom-sheet'; diff --git a/docs/docs/built-in-adapters/swmansion.md b/docs/docs/built-in-adapters/swmansion.md index 4b33024..d93156f 100644 --- a/docs/docs/built-in-adapters/swmansion.md +++ b/docs/docs/built-in-adapters/swmansion.md @@ -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/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/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/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} />