Skip to content

feat(Drawer): recede a buried drawer so a stack reads as layered pages - #5652

Open
freddymeta wants to merge 7 commits into
mainfrom
feat/drawer-nested-stack
Open

feat(Drawer): recede a buried drawer so a stack reads as layered pages#5652
freddymeta wants to merge 7 commits into
mainfrom
feat/drawer-nested-stack

Conversation

@freddymeta

Copy link
Copy Markdown
Contributor

Stacked on #5550 — the commits below it ARE #5550. Based on main rather than that branch because CI only runs for PRs targeting main, so a stacked base means no CI and no Storybook preview. Review #5550 first; this rebases to a single commit the moment it lands.

The gap

Open a second drawer over a first and the first is simply gone. Nothing says where the user came from, or how deep they are — the panel behind is covered by an identically-shaped panel, so a two-level flow reads exactly like a one-level flow that changed its contents.

EPS solves this in its own Sheet, and the effect is worth having here: the panels behind the front one recede, so the stack reads as layered pages.

What it does

A drawer with drawers stacked on top withdraws toward its own closing edge and shrinks a little per level, keeping each leading edge visible beside the one in front, with rounded corners so it reads as a card behind rather than a clipped panel. The transform origin is pinned to the closing edge, so the shrink is a withdrawal into the page rather than a panel floating free of it. Both flip under RTL.

Nothing to opt into: it follows the stacking contract Drawer already documents. hasStackRecede={false} opts a drawer out.

nested drawer stack

(Assets live on the assets/pr-drawer-stack branch — asset-only, deletable with the PR. I have no fork.)

Themeable, not per-call

The geometry is a design decision, so it belongs to the theme rather than to each call site. Four vars are declared on the element carrying the drawer theme target, the same door --spinner-diameter opens:

var default what it does
--drawer-stack-peek 40px how far each buried level slides out from behind the one in front
--drawer-stack-scale-step 0.04 how much it shrinks per level, unitless
--drawer-stack-min-scale 0.8 floor, so a deep stack levels off
--drawer-stack-radius var(--radius-element) corner radius while buried
drawer: {base: {'--drawer-stack-peek': '80px', '--drawer-stack-scale-step': '0.1'}}

Depth is mirrored to data-stack-depth on the panel, so the state is inspectable and an app can hang its own rules off it.

Two deliberate departures from the EPS implementation

Depth comes from the stack registry, not from React nesting. EPS threads a context up through physically nested sheets. Drawer's documented contract is the opposite — siblings stack, never nest them — so a nesting context would find nothing to walk, and EPS needs a nestedDepth escape hatch for exactly the flat-sibling case Drawer mandates. The LIFO registry already holds the order; it gains a subscription and useSyncExternalStore reads it, with a server snapshot of 0. No escape hatch needed.

The shrink is a ratio per level, not a px inset. EPS insets the cross-axis edge by 16px per level, which has to be divided by the measured panel to become a scale() — that costs a ResizeObserver, and it makes the recede shallower on a tall screen than a short one. A unitless ratio is resolution-independent, needs no measurement, and is directly themeable.

Measured

Chromium, against the built packages/lab/dist, three drawers deep:

data-stack-depth transform origin radius
Level 1 2 matrix(0.92, 0, 0, 0.92, -80, 0) closing edge 8px
Level 2 1 matrix(0.96, 0, 0, 0.96, -40, 0) closing edge 8px
Level 3 (front) absent matrix(1, 0, 0, 1, 0, 0) centre 0px

Overriding the two vars to 80px / 0.1 moved them to scale(0.8) — clamped by the floor — and scale(0.9), at -160px / -80px. With hasStackRecede={false} all three sat at matrix(1, 0, 0, 1, 0, 0). Unwinding 3 → 2 → 1 returned each panel to rest.

Tests

63 pass. The four new ones cover depth by level, the unwind, the opt-out, and the vars being declared on the themeable element; reverting the recede wiring fails exactly the two behavioural ones. Full build, lab/docs/Storybook typechecks, check:repo and lint:strict pass.

Storybook: Lab/DrawerNestedStack.

No changeset: @astryxdesign/lab is private and not released.

freddytan and others added 6 commits August 27, 2026 17:33
…iewport

Rebuilt on today's main as one commit. The branch carried eight,
including #5549's two (landed, and dropped here) and four that walked
the modality API out and back again — noise in a review, so they are
squashed into the shape they arrived at.

Rebased onto `useDrawerDialogPresence`, which #5549 extracted. The hook
gains one optional field, `mountHost`: a ref is stable, so nothing in it
can observe the dialog ELEMENT arriving, and a bounded Drawer renders no
dialog until its container resolves one commit later. Without naming the
host as a dependency an already-open bounded Drawer never opens at all —
which is exactly how the rebase first failed its own test.

Bounded mode, unchanged from the reviewed head:

- the panel is portalled into the container and pinned to its
  SCROLLPORT, so scrolling the pane 180px no longer carries the drawer
  180px with it;
- `isModal` blocks the container with `inert` rather than the top layer,
  so the keyboard is blocked exactly as far as the pointer is;
- target resolution is deferred until the panel paints and survives the
  host being swapped or detached.

New in this round, from review: the inert pass is held for the whole
open lifetime instead of stamped once. A pane is live content — a row
streams in, a menu opens, a lazy panel resolves — and a one-shot pass
left every later arrival focusable behind a scrim that already blocked
the pointer. A `childList` MutationObserver re-runs it; the drawer still
hands back only what it inerted, so a container someone else marked is
returned untouched.

Two tests, both verified failing against a one-shot pass.
CI caught what the lab typecheck could not: the sandbox app renders
Drawer with `hasScrim={false}` in two places, and `packages/lab`'s
tsconfig only covers its own `src`, so nothing local failed. Both meant
non-modal, which is what they now say.

The third `hasScrim` in the repo is BottomSheet's, which keeps the name
and is untouched.
Follow the reviewed API ruling instead of combining enforcement and paint in
`isModal`:

- `containerRef` chooses where the Drawer is scoped;
- `modality="modal" | "nonModal"` chooses whether that scope is blocked;
- `hasScrim` chooses whether it is dimmed, defaulting to modality.

The axes are real in both viewport and bounded modes. A non-modal scrim is
paint-only and pointer-transparent; a modal without a scrim still blocks. A
bounded modal keeps current and late-inserted siblings inert through the exit
transition. A live modality change reopens the native dialog with show() or
showModal(), so native enforcement cannot drift from the prop.

Remove the superseded public `isModal` prop and migrate all branch-owned call
sites, docs, examples, and the existing Storybook story. The story now exposes
all four combinations. Unit coverage proves all viewport and bounded
combinations; coupling paint back to modality makes three focused tests fail.

Chromium verified the full 2x2x2 matrix (viewport/bounded x modal/nonModal x
scrim/clear): painted pixels were 153,160,169 vs 255,255,255; modal controls
received 0 clicks, non-modal controls received 1; bounded modal content was
inert; both non-modal scrims had pointer-events:none.
Harden the independent modality axis in two transition states:

- an open native dialog cannot switch between show() and showModal() in place,
  so track its opening mode and synchronously close/reopen when modality changes;
- bounded background content stays inert for the whole rendered lifetime,
  including the slide-out, instead of becoming keyboard-active before the
  pointer dismissal plane has left.

Extend the unit guard through modal -> nonModal in both directions and assert
bounded inert cleanup only after the transform transition. Correct the Chinese
docs that still claimed a bounded Drawer could never be modal.
Opening a second drawer over a first replaced it outright: the panel behind
vanished under the new one, so nothing said where the user came from or how
deep they were. The EPS Sheet solves this by receding the panels behind the
front one, and this brings that to Drawer.

A drawer with drawers stacked on top withdraws toward its own closing edge and
shrinks per level, keeping each leading edge visible beside the one in front,
with rounded corners so it reads as a card behind rather than a clipped panel.
The origin is pinned to the closing edge, so the shrink is a withdrawal into
the page rather than a panel floating free. Both flip under RTL.

Two deliberate departures from the EPS implementation:

- Depth comes from the existing LIFO stack registry, not from React nesting.
  Drawer's documented contract is that siblings stack and must never be
  nested, so a nesting context would find nothing to walk; the registry
  already holds the order. It gains a subscription and `useSyncExternalStore`
  reads it, with a server snapshot of 0.
- The shrink is a unitless ratio per level rather than a px inset. An inset
  has to be divided by the measured panel to become a scale, which costs a
  ResizeObserver and recedes less on a tall screen than a short one.

The geometry is themeable rather than per-call, following the Spinner var
precedent: `--drawer-stack-peek`, `--drawer-stack-scale-step`,
`--drawer-stack-min-scale` and `--drawer-stack-radius` are declared on the
element carrying the `drawer` theme target. `hasStackRecede={false}` opts a
drawer out. Depth is mirrored to `data-stack-depth` so the state is
inspectable and an app can hang its own rules off it.

Chromium, three deep: depths 2/1/0, buried panels at scale(0.92)/scale(0.96)
translated -80px/-40px with 8px corners and the origin on the closing edge,
the front panel untouched at scale(1). Overriding the two vars to 80px/0.1
moved them to scale(0.8) (clamped by the floor) and scale(0.9) at
-160px/-80px. Unwinding the stack returned each panel to rest.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 28, 2026
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 29, 2026 6:13am

Request Review

@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Aug 28, 2026
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Drawer (@astryxdesign/lab) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 723 -
Complexity N/A Very High (83) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/lab N/A 3.5KB 1.5KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

github-actions Bot added a commit that referenced this pull request Aug 28, 2026
Only conflict was the generated motionAudit.ts. Taken from main verbatim
rather than regenerated: main's own committed copy does not reproduce
locally either (the generator emits 284 lines here against 1368
committed), so a local regen would have written a file that disagrees
with every other branch.

63 Drawer tests pass.
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. community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant