Skip to content

feat!: customizable backdrop (color, style, or custom component) - #46

Merged
arekkubaczkowski merged 5 commits into
mainfrom
claude/bottom-sheet-stack-api-x8elne
Aug 11, 2026
Merged

feat!: customizable backdrop (color, style, or custom component)#46
arekkubaczkowski merged 5 commits into
mainfrom
claude/bottom-sheet-stack-api-x8elne

Conversation

@arekkubaczkowski

@arekkubaczkowski arekkubaczkowski commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes #45.

custom-backdrop.mov

The shared backdrop's appearance was hardcoded to rgba(0, 0, 0, 0.5), and the only knob anywhere in the public API was backdrop: false on open(), which turned it off entirely. Theming, blur, and per-sheet variation all required either patching the package or rendering your own backdrop inside the sheet — which loses everything the shared one provides: stack-aware z-indexing, living outside the BottomSheetScaleView transform, and being driven from the sheet's live animatedIndex.

This makes what the backdrop renders configurable while leaving all of that behaviour with the manager.

API

Two levels, the sheet's choice winning over the group's:

// Group default — same prop name and type as on the adapters
<BottomSheetManagerProvider
  id="default"
  backdrop={{ kind: 'styled', style: { backgroundColor: 'rgba(0,0,0,0.75)' } }}
>

// Per sheet — on any shipped adapter, in inline, portal and persistent mode alike
<GorhomSheetAdapter backdrop={{ kind: 'styled', style: { backgroundColor: 'rgba(0,0,0,0.2)' } }} />
<CustomModalAdapter backdrop={{ kind: 'custom', component: BlurBackdrop }} />
<SwmansionSheetAdapter backdrop={false} />
type BackdropConfig =
  | { kind: 'styled'; style?: StyleProp<ViewStyle>; pressToDismiss?: boolean }
  | { kind: 'custom'; component: ComponentType<BackdropComponentProps>; pressToDismiss?: boolean };

type BackdropComponentProps = {
  sheetId: string;
  /** -1 hidden → 0 visible — the same shared value the built-in backdrop interpolates. */
  animatedIndex: SharedValue<number>;
  /** Routes through requestClose, so onBeforeClose interceptors still run. */
  close: () => void;
};

A discriminated union rather than three mutually-exclusive optional fields, mirroring OpenPayloadkind is what callers reason about. A custom component receives the raw animatedIndex rather than a pre-computed opacity, so blur intensity or a gradient can be driven from the sheet's real position on the UI thread; it therefore owns its own fade, and the built-in opacity is deliberately not applied on top.

Resolution is atomic for the visual choice — a sheet-level config replaces the group's rendering entirely, so a group custom component never bleeds under a sheet that asked for a styled scrim. Styles compose (default → group → sheet) where both levels are styled, and pressToDismiss resolves per field.

Adapter authors get useAdapterBackdrop(id, backdrop) and AdapterBackdropProps, which is exactly what the five shipped adapters use.

Breaking changes

1. backdrop?: boolean removed from open() options (useBottomSheetManager, useBottomSheetControl).

open(<MySheet />, { backdrop: false });   // before
<GorhomSheetAdapter backdrop={false}>     // after, in MySheet's JSX

Declaring it on the adapter works identically across all three operating modes, and keeping open() out of the field is what lets a persistent sheet's backdrop survive close/re-open cycles. The capability that genuinely moves is per-open variation — the migration guide shows the params route.

2. backdropComponent removed from GorhomSheetAdapterProps. The manager always renders the backdrop, so the two can never stack. Passing gorhom's own silently disabled the stack-aware one and substituted a per-sheet backdrop that does not participate in the stack — a bug shaped like a feature. backdrop={{ kind: 'custom', component }} replaces it.

Both are documented under Backdrop → Migration. This lands as 3.0.0.

Testing

117 tests pass (24 new), plus typecheck, lint, and a docs build with onBrokenLinks: 'throw'.

Per CLAUDE.md's "write the test so it fails without the fix", each new test was confirmed to fail against the unfixed code. The ones worth calling out cover decisions that would otherwise silently regress:

  • a value-equal fresh object literal produces no store write — merging useAdapterBackdrop's two effects into the obvious one-effect-with-cleanup form passes every other test
  • restyling does not re-render the memoized sheet layer (QueueItem subscribes to a stable 'off' | 'own' | 'inherit' primitive, never the config object)
  • a config style's own opacity does not replace the manager's fade
  • a style carrying an explicit undefined is not mistaken for one carrying a real value in its place
  • a persistent sheet keeps its backdrop across a full close/re-open cycle
  • tap routes through requestClose, so a refusing onBeforeClose interceptor keeps the sheet open

Review notes

The implementation was reviewed by four independent agents (API design, correctness, readability/DRY, docs-vs-code accuracy). Findings that survived verification are folded into the commits above — the substantive ones were the equality false-positive, the opacity ordering, a one-frame flash of a group-level custom backdrop (now applied in a layout effect), and the two untested design decisions listed above.

🤖 Generated with Claude Code

claude and others added 5 commits August 10, 2026 15:44
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TY6uDERCua6Z4UrwU1wUjk
Review follow-ups on the configurable-backdrop work.

- GorhomSheetAdapter no longer accepts gorhom's `backdropComponent`: it is
  omitted from the props type and forced to render nothing. The manager
  draws the one shared, stack-aware backdrop for every sheet, so the
  adapter now matches the other four. This removes the inference +
  precedence + dev-warning branch entirely — and with it a warning that
  wrongly claimed stacking for the sound `backdrop={false}` combination.
- QueueItem subscribes to a new `useSheetBackdropEnabled` boolean instead
  of the config object, so restyling no longer re-renders the memoized
  sheet layer. Only BottomSheetBackdrop reads the config.
- `backdropValuesEqual` moves out of `store/helpers.ts` into
  `backdrop.equality.ts`, keeping helpers pure stack operations and the
  store layer free of React Native imports.
- The reanimated mock gains a passthrough `Animated.View` in jest.setup.ts,
  dropping a ~40-line near-verbatim re-mock from backdrop.test.tsx.
- Docs: gorhom backdrop section rewritten, `backdropComponent` migration
  documented, duplicate `sidebar_position: 6` resolved, CLAUDE.md file map
  and pitfalls updated.

BREAKING CHANGE: `GorhomSheetAdapter` no longer accepts `backdropComponent`.
Use `backdrop={{ kind: 'custom', component: MyBackdrop }}` instead — it
renders in the manager's stack-aware backdrop layer and receives
`{ sheetId, animatedIndex, close }`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md carries project guidelines, not an inventory of files. The map
duplicated the directory tree, said nothing a reader could act on, and went
stale on every added or renamed file.

The guidance that happens to name a symbol stays — those entries encode why
a piece of code is the way it is (the sanctioned memoization exceptions, the
registry table, the pitfalls), which is the point of the document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t review

Four review passes (API design, correctness, readability, docs accuracy)
against the configurable-backdrop work. Every fix below carries a test that
fails without it.

Correctness:
- `backdropValuesEqual` walked only one style's keys and used the key count
  as a proxy for the key set, so a style carrying an explicit `undefined`
  compared equal to one carrying a real value in its place — `setBackdrop`
  bailed and the restyle was silently dropped. Comparing flattened JSON fixes
  it and cuts the file roughly in half; key-order sensitivity is the only
  cost, and it buys one redundant write, never a wrong render.
- A config style's own `opacity` beat the manager's fade, because
  `animatedStyle` sat before the user styles in the array. It now sits last.
- `useAdapterBackdrop` applies the prop in a layout effect. The write lands a
  commit after the backdrop first renders; for a `styled` group default that
  frame is invisible (opacity 0), but a `custom` one owns its fade and painted
  at full strength before the sheet's own config replaced it.

API:
- The provider's `backdropConfig?: BackdropConfig` becomes
  `backdrop?: BackdropConfig | false` — same name and type as the adapter
  prop, and a group can now opt out of backdrops entirely. `QueueItem` reads a
  stable `'off' | 'own' | 'inherit'` selector and folds in the group value, so
  it still never subscribes to the config object.
- Adapters share `AdapterBackdropProps` instead of five copies of the same
  prop and JSDoc.
- Resolution moves to `backdrop.resolve.ts`, which makes the atomic-visual /
  per-field-pressToDismiss rule unit-testable and drops the `let` in
  `BottomSheetBackdrop`.

Tests: cover the two decisions that had none — that a value-equal fresh
literal produces no store write (merging the two effects passes otherwise),
and that restyling does not re-render the memoized sheet layer.

Docs: the blur example was missing its `StyleSheet` import; `custom-adapters`
still taught the hand-rolled effect instead of `useAdapterBackdrop`; three
adapter pages never documented their new prop; `backdrop.md` still referenced
gorhom's removed `backdropComponent`; `intro` lacked the feature README has.
Also documents that `backdrop={false}` removes the touch shield that
`pressToDismiss: false` keeps, and how to vary a backdrop per open via params.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The example only ever used `backdrop={false}`, so the new configuration
surface was never run — which is also how the JSX-text bug below survived
review.

Adds a Backdrop demo that stacks all four variants so the resolution rules
are visible rather than described: the default scrim, a `kind: 'custom'`
expo-blur backdrop, a `kind: 'styled'` tint, and `pressToDismiss: false`.

`BlurBackdrop` is declared at module scope on purpose — the config is
compared by component identity, so an inline arrow would be a new type every
render and would remount the backdrop, restarting the blur.

Verified on an iPhone 17 Pro Max simulator: the blur intensity follows
`animatedIndex` continuously (weakens mid-drag, recovers on snap-back)
rather than switching discretely, tap-to-dismiss still routes through
`requestClose` with a custom component in place, the tint layers over the
sheet below while leaving the top sheet untouched, and `pressToDismiss:
false` keeps the sheet open while still blocking touches.

Also fixes a JSX text node where `{false}` was parsed as an expression and
rendered nothing, so the copy read "backdrop=" instead of "backdrop={false}".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@arekkubaczkowski
arekkubaczkowski merged commit f6695d3 into main Aug 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: customizable backdrop (color / style / custom component)

2 participants