Skip to content

feat(core): BottomSheet replace handoff on open-order stacking (prototype, stacked on #7648) - #7712

Closed
imdreamrunner wants to merge 2 commits into
feat/core-bottom-sheet-open-orderfrom
feat/sheet-replace-on-open-order
Closed

imdreamrunner wants to merge 2 commits into
feat/core-bottom-sheet-open-orderfrom
feat/sheet-replace-on-open-order

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Prototype for evaluation, stacked on #7648 (candidate C). This PR's diff is only its delta over feat/core-bottom-sheet-open-order. It answers one question with a working implementation and measurements: can candidate C reproduce BottomSheetSwitcher exactly — the replace handoff between steps — from standalone BottomSheets alone, so that the switcher can be deprecated? The deprecation itself is a small PR stacked on this one (feat/deprecate-bottom-sheet-switcher), so this diff stays one capability.

Primary intent

Decide whether BottomSheetSwitcher is still needed once sheets stack by open order (#7648). The switcher's one job the stacking model does not cover is the replace handoff between steps: the entering step rises above the retained one, a taller retained step moves down to the entering step's top edge at the same time, the retained step fades only after both motions complete, it is inert and hidden from assistive technology at once, there is one scrim throughout with no dip, focus moves into the new step, and closing returns focus to the flow's opener (component:BottomSheetSwitcher FR3 / ORD1 / FR4). Under C alone, setA(false); setB(true) in one update reads as a push plus a pop.

The other templates do not fit: nothing is broken (bug-fix), the capability is a prototype of an existing component's behaviour on a different structure (new-feature would imply a decision), and pixels are deliberately unchanged (visual-update).

User or maintainer impact

Builders: a multi-step flow becomes one BottomSheet per step plus one state value naming the open step — no BottomSheetSwitcher, no sheetId. Nothing changes for a sheet on its own, for stacked sheets, or for switcher flows (the switcher stays supported and is one stack level, as in C).

Maintainers: the answer to the deprecation question, with the exact cost of achieving it on per-step dialogs, and the hardening an independent verification of the registry asked for.

Problem and solution fit

Mechanism chosen — (a), no new API. The private stack registry (bottomSheetStack.ts) recognises a replace and runs the switcher's handoff on the two standalone dialogs.

The rule. A replace is a move the person makes from one sheet to the next:

  1. exactly one sheet closes and exactly one sheet opens in the same update (one React commit);
  2. both are standalone modal sheets of one document (a switcher flow replaces its own steps and is never paired; non-modal sheets take no part);
  3. the closing sheet was the top sheet of its stack once the opening sheet is set aside — a covered sheet closing while a sheet opens is a pop beneath a push;
  4. neither dialog contains the other — a sheet in another sheet's content closes with it, it does not replace it;
  5. the interaction came from inside the closing sheet: the element that was focused when the update's first open/close flip arrived (read before any dialog of the update is shown, so the opening sheet's own showModal() cannot have moved focus yet) is in the closing sheet's dialog, or nowhere (a control that left with the step). A close and an open driven from a control outside the closing sheet are two unrelated owners acting in one update: they keep their own openers and their own push and pop motion.

Condition 5 is the answer to the verification report's C7 case (two owners in one tree; a page control closes X's sheet and opens Y's) — the only case where pairing was wrong, because B then adopted A's opener and closing B returned focus to X's control. Why this condition and not another:

  • Opener adoption only when the replacer had none (the report's minimum): under a modal sheet the replacer's own opener is always inside the closing sheet or nowhere, so it never has one; the guard would never fire, and the visual replace between unrelated owners would remain.
  • An explicit opt-in prop (replaces / a flow key): rejected as long as a rule with no API covers every real flow. Every step flow passes condition 5 by construction: Continue and Back live in the step being left; a step change made by code after a delay (a save that resolves) passes too, because under a modal sheet focus cannot be anywhere but inside it. Kept in reserve.
  • Pairing by React tree position (siblings only, a common parent): a flow with steps wrapped in their own components has neither, and unrelated owners at the app root have both.
  • Reading document.activeElement at pairing time: wrong on a reversal (Back while the previous step is fading), because the returning sheet's layout effect runs first in tree order and its showModal() moves focus before the pairing is seen. Hence the actor is captured at the update's first flip.

The handoff itself is the switcher's state machine, ported into the registry and driven by the panels' existing motion reports: covered → (entering starts; a taller retained sheet gets aligning with the measured offset) → both complete → fading → fade complete → the replaced dialog closes and the replacing sheet takes the scrim in the same frame. The replaced sheet is inert/aria-hidden at once, stands down from the dismissal stack (so Escape and a platform close reach the replacing sheet even when the replaced one registered later), and lends its opener to the replacer. A reversal mid-handoff swaps the roles; a dismissal mid-handoff drops the retained sheet at once, as the switcher does.

Non-modal sheets (hasScrim={false}) do not replace. The first version ordered the two show() dialogs with a page-level z-index: 1001, which AST-027 rules out (z-index is local only; no larger number as a substitute for top-layer promotion). Until non-modal hosting moves to the top layer (the migration gap AST-027 already records for the switcher's show() path), a non-modal step change is a plain exit and entrance. The switcher still covers that one case alone, and its deprecation says so.

Alternatives for the mechanism, rejected: (b) a prop on BottomSheet — see above; (c) a helper hook or component over standalone sheets — it would need the same registry-level facts (who is top, who owns the scrim, the top-layer order), a second coordinator next to C's, and cannot stop per-step dialogs fighting on backdrops and focus without the registry's cooperation anyway; letting the replacer own the scrim from the start — its backdrop paints above and dims the retained panel; cross-fading the two backdrops — dips to ~44% of the dim halfway; deferring the reconcile to a microtask — breaks C's first-frame deep-link guarantee.

Public API: none. No prop, type, default or export changes. Before/after callsites for a flow:

// before: one switcher, steps by sheetId
<BottomSheetSwitcher activeSheet={step} onActiveSheetChange={setStep}>
  <BottomSheet sheetId="overview" label="Set up notifications">…</BottomSheet>
  <BottomSheet sheetId="frequency" label="Notification frequency">…</BottomSheet>
</BottomSheetSwitcher>

// after: one sheet per step, one state value; moving on closes one and opens the next in one update
<BottomSheet isOpen={step === 'overview'} onOpenChange={o => !o && setStep(null)} label="Set up notifications">…</BottomSheet>
<BottomSheet isOpen={step === 'frequency'} onOpenChange={o => !o && setStep(null)} label="Notification frequency">…</BottomSheet>

Caller burden added: knowing that a same-update close+open of the top sheet, made from inside it, is a replace (the new "Replacing a sheet" paragraph of the Stacking docs).

Authority

  • component:BottomSheetSwitcher (draft) FR1, FR3, FR4, FR6, ORD1 define the handoff reproduced here.

  • family:overlay-dismissal (current): both sheets stay in the one shared dismissal stack; a replaced sheet answers isPresent false so one Escape reaches the one interactive sheet (FR2/FR3 of the family).

  • architecture:layer-runtime (current): native-dialog hosting unchanged. spec:AST-027 (current): no page-level z-index; the non-modal path stays out of the replace.

  • Owning record for the replace rule: component:BottomSheet. Both sheet records are still draft; the rule is a property of the standalone sheet and belongs in its contract, not the switcher's (which it supersedes) and not a new record (no distinct fact boundary). Proposed claim text, to be added to BottomSheet.spec.md in a spec PR once the A/B/C decision picks this model (no spec PR is opened by this prototype):

    FR-R1 — Replace. When exactly one modal standalone sheet closes and exactly one opens in the same update, the closing sheet was the top sheet of its stack with the opening sheet set aside, neither dialog contains the other, and the element focused when the update's first open or close arrived is inside the closing sheet's dialog or is none, the opening sheet replaces the closing one: it enters above the retained sheet; a retained sheet whose top edge is above the entering sheet's resting top edge moves down to meet it concurrently; the retained sheet fades only after both motions complete, is inert and hidden from assistive technology from the first frame, takes no dismissal request, and keeps the stack's visible backdrop until it leaves, when the replacing sheet's backdrop takes over with no transition. The replacing sheet adopts the replaced sheet's focus-return target.
    FR-R2 — Boundary. A close and an open in different updates, a covered sheet closing while a sheet opens, several sheets changing at once, a sheet closing with its host, or a close and an open driven from a control outside the closing sheet are not a replace: each sheet keeps its own push or pop motion and its own focus-return target.
    FR-R3 — Non-modal. Sheets with hasScrim={false} neither stack nor replace.
    DEC — BottomSheetSwitcher deprecated. The switcher's handoff is this contract's FR-R1 on standalone sheets; the switcher remains supported until a [breaking] removal in a declared minor (AST-017 FR26/FR28).

Change and evidence

Files (delta over C): bottomSheetStack.ts (+357/−11: batch flips and actor, detectReplace, the replace state machine, reportBottomSheetMotion, replaced sheets excluded from the replacer's recede depth, scrim owner = bottom-most open or retained sheet), BottomSheet.tsx (+84/−14: retained/entering panel state from the registry's view, held open pose until the registry answers, motion reports, dismissal stand-down while replaced, opener handover), BottomSheetReplace.test.tsx (new, 32 tests), BottomSheet.doc.mjs ("Replacing a sheet" in Stacking; multi-step guidance points at plain sheets), apps/storybook/stories/BottomSheetReplace.stories.tsx (new: the four switcher stories rebuilt from standalone sheets, same content, labels, heights and steps), a [feat] Changeset. 1,728 lines added, 44 removed.

Hardening (from the independent verification of the registry)

Item Where Test
One registry per page, guarded by shape. The state is on globalThis under Symbol.for('astryx.bottomSheetStack'); a copy of another shape keeps its own. in C (#7648), since the registry is C's; this PR bumps the shape to 2 because it adds batch fields C: R13 (two copies share; other shape keeps its own)
Per-document stacks. Entries are keyed by ownerDocument; a sheet portaled into another document has its own stack, top layer and focus. in C C: R13 "sheets in another document form their own stack"
C7 guard. Condition 5 above; the actor is captured at the update's first flip. this PR "a close and an open driven from a control outside the closing sheet are a pop and a push, each with its own opener"; "the same close and open driven from inside the closing sheet are a replace"; "a step flow whose steps are wrapped in their own components still replaces"; "a step change made by code while focus is in the step is a replace"
Test reset clears the seal flag. in C —
No provider, public or internal: the module registry is the repo's shape for document-wide facts (layerStack, modalOutlet, useScrollLock); a context cannot reach a second root, a second package copy or a widget outside it. The shared dismissal stack has the same two-copies problem and is a follow-up outside these PRs. — —

Review findings resolved

Finding Fix Test
Non-modal replace ordered two show() dialogs with a page-level z-index: 1001 (AST-027). Non-modal sheets are out of replace pairing (and out of the registry, as in C); no new page band. "non-modal sheets neither stack nor replace: a step change is a pop and a push with show()"; "a non-modal sheet never pairs with a modal one"
BottomSheet.doc.mjs still sent multi-step flows to the switcher (the hasScrim doc, both descriptions, a best practice, the example). All now teach one state value and standalone sheets; the example is rewritten; the deprecation PR adds the @deprecated wording on the switcher's own surfaces. check:knowledge
setIsCloseSettled(false) reset in a layout effect with no external system. The flag is keyed to the close it settles: every change of isOpen resets it during render (the previous-value pattern), and only the layout effect that follows the registry's answer sets it. existing handoff tests (a wrong pose would snap the retained panel)
The frames writeScrimInstantly scheduled were not cancelled on unmount. The scrim write moved into C as writeScrimOpacity with a per-host pending handle; hosts cancel it in an effect cleanup. C: R6 scrim tests

Parity, measured

Chromium (Playwright 1.61, Chrome Headless Shell 149) at 390×844 with touch against the static Storybooks of main (BottomSheetSwitcher stories) and this branch (BottomSheetReplace stories). Per switch, every animation frame: each panel's computed transform and opacity, every dialog's ::backdrop opacity (combined into the page dim), elementFromPoint on each panel, inert / aria-hidden, document.activeElement, the native showModal/show/close log, mid-handoff and settled screenshots with a pixel diff. Timings are relative to the click; the two builds can sit one frame apart purely from where the click fell in the frame, so deltas are read with a ±1 frame (≈17 ms) tolerance. "Back-during-handoff" is Back pressed 150 ms into 1→2.

Chromium, default motion

Story Switch Entering start / end Δ (ms) Align offset main / branch (px) Fade start / end Δ (ms) Per-frame max Δ (±1 frame): entering px / retained px / retained opacity Page dim main / branch Pixel diff mid / settled (px) Focus after Retained inert & never on top
hug-content 1to2 +3 / +2 156 / 156 +2 / +1 0.4 / 0.2 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content 2to3 +0 / -1 0 / 0 -1 / -1 0.5 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content 3to2 +0 / +17 56 / 56 +17 / +17 0.3 / 0.1 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content 2to1 -1 / +0 0 / 0 +0 / +0 0.5 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content back-during-handoff +0 / +0 302 / 302 +1 / +1 33.8 / 0.2 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 1 / 1693 same same
capped 1to2 +1 / +1 0 / 0 +1 / +0 0.7 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped 2to3 +0 / +17 0 / 0 +17 / +18 0.5 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped 3to2 +0 / -16 0 / 0 -17 / -17 0.9 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped 2to1 +1 / -15 0 / 0 -15 / -16 0.2 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped back-during-handoff +0 / +0 571.3 / 571.3 +0 / +0 0.0 / 0.8 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 1646 / 1646 same same
no-scrim 1to2 +0 / -16 156 / 431.8 n/a / n/a 0.2 / 275.8 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 12309 / 0 differs: body (page) vs panel[How often?] same
no-scrim 2to3 +0 / +0 0 / 302 n/a / n/a 0.2 / 302.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 1896 / 0 differs: body (page) vs panel[Where should we notify you?] same
no-scrim 3to2 +0 / +16 56 / 358 n/a / n/a 0.2 / 302.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 7897 / 0 differs: body (page) vs panel[How often?] same
no-scrim 2to1 +0 / +0 0 / 302 n/a / n/a 0.3 / 302.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 2488 / 0 differs: body (page) vs panel[Set up notifications] same
no-scrim back-during-handoff +0 / +0 302 / 302 n/a / n/a 62.5 / 0.2 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 7517 / 0 same same
narrow-viewport 1to2 -1 / +0 156 / 156 +0 / +0 0.3 / 0.2 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport 2to3 +0 / -17 0 / 0 -16 / -16 0.3 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport 3to2 +0 / +16 56 / 56 +17 / +16 0.2 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport 2to1 +0 / +18 0 / 0 +18 / +17 0.4 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport back-during-handoff +0 / +0 302 / 302 +0 / -1 33.8 / 0.2 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 141 / 1693 same same

Chromium, prefers-reduced-motion: reduce (every motion completes in one frame on both builds; the dim must still hold at 0.5)

Story Switch Entering start / end Δ (ms) Align offset main / branch (px) Per-frame max Δ: entering px / retained px / opacity Page dim main / branch Pixel diff mid / settled (px) Focus after
hug-content 1to2 -1 / -1 156 / 156 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
hug-content 2to3 +1 / +1 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
hug-content 3to2 -1 / -1 56 / 56 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
hug-content 2to1 -1 / -1 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
hug-content back-during-handoff +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
capped 1to2 +2 / +2 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
capped 2to3 -1 / -1 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
capped 3to2 +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
capped 2to1 +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
capped back-during-handoff +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
no-scrim 1to2 +0 / +0 156 / 0 0.0 / 0.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[How often?]
no-scrim 2to3 +1 / +1 0 / 0 0.0 / 0.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[Where should we notify you?]
no-scrim 3to2 +1 / +1 56 / 0 0.0 / 0.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[How often?]
no-scrim 2to1 +1 / +1 0 / 0 0.0 / 0.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[Set up notifications]
no-scrim back-during-handoff +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 4319 / 0 differs: body (page) vs panel[Set up notifications]
narrow-viewport 1to2 +2 / +2 156 / 156 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
narrow-viewport 2to3 +1 / +1 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
narrow-viewport 3to2 +0 / +0 56 / 56 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
narrow-viewport 2to1 -1 / -1 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
narrow-viewport back-during-handoff +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same

Before this revision the reduced-motion case failed: the stylesheet's literal 0.01s reduced-motion duration overrode the inline 0s pin, and the page dim dropped to 0 for one or two frames on every switch (mid-switch screenshots differed by 124k–207k px). Measured on the previous head of this PR (e191db295):

Story Switch Page dim main / old C+ (e191db2) Pixel diff mid / settled (px)
hug-content 1to2 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 0 / 0
hug-content 2to3 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 207192 / 0
hug-content 3to2 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 0 / 0
hug-content 2to1 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 168192 / 0
hug-content back-during-handoff 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 0 / 0
capped 1to2 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 124122 / 0
capped 2to3 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 124122 / 0
capped 3to2 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 124122 / 0
capped 2to1 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 124122 / 0
capped back-during-handoff 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 0 / 0
no-scrim 1to2 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0
no-scrim 2to3 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0
no-scrim 3to2 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0
no-scrim 2to1 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0
no-scrim back-during-handoff 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0
narrow-viewport 1to2 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 0 / 0
narrow-viewport 2to3 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 207192 / 0
narrow-viewport 3to2 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 0 / 0
narrow-viewport 2to1 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 168192 / 0
narrow-viewport back-during-handoff 0.5..0.5 / 0..0.5 (max per-frame Δ 0.500) 0 / 0

WebKit (Playwright's WebKit 26.5 in its container, same harness). Headless WebKit's first frames after a click arrive 50–150 ms apart and vary between runs, so per-frame motion cannot be compared there; the main-vs-main noise floor below shows deltas of the same size as main-vs-branch. What WebKit does establish: the page dim constant at 0.5 on every switch, settled screenshots identical (0 px) on every pair, mid-handoff screenshots identical on 12 of 16 modal switches, focus and inertness identical.

Story Switch Time-aligned (≤10 ms) max Δ: entering px / retained px / opacity (frames) Page dim main / branch Pixel diff mid / settled (px) Focus after Retained inert & never on top
hug-content 1to2 0.0 / 0.0 / 0.046 (65) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 726 / 0 same same
hug-content 2to3 18.6 / 0.0 / 0.018 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content 3to2 17.5 / 23.8 / 0.554 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content 2to1 4.7 / 0.0 / 0.012 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content back-during-handoff 72.3 / 13.3 / 0.090 (92) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 21484 / 0 same same
capped 1to2 27.8 / 0.0 / 0.223 (80) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 1382 / 0 same same
capped 2to3 9.9 / 0.0 / 0.025 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped 3to2 21.5 / 0.0 / 0.012 (86) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped 2to1 5.1 / 0.0 / 0.017 (88) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped back-during-handoff 0.0 / 23.0 / 0.034 (91) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
no-scrim 1to2 2.0 / 289.6 / 0.000 (86) 0..0 / 0..0 (max per-frame Δ 0.000) 9138 / 0 differs: body (page) vs panel[How often?] same
no-scrim 2to3 19.3 / 292.8 / 0.000 (88) 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[Where should we notify you?] same
no-scrim 3to2 18.3 / 282.3 / 0.000 (88) 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[How often?] same
no-scrim 2to1 7.3 / 285.4 / 0.000 (88) 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[Set up notifications] same
no-scrim back-during-handoff 54.4 / 4.0 / 0.000 (94) 0..0 / 0..0 (max per-frame Δ 0.000) 6091 / 0 differs: body (page) vs panel[Set up notifications] same
narrow-viewport 1to2 12.0 / 32.2 / 0.323 (85) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 30317 / 0 same same
narrow-viewport 2to3 15.3 / 0.0 / 0.223 (86) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport 3to2 4.5 / 23.8 / 0.331 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport 2to1 5.9 / 0.0 / 0.000 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport back-during-handoff 16.4 / 8.1 / 0.643 (91) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 27850 / 0 same same

WebKit noise floor, main against itself with the same harness:

Story Switch Entering start / end Δ (ms) Time-aligned max Δ: entering px / retained px / opacity (frames) Page dim Pixel diff mid / settled (px)
hug-content 1to2 -110 / -61 5.8 / 3.0 / 0.093 (64) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 30374 / 0
hug-content 2to3 -59 / -9 19.6 / 0.0 / 0.223 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0
hug-content 3to2 +0 / -8 14.3 / 3.0 / 0.017 (88) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0
hug-content 2to1 -1 / -9 13.9 / 0.0 / 0.017 (88) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0
hug-content back-during-handoff +0 / +0 10.1 / 8.0 / 0.095 (93) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 27926 / 0
capped 1to2 +2 / +2 7.8 / 0.0 / 0.064 (85) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 2717 / 0
capped 2to3 +2 / +15 9.7 / 0.0 / 0.223 (85) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0
capped 3to2 +0 / -2 8.9 / 0.0 / 0.025 (86) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0
capped 2to1 +1 / +1 7.1 / 0.0 / 0.033 (86) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0
capped back-during-handoff +0 / +0 0.0 / 4.6 / 0.068 (94) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0
no-scrim 1to2 +0 / +2 2.1 / 1.1 / 0.017 (86) 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0
no-scrim 2to3 +20 / -12 16.5 / 0.0 / 0.223 (86) 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0
no-scrim 3to2 +0 / +8 14.9 / 3.2 / 0.223 (88) 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0
no-scrim 2to1 +2 / +14 9.5 / 0.0 / 0.223 (86) 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0
no-scrim back-during-handoff +0 / +0 7.2 / 4.0 / 0.057 (91) 0..0 / 0..0 (max per-frame Δ 0.000) 2684 / 0
narrow-viewport 1to2 +1 / -1 11.5 / 7.0 / 0.008 (86) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 5736 / 0
narrow-viewport 2to3 +2 / +17 8.0 / 0.0 / 0.260 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0
narrow-viewport 3to2 +0 / +2 14.1 / 2.4 / 0.012 (88) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0
narrow-viewport 2to1 -6 / -2 9.2 / 0.0 / 0.003 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0
narrow-viewport back-during-handoff +0 / +0 8.7 / 4.0 / 0.080 (92) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 27490 / 0

Dismissal on the active step (step 2), after reopening with focus on the opener (Chromium; reduced motion and WebKit agree):

Story Action main: open after; focus after branch: open after; focus after Native calls (branch)
hug-content dismiss-escape none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
hug-content dismiss-scrim none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
hug-content dismiss-swipe none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
hug-content escape-during-handoff none; body (page) none; body (page) showModal(Notification frequency), close(Set up notifications), close(Notification frequency)
capped dismiss-escape none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
capped dismiss-scrim none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
capped dismiss-swipe none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
capped escape-during-handoff none; body (page) none; body (page) showModal(Notification frequency), close(Set up notifications), close(Notification frequency)
no-scrim dismiss-escape none; body (page) none; body (page) close(Notification frequency)
no-scrim dismiss-scrim Set up notifications; button"Set up notifications" on page Set up notifications; panel[Set up notifications] show(Set up notifications), close(Notification frequency)
no-scrim dismiss-swipe none; button"Set up notifications" on page none; body (page) close(Notification frequency)
no-scrim escape-during-handoff none; body (page) none; body (page) show(Notification frequency), close(Notification frequency), close(Set up notifications)
narrow-viewport dismiss-escape none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
narrow-viewport dismiss-scrim none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
narrow-viewport dismiss-swipe none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
narrow-viewport escape-during-handoff none; body (page) none; body (page) showModal(Notification frequency), close(Set up notifications), close(Notification frequency)
Story First entrance scrim fade (main) (branch) Open-settled px diff Dismissal-settled px diffs Console errors main / branch Overflow
hug-content 0 → 0.423 (100 ms) → 0.5, 10 intermediate frames 0 → 0.423 (100 ms) → 0.5, 10 0 0 / 0 / 0 0 / 0 390=390 / 390=390
capped 0 → 0.422 (100 ms) → 0.5, 10 intermediate frames 0 → 0.422 (100 ms) → 0.5, 10 0 0 / 0 / 0 0 / 0 390=390 / 390=390
no-scrim 0 → 0 (100 ms) → 0, 0 intermediate frames 0 → 0 (100 ms) → 0, 0 0 0 / 0 / 0 0 / 0 390=390 / 390=390
narrow-viewport 0 → 0.422 (100 ms) → 0.5, 10 intermediate frames 0 → 0.422 (100 ms) → 0.5, 10 0 0 / 0 / 0 0 / 0 390=390 / 390=390

No regressions in C. C's eight Core/BottomSheetStacking stories driven identically on C's head and on this branch (open, drill in up to three levels, Escape pop with the dim sampled per frame, "Close all" where offered, and the owner-closes-under-others scrim handoff on the out-of-tree-order story): every state summary, screenshot (0 px), call log and dim range identical. Existing tests pass unedited: the validation set goes from 88 files / 2509 tests on C to 89 / 2537 here (+1 file, +28 tests — the new suite; the three registry-state tests live in C's suite).

Deltas remaining, with root causes

  1. No-scrim flows. By design now: non-modal sheets do not replace, so the No scrim pair is a plain exit and entrance against the switcher's handoff (retained translateY diverges by up to ~290 px: it slides out), and focus follows native show() into each new step where main leaves it on body. Until non-modal hosting reaches the top layer, hasScrim={false} flows keep the switcher (which stays supported); the deprecation says so.
  2. Rapid reversal travel start (hug-content / narrow-viewport, Back at 150 ms): the returning sheet begins its travel back one to two frames later than the switcher's step (≤ 34 px at peak velocity for one or two frames); the retained panel (Δ ≤ 0.2 px), the fade (±1 frame) and the dim (constant 0.5) are identical. Root cause: the reversal re-shows the returning dialog (close() + showModal(), two forced layouts) and runs one extra synchronous render in the same task. Absent without alignment travel (capped: 0.0 px).
  3. Structural, not visual: two open dialogs through a handoff (one at rest) and two native calls per switch where the switcher has one dialog and no calls.

API audit

Core built on C's head and on this branch; every emitted .d.ts and the exports map compared. exports identical. BottomSheet/BottomSheet.d.ts differs only in doc comments. BottomSheet/bottomSheetStack.d.ts (internal; not reachable through the exports map or any public entry) gains the view fields replacedMotion, alignmentOffset, isReplacing, the types BottomSheetReplacedMotion, BottomSheetReplaceMotion, BottomSheetReplaceable, the optional replaceable registration field and reportBottomSheetMotion. Public additions: none.

If #7719 (CSS scroll lock) and/or #7720 (per-step switcher dialogs) land on main first

Noted, not implemented. #7719: the registry does not touch scroll locking; useScrollLock(shouldPresent && hasScrim) in both hosts is the only coupling, and the handoff keeps the lock held through the whole switch (both dialogs presented), so a CSS lock changes nothing here beyond the rebase. #7720: a switcher whose steps each own a dialog is no longer one dialog to register; C's "a whole switcher is one level" would become "the active step's dialog is the flow's level" — the switcher would register the active step's dialog (re-pointing getElement/show/hide on each handoff, with the same key so the rank is kept) and this PR's replace would need to exclude switcher-owned step dialogs from pairing, or the switcher could drop its own handoff machinery and let its steps be ordinary sheets that replace through this registry — at which point #7720's prototype and this PR converge, and the switcher becomes a thin controller over isOpen.

Scope

  • This PR has one primary intent.
  • Independently removable deltas were removed or split (the registry hardening is in C; the deprecation is stacked on this PR).
  • Public text and artifacts contain no internal Meta context.

Testing

pnpm exec vitest run packages/core/src/{BottomSheet,Dialog,AlertDialog,Selector,DropdownMenu,ContextMenu,DateInput,DateTimeInput,TimeInput,Tokenizer,Layer,Layout,CommandPalette,MoreMenu,Popover,Lightbox,MobileNav}/ packages/lab/src/MobileTokenizer/
  main:    Test Files 87 passed (87) · Tests 2454 passed (2454)
  C head:  Test Files 88 passed (88) · Tests 2509 passed (2509)
  branch:  Test Files 89 passed (89) · Tests 2537 passed (2537)
  DropdownMenuLazySheet alone: 1 file, 2 tests passed
pnpm -F @astryxdesign/core typecheck                                   ✓
ASTRYX_STRICT_LINT=1 eslint packages/core/src/BottomSheet apps/storybook/stories/BottomSheetReplace.stories.tsx apps/storybook/stories/BottomSheetStacking.stories.tsx   0 errors (1 pre-existing warning in an untouched chromium spec)
pnpm check:sync · check:knowledge · check:package-boundaries · check:changesets   ✓
node scripts/sync-exports.js --check · git diff --check                 ✓
pnpm exec vitest run --project node scripts/build-css.test.mjs          11 passed (no transition-duration:0s outside @media; the 0s pin is an inline custom property)
node scripts/release/changeset-coverage.mjs pr --base feat/core-bottom-sheet-open-order --head HEAD --released v0.7.1   ✓
node scripts/release/changeset-coverage.mjs pr --base origin/main --head HEAD --released v0.7.1                        ✓
pnpm -F @astryxdesign/core build (C head and branch) + .d.ts / exports diff    see API audit
pnpm storybook:build                                                    ✓
Chromium 390×844 (touch), default and reduced motion; WebKit 26.5; static Storybooks: 4 story pairs × (4 switches + reversal + 4 dismissals + open fade); C's 8 stacking stories on C head vs branch   see Evidence

Verdict

Yes, for modal flows; the no-scrim flow is the one gap. On top of C, a switcher flow rebuilt from standalone sheets is pixel-identical at rest and mid-handoff, frame-identical in motion and timing (within one frame), identical in scrim behaviour (one steady dim, no dip, also under reduced motion), inertness, hit-testing, dismissal and focus — for modal flows, which is what BottomSheetSwitcher is used for. The replace is recognised without a new API by a rule that excludes the unrelated-owners case the verification found. The switcher can be deprecated now (the stacked PR does it) and removed in a later minor once non-modal hosting has moved to the top layer, which is the one case the switcher still covers alone.

@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 20, 2026 11:01pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 20, 2026
@astracat-bot

astracat-bot Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🔄 In progress (for maintainers only) Oct 20, 2026, 10:56 PM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough prototype. The measured handoff tracks BottomSheetSwitcher closely, but approval waits on a maintainer deciding whether a standalone BottomSheet should implicitly treat the top sheet closing while one other sheet opens in the same update as a replace. That decision also needs to name the spec record that will own the rule, because the BottomSheet and BottomSheetSwitcher records are both still draft. Whatever the decision, these points need changes:

  • The non-modal path orders the two dialogs with a page-level z-index: 1001, which AST-027 rules out; keeping non-modal sheets out of replace pairing would resolve it.
  • BottomSheet.doc.mjs still sends multi-step flows to BottomSheetSwitcher (the hasScrim prop doc and both descriptions), while the new paragraph says no switcher is needed.
  • The new setIsCloseSettled(false) reset in a layout effect names no external system, and the animation frames that writeScrimInstantly schedules are not cancelled on unmount.

[Automated review]

// A non-modal sheet replacing another must paint above it whatever their
// document order; show() dialogs have no top layer to order them.
dialogNonModalReplacing: {
zIndex: 1001,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This orders two non-modal show() dialogs with a page-level number: during a replace the incoming dialog computes z-index: 1001 against the outgoing dialog's 1000, both position: fixed. AST-027 (DEC-1, FR4, FR8) keeps z-index local and rules out a larger number as a substitute for top-layer promotion. Keeping non-modal sheets out of replace pairing until their hosting moves to the top layer would avoid adding a second page band.

// eslint-disable-next-line @eslint-react/set-state-in-effect -- latches controlled open state for exit animation
setIsPresented(true);
// eslint-disable-next-line @eslint-react/set-state-in-effect -- the next close starts unsettled again
setIsCloseSettled(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reset runs in an Effect, but its reason does not name an external system or a DOM measurement (react-component-runtime INV3). It also costs an extra synchronous render after every open. Resetting isCloseSettled during render when isOpen turns true, or keying the flag to the close it settles, avoids both.

if (scrimDurationRestoreRef.current != null) {
cancelAnimationFrame(scrimDurationRestoreRef.current);
}
scrimDurationRestoreRef.current = requestAnimationFrame(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These chained frames are cancelled only by the next write; nothing cancels them on unmount (react-component-runtime INV5). Cancelling scrimDurationRestoreRef in the registration effect's cleanup would close that.

anatomy,
description:
"A mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for multi-step flows that replace one step with the next, and open a second sheet over the first when a step should stay visible beneath it.\n\nStacking. Every sheet keeps its own isOpen. Open a sheet while another scrim sheet is open and it stacks on top: the sheet beneath stays mounted with its scroll position and inputs, recedes behind the new one (the nearest covered sheet shrinks to 0.96 and lifts 8px, deeper ones to 0.92 and 16px), and is blocked from pointer, keyboard, and assistive technology by the top sheet's modal dialog. The sheets can be siblings, live in separate components, sit inside another sheet's content, or sit inside a BottomSheetSwitcher step; a whole switcher is one level, and switching its step in place does not change its position. Nothing moves in the DOM: each sheet's dialog stays where it is written, and no wrapper component is needed.\n\nOrder is open order. The top sheet is the one presented most recently that is still open; a sheet that closes and reopens comes back on top. When several sheets open in one update (a deep link into a flow), there is no open order between them, so tree order decides: a sheet below the sheets rendered in its content, siblings in the order they are written. That order is right in the first painted frame.\n\nOne scrim. Only the bottom sheet's backdrop dims the page; the backdrops of the sheets above it are transparent, so a stack never darkens per level. The dim holds steady while sheets push and pop, and if the bottom sheet closes while sheets remain above it, the next sheet's backdrop takes over in the same frame.\n\nDismissal. Escape, a tap on the scrim, a downward swipe, and a platform close request (Android back, a close watcher) reach only the top sheet, through that sheet's own onOpenChange, and that sheet's purpose applies: required blocks all of them, form blocks the scrim and the swipe. Nothing closes a whole stack at once; set every sheet closed in one update when a flow is done and they leave together.\n\nClosing a covered sheet from code. Sheets rendered inside the closing sheet's content are part of it: they are presented only while it is open, and when it closes their onOpenChange(false) is called and they leave with it. Sheets above it that are not in its content stay open and keep their order; the closing sheet slides out from beneath them, and if it owned the scrim, the sheet above takes it over.\n\nFocus. Opening a sheet moves focus into it (a data-autofocus descendant first). Closing the top sheet returns focus into the sheet it reveals: to the control that opened it when that control is still there, otherwise to the revealed sheet's panel. When the last sheet closes, the standalone rules apply: finalFocusRef, else the element that opened the sheet, with a focus ring only after keyboard input.\n\nCaveats. A Dialog, AlertDialog, or Popover opened from a sheet paints above the sheet and does not make it recede; a sheet opened from inside that Dialog paints above the Dialog and stacks over the sheet beneath. Sheets with hasScrim={false} stay out of the stack entirely. A sheet React hides (a suspended Suspense boundary, Activity mode=\"hidden\") leaves the stack and closes its dialog; when it is shown again it is presented again, on top. Only sheets rendered in the same document stack; a sheet inside an iframe is its own stack.",
"A mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for multi-step flows that replace one step with the next, and open a second sheet over the first when a step should stay visible beneath it.\n\nStacking. Every sheet keeps its own isOpen. Open a sheet while another scrim sheet is open and it stacks on top: the sheet beneath stays mounted with its scroll position and inputs, recedes behind the new one (the nearest covered sheet shrinks to 0.96 and lifts 8px, deeper ones to 0.92 and 16px), and is blocked from pointer, keyboard, and assistive technology by the top sheet's modal dialog. The sheets can be siblings, live in separate components, sit inside another sheet's content, or sit inside a BottomSheetSwitcher step; a whole switcher is one level, and switching its step in place does not change its position. Nothing moves in the DOM: each sheet's dialog stays where it is written, and no wrapper component is needed.\n\nOrder is open order. The top sheet is the one presented most recently that is still open; a sheet that closes and reopens comes back on top. When several sheets open in one update (a deep link into a flow), there is no open order between them, so tree order decides: a sheet below the sheets rendered in its content, siblings in the order they are written. That order is right in the first painted frame.\n\nOne scrim. Only the bottom sheet's backdrop dims the page; the backdrops of the sheets above it are transparent, so a stack never darkens per level. The dim holds steady while sheets push and pop, and if the bottom sheet closes while sheets remain above it, the next sheet's backdrop takes over in the same frame.\n\nDismissal. Escape, a tap on the scrim, a downward swipe, and a platform close request (Android back, a close watcher) reach only the top sheet, through that sheet's own onOpenChange, and that sheet's purpose applies: required blocks all of them, form blocks the scrim and the swipe. Nothing closes a whole stack at once; set every sheet closed in one update when a flow is done and they leave together.\n\nClosing a covered sheet from code. Sheets rendered inside the closing sheet's content are part of it: they are presented only while it is open, and when it closes their onOpenChange(false) is called and they leave with it. Sheets above it that are not in its content stay open and keep their order; the closing sheet slides out from beneath them, and if it owned the scrim, the sheet above takes it over.\n\nReplacing a sheet. Close the top sheet and open another in the same update (one state value that names the open step does this) and the new sheet replaces it instead of stacking on it: the new sheet rises above the one it replaces; if the replaced sheet is taller it moves down to meet the new sheet's top edge at the same time; the replaced sheet fades only after both motions finish, and is inert and hidden from assistive technology from the start. The scrim holds steady throughout, with no dip; focus moves into the new sheet; and when the flow closes, focus returns to the element that opened its first sheet, whichever sheet closed last. This is the handoff BottomSheetSwitcher runs between its steps, so a multi-step flow needs no switcher: give each step its own BottomSheet and one state value. A replace happens only when the closing sheet was the top sheet of its stack and exactly one sheet closes and one opens in the update; a covered sheet closing while a sheet opens, or a close and an open in different updates, keep the ordinary push and pop motion. A replace over an open stack runs one level up and leaves the sheets beneath covered. Non-modal sheets (hasScrim={false}) replace each other the same way, without a scrim.\n\nFocus. Opening a sheet moves focus into it (a data-autofocus descendant first). Closing the top sheet returns focus into the sheet it reveals: to the control that opened it when that control is still there, otherwise to the revealed sheet's panel. When the last sheet closes, the standalone rules apply: finalFocusRef, else the element that opened the sheet, with a focus ring only after keyboard input.\n\nCaveats. A Dialog, AlertDialog, or Popover opened from a sheet paints above the sheet and does not make it recede; a sheet opened from inside that Dialog paints above the Dialog and stacks over the sheet beneath. Sheets with hasScrim={false} stay out of the stack entirely. A sheet React hides (a suspended Suspense boundary, Activity mode=\"hidden\") leaves the stack and closes its dialog; when it is shown again it is presented again, on top. Only sheets rendered in the same document stack; a sheet inside an iframe is its own stack.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new "Replacing a sheet" paragraph says a multi-step flow needs no switcher. This description still opens with "use BottomSheetSwitcher for multi-step flows that replace one step with the next", and the dense description (line 399) and the hasScrim doc (line 168) say the same. Whichever way the decision goes, all three should agree.

A modal BottomSheet opened while another modal sheet is open stacks on top
of it: the covered sheet stays mounted with its state, recedes (0.96 / -8px,
then 0.92 / -16px) and is blocked; only the bottom sheet's backdrop dims the
page; Escape, a scrim tap, a swipe and a platform close request pop only the
top sheet through its own callback. Order is open order wherever the sheets
are written (siblings, separate components, inside another sheet's content,
inside a BottomSheetSwitcher step; a whole switcher is one level). Sheets
opened in one commit are ordered by tree, right in the first painted frame,
through a private commit-phase registry that reconciles the native top layer.
A sheet rendered in a closing sheet's content closes with it. No public API
change; a sheet on its own behaves exactly as before.

Candidate C for the AST-044 stacked-sheet decision, alongside #6728 and #7503.
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

Folded into #7648, which now carries open-order stacking, the replace handoff, and the BottomSheetSwitcher deprecation as one change.

@github-actions
github-actions Bot deleted the feat/sheet-replace-on-open-order branch October 21, 2026 06:22

This branch was successfully deployed

1 active deployment
Preview — 2d858195 Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant