From 8701a86ad34489555d6ec83d2379975bf4d30e19 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 18:30:48 -0600 Subject: [PATCH 1/8] feat(ui): stacked dialog motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One backdrop for the whole stack. A stacked dialog paints none, and the root-level dialog's scrim survives underneath it. The scrim it replaces was solved so that two levels composited to an intended total, which held only for the two-deep case: alpha over alpha compounds, so the three-deep stack this exists for went from an intended 0.68 to 0.83. Depth comes from the surface beneath receding instead — scale and a lift, with the radius divided by the same factor so the corners render unchanged, the correction `ENTER_SCALE` already documents. Only `prompt` recedes; `panel` and `card` are root-level surfaces and leave the stacked prompt's shadow to carry the separation. A dialog stacked inside another one warns in development if it is any size but `prompt`, which covers panel-in-panel and card-in-panel without enumerating what may host what. The recede stays live on the phone band where the entrance scale is pinned flat: those are different gestures, and a stacked sheet covers enough of what is beneath it that dropping the recede would leave that level with no cue at all. Keyed on `data-stacked`, not `data-nested` — the latter reports any floating ancestor, so a dialog opened from a menu item would have lost its only scrim. --- .changeset/dialog-stack-motion.md | 5 ++ .../swingset/src/stories/dialog.component.mdx | 29 +++++++- .../src/stories/dialog.component.stories.tsx | 69 +++++++++++++++++- .../mosaic/components/dialog/dialog.styles.ts | 71 ++++++++++++++++--- .../mosaic/components/dialog/dialog.test.tsx | 69 ++++++++++++++++-- .../src/mosaic/components/dialog/dialog.tsx | 24 +++++++ 6 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 .changeset/dialog-stack-motion.md diff --git a/.changeset/dialog-stack-motion.md b/.changeset/dialog-stack-motion.md new file mode 100644 index 00000000000..8acfaa74312 --- /dev/null +++ b/.changeset/dialog-stack-motion.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Mosaic `Dialog`s stacked on one another now share a single backdrop instead of each painting its own, so the page no longer darkens further with every level. The dialog beneath a stacked `prompt` recedes slightly to signal the layering. Only `prompt` dialogs are meant to stack; opening a `panel` or `card` inside another dialog now warns in development. diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index ebe7b975a50..b6fa8818201 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -251,9 +251,22 @@ which also shrinks the visual viewport — is excluded. ### Stacked dialogs -A dialog opened from inside another one carries `data-nested` and paints its own, lighter scrim, so -each level reads as a step further from the page without the backdrops compounding toward an -opaque wall. +Only a `prompt` stacks. `panel` and `card` are root-level surfaces — they host a stack, they are +never the thing stacked — and a dialog opened inside another one warns in development if it is any +other size. + +A stacked dialog carries `data-stacked` and paints **no** scrim: one backdrop serves the whole +stack, so how dark the page goes never depends on how deep the stack is. The dialog beneath carries +`data-stack-base`, and if it is a `prompt` it recedes — scaling down slightly and lifting, with its +radius divided by the same factor so the corners render unchanged. That recede and the stacked +surface's own shadow are the entire depth cue, so a stacked prompt over a `panel` (which never +recedes) leans on the shadow alone. + +`data-stacked` is narrower than the older `data-nested`, which reports any floating ancestor: a +dialog opened from a menu item is nested but not stacked, and still owns its scrim. + +Under `prefers-reduced-motion: reduce` nothing recedes — the surface beneath holds still and the +stacked one simply appears over it. --- @@ -342,6 +355,16 @@ prompt. storyModule={DialogStories} /> +A `panel` never recedes, so what separates the two levels here is the prompt's own shadow. + +Stack a prompt on a prompt and the one beneath moves instead — the shape a close confirmation +takes: + + + Nest by rendering a `Dialog` inside another one's children. Nothing else is required — the inner dialog finds the outer through Floating UI's tree and wires up its own stacking: diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 2d5e857a58d..640fcd51b9e 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -154,7 +154,7 @@ function AddValueDialog({ ); } -/** A `panel` account surface with `card` dialogs opened from inside it. */ +/** A `panel` account surface with `prompt` dialogs opened from inside it. */ export function Nested() { return ( ({ when: SESSION_TIMES[index % SESSION_TIMES.length], })); +const editProfileTrigger = (props: RenderProps) => ; + +const discardTrigger = (props: RenderProps) => ( + +); + +/** + * A prompt stacked on a prompt — the shape a close confirmation takes. The second prompt paints + * no scrim of its own; the one beneath it recedes instead. + */ +export function StackedPrompts() { + return ( + + {({ close }) => ( + <> + + }>Update profile + }>Change the name people see on your account. + +
+ + {({ close: closeConfirmation }) => ( + <> + }>Discard changes? + }>Your edits will be lost. +
+ + +
+ + )} +
+ +
+ + )} +
+ ); +} + /** The panel clips rather than scrolling, so the scroll region is composed inside it. */ export function PanelSidebar() { return ( diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 181e0dc29f4..1e65e792c69 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -10,18 +10,24 @@ export const styles = stylex.create({ // Black in both schemes. A grey veil was tried for dark mode — lightening a dark page rather // than darkening it — and it read as haze over the page rather than as a surface lifting off it. // - // A stacked dialog paints its OWN scrim rather than deferring to the one beneath it, so each - // level reads as a step further from the page. It is lighter than the base because the two - // COMPOSITE: alpha over alpha is `1 − (1 − a)(1 − b)`, so the nested value is solved for the - // intended total rather than picked by eye — `1 − 0.32/0.6 = 0.4667` lands two levels on 0.68. - // Exact for a two-deep stack, which is the shape that exists; a third level would go darker - // still, and wants its own value rather than a third application of this one. - // `data-nested` comes from the headless layer. + // ONE scrim for the whole stack: a stacked dialog paints none, and the root-level dialog's + // survives underneath it. The stack reads as depth through the surface beneath receding + // (`popupMotion.prompt`), not through the page going darker. + // + // The alternative — each level painting a lighter scrim solved so the composite lands on an + // intended total — was here first, and worked only for the two-deep case it was solved for. + // Alpha over alpha is `1 − (1 − a)(1 − b)`, so every level compounds: a third took the same + // three-deep stack this feature exists for from an intended 0.68 to 0.83, and "how dark is the + // page" became a function of stack depth. + // + // Keyed on `data-stacked`, NOT `data-nested`: the latter reports any floating ancestor, so a + // dialog opened from a menu item would drop the only scrim it has. Both come from the headless + // layer. backdrop: { inset: 0, backgroundColor: { default: 'color-mix(in oklab, oklch(0 0 0) 40%, transparent)', - ':where([data-nested])': 'color-mix(in oklab, oklch(0 0 0) 46.67%, transparent)', + ':where([data-stacked])': 'transparent', }, position: 'fixed', }, @@ -424,6 +430,23 @@ export const backdropMotion = stylex.create({ const SHEET_EXIT_EASE = 'ease-out'; const ENTER_SCALE = 0.94; + +// How far a prompt recedes while another prompt is stacked on it, and the radius that survives +// that scale — the same `r/s` correction `ENTER_SCALE` documents above, for the same reason. +// +// Shallower than the entrance scale on purpose: the entrance is a surface arriving from nowhere, +// while this is a surface that stays legible the whole time and only has to read as further back. +// The lift is what separates it from the entrance rather than the depth of the scale — a surface +// that only shrinks reads as being pushed away, one that shrinks and rises reads as being layered +// over, which is the relationship this actually is. +// +// A single step rather than a `--cl-stack-index` formula: the headless layer counts DIRECT +// children, so a third level would report the same 1 as the second and every level below the top +// would recede identically anyway. The formula and the cumulative count belong in the same change, +// whenever a stack deep enough to need them turns up. +const STACK_SCALE = 0.96; +const STACK_LIFT = '-0.5rem'; + const popupRadius = radiusVars['--cl-radius-xl']; export const popupMotion = stylex.create({ @@ -437,15 +460,22 @@ export const popupMotion = stylex.create({ prompt: { borderRadius: { default: popupRadius, + // The recede is the one scale that survives the phone band, so unlike the entrance its + // radius correction is NOT pinned flat there — see `transform` below. + ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`, ':where([data-starting-style], [data-ending-style])': `calc(${popupRadius} / ${ENTER_SCALE})`, '@media (max-width: 47.99rem)': { default: popupRadius, + ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`, ':where([data-starting-style], [data-ending-style])': popupRadius, }, - // Both branches resolve to the same value, so their order relative to each other cannot - // matter: there is no scale to counteract in either case. + // Both entrance branches resolve to the same value, so their order relative to each other + // cannot matter: there is no scale to counteract in either case. The stack branch is here + // for the same reason it is on `transform` — under `reduce` nothing scales, so there is + // nothing to correct. '@media (prefers-reduced-motion: reduce)': { default: popupRadius, + ':where([data-stack-base])': popupRadius, ':where([data-starting-style], [data-ending-style])': popupRadius, }, }, @@ -481,13 +511,34 @@ export const popupMotion = stylex.create({ */ transform: { default: 'scale(1)', + /** + * The recede: what a prompt does while another prompt is stacked on it. There is no second + * scrim, so this and the stacked surface's own shadow are the entire depth cue. + * + * Kept ON the phone band, where the entrance scale is pinned flat. Those are different + * gestures and the reasoning does not carry over: the entrance pin exists because stacking a + * shrink on top of a full-height slide makes the sheet arrive small and settle. A sheet + * receding under another sheet is the familiar one — it is what vaul does — and on a phone, + * where a stacked sheet covers most of what is beneath it, dropping the recede would leave + * the level below with no depth cue at all. + * + * `@stylexjs/sort-keys` puts this branch before the entrance one, so an exit that somehow + * begins while a child is still open renders the exit scale rather than the recede. Nothing + * ordinary reaches that state — floating-ui blocks the parent's own dismissal while a child + * is open — and the exit scale is the better of the two to see if anything ever does. + */ + ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, ':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE})`, '@media (max-width: 47.99rem)': { default: 'scale(1)', + ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, ':where([data-starting-style], [data-ending-style])': 'scale(1)', }, '@media (prefers-reduced-motion: reduce)': { default: 'scale(1)', + // Reduced motion drops the recede entirely rather than snapping to it: the level below + // holds still and the stacked surface simply appears over it. + ':where([data-stack-base])': 'scale(1)', ':where([data-starting-style], [data-ending-style])': 'scale(1)', }, }, diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx index 9b7ff5eb24e..43464782b69 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -245,27 +245,84 @@ describe('stacked backdrops', () => { ); - it('marks only the inner backdrop as nested, so the scrims do not compound', async () => { - const user = userEvent.setup(); - render( + function renderStack() { + return render( + Account
Outer body
+ Add email address
Inner body
, ); + } - expect(document.querySelector('.cl-dialog-backdrop')).not.toHaveAttribute('data-nested'); + it('marks only the stacked backdrop, so one scrim paints for the whole stack', async () => { + const user = userEvent.setup(); + renderStack(); + + expect(document.querySelector('.cl-dialog-backdrop')).not.toHaveAttribute('data-stacked'); await user.click(screen.getByRole('button', { name: 'Add email' })); const backdrops = document.querySelectorAll('.cl-dialog-backdrop'); - expect(backdrops[0]).not.toHaveAttribute('data-nested'); - expect(backdrops[1]).toHaveAttribute('data-nested', ''); + expect(backdrops[0]).not.toHaveAttribute('data-stacked'); + expect(backdrops[1]).toHaveAttribute('data-stacked', ''); + }); + + it('marks the popup beneath as the stack base, so it can recede', async () => { + const user = userEvent.setup(); + renderStack(); + + const outerPopup = document.querySelector('.cl-dialog-popup'); + expect(outerPopup).not.toHaveAttribute('data-stack-base'); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + + const popups = document.querySelectorAll('.cl-dialog-popup'); + expect(popups[0]).toHaveAttribute('data-stack-base', ''); + expect(popups[1]).not.toHaveAttribute('data-stack-base'); + }); + + it('warns when a stacked dialog is not a prompt', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const user = userEvent.setup(); + render( + + Account +
Outer body
+ + Add email address +
Inner body
+
+
, + ); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('size="card"')); + warn.mockRestore(); + }); + + it('does not warn for a stacked prompt, or for a root-level panel', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const user = userEvent.setup(); + renderStack(); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); }); }); diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx index 14d08dabf45..af5f84c39cf 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -206,16 +206,40 @@ const Viewport = React.forwardRef(function ); }); +/** + * Warns when a dialog stacked on another one is not a `prompt`. + * + * Only `prompt` stacks. `panel` and `card` are root-level surfaces: they host a stack, and the + * styles that make one work — dropping the scrim, receding behind the surface above — exist for + * `prompt` alone, so a `panel` opened inside a dialog silently renders with neither. + * + * One rule stated on the child covers every case, without having to enumerate which sizes may + * host what. + */ +function useStackedSizeWarning(isStacked: boolean, size: DialogSize) { + React.useEffect(() => { + if (process.env.NODE_ENV === 'production' || !isStacked || size === 'prompt') { + return; + } + console.warn( + `Mosaic: a Dialog opened inside another Dialog should be size="prompt", but this one is size="${size}". ` + + 'Only prompts are styled to stack — this dialog will paint no backdrop and the surface beneath it will not recede.', + ); + }, [isStacked, size]); +} + /** The dialog surface: `role="dialog"`, focus-trapped, and the element that paints. */ const Popup = React.forwardRef(function DialogPopup( { className, style, ...rest }, ref, ) { const size = React.useContext(DialogSizeContext); + const { isStacked } = useDialogContext(); // Observed through state rather than a plain ref, because the warning has to re-run when the // node arrives and a ref mutation does not re-render. const [node, setNode] = React.useState(null); useAccessibleNameWarning(node, 'Dialog'); + useStackedSizeWarning(isStacked, size); const mergedRef = React.useCallback( (element: HTMLDivElement | null) => { From 95adb9aaac67d3bf4e50aa627e5b5243c8339123 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 20:29:56 -0600 Subject: [PATCH 2/8] feat(ui): dim the surface beneath a stacked dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recede alone reads as smaller more than as further back. Veiling the contents toward the surface's own background separates the layers on its own — which is what `panel` and `card` get, since neither moves. A veil rather than `opacity` on the popup: fading the popup fades the surface with it, letting the scrim through, which reads as the dialog dissolving rather than as depth. --- .../swingset/src/stories/dialog.component.mdx | 15 ++++--- .../mosaic/components/dialog/dialog.styles.ts | 43 +++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index b6fa8818201..f5682649a7d 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -257,16 +257,16 @@ other size. A stacked dialog carries `data-stacked` and paints **no** scrim: one backdrop serves the whole stack, so how dark the page goes never depends on how deep the stack is. The dialog beneath carries -`data-stack-base`, and if it is a `prompt` it recedes — scaling down slightly and lifting, with its -radius divided by the same factor so the corners render unchanged. That recede and the stacked -surface's own shadow are the entire depth cue, so a stacked prompt over a `panel` (which never -recedes) leans on the shadow alone. +`data-stack-base`, and its contents dim toward its own background — enough on its own to read as a +layer further back, which is what a `panel` gets, since a panel never moves. A `prompt` also +recedes, scaling down slightly and lifting, with its radius divided by the same factor so the +corners render unchanged. `data-stacked` is narrower than the older `data-nested`, which reports any floating ancestor: a dialog opened from a menu item is nested but not stacked, and still owns its scrim. -Under `prefers-reduced-motion: reduce` nothing recedes — the surface beneath holds still and the -stacked one simply appears over it. +Under `prefers-reduced-motion: reduce` nothing recedes — the surface beneath holds still, keeping +the dimming as its only cue. --- @@ -355,7 +355,8 @@ prompt. storyModule={DialogStories} /> -A `panel` never recedes, so what separates the two levels here is the prompt's own shadow. +A `panel` never recedes, so what separates the two levels here is the dimming and the prompt's own +shadow. Stack a prompt on a prompt and the one beneath moves instead — the shape a close confirmation takes: diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 1e65e792c69..6ea99c32b23 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -2,6 +2,11 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, durationVars, easingVars, radiusVars, space } from '../../tokens.stylex'; +// How far the surface beneath a stacked prompt is veiled toward its own background. Declared up +// here because `styles.popup` needs it; it belongs with `STACK_SCALE` / `STACK_LIFT` further down, +// which drive the other half of the same effect. +const STACK_VEIL_OPACITY = 0.4; + export const styles = stylex.create({ // The scrim. A black wash over `transparent` rather than a percentage of a neutral // token: it composites over whatever the host app renders, so the same value reads @@ -87,6 +92,30 @@ export const styles = stylex.create({ // raw content rather than a `Card` and the surface has to come from somewhere. `sizes.card` // nulls the painting properties back out — see the note there. popup: { + /** + * The other half of the recede: while a prompt is stacked on this surface, its contents dim + * toward the surface's own background, so the layer beneath reads as further back rather than + * merely smaller. + * + * A veil rather than `opacity` on the popup, because those are different effects. Fading the + * popup fades the SURFACE — its background and its shadow — and the scrim shows through, which + * reads as the dialog dissolving. Painting the background colour back over the contents leaves + * the surface at full strength and dims only what sits on it. + * + * Driven by a private custom property rather than by a state branch on the pseudo-element: + * a `:where()` nested inside a `::after` block would describe the pseudo-element's own state, + * not the popup's. Setting the variable on the popup — where the state actually lives — and + * reading it here is the only shape that says what is meant. + * + * `zIndex` so it also covers `Dialog.CloseButton`, which is positioned and would otherwise + * paint over it and stay undimmed. Never interactive: the whole subtree is inert while a + * stacked dialog holds focus, and `pointer-events: none` keeps it that way regardless. + * + * Kept under `prefers-reduced-motion: reduce`, where the recede is dropped. A cross-fade is + * not the kind of motion that setting is about, and without it that mode would have no depth + * cue at all. + */ + '--_cl-stack-veil': { default: 0, ':where([data-stack-base])': STACK_VEIL_OPACITY }, padding: space['6'], // Forced-colors mode discards `box-shadow` outright, and the ring above is the only thing // separating the surface from the page — so in HCM the dialog would float edgeless over its @@ -130,6 +159,20 @@ export const styles = stylex.create({ // The containing block for `Dialog.CloseButton`. position: 'relative', width: '100%', + '::after': { + inset: 0, + // Follows the popup's own radius, counter-scale included. + borderRadius: 'inherit', + backgroundColor: colorVars['--cl-color-card'], + content: '""', + opacity: 'var(--_cl-stack-veil)', + pointerEvents: 'none', + position: 'absolute', + transitionDuration: durationVars['--cl-duration-base'], + transitionProperty: 'opacity', + transitionTimingFunction: easingVars['--cl-ease-enter'], + zIndex: 1, + }, }, /** From 3fa538ab82b4031649fabd76cf7c0c74ea2bbc2d Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 20:49:48 -0600 Subject: [PATCH 3/8] feat(ui): separate a stack from a nested dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dialog over a `panel` or a `card` keeps the scrim it had — that is a new surface over a page-like one, and its own scrim is what says so. Only successive prompts drop it, where a second scrim would darken the page for what is the same conversation one step further in. Whether it is a stack turns on the size of the dialog BENEATH, which the headless layer has no notion of, so the backdrop resolves it from the parent size carried in context and applies the style rather than keying on `data-stacked`. The veil moves to `sizes.prompt` for the same reason. Reduced motion keeps the recede and drops only its duration. Removing it outright left a stacked prompt sitting on an identical prompt with no scrim between them, which reads as a rendering fault rather than as a preference being honoured. Dismissing a stack in one action now staggers the exits off `data-stack-exiting`, so it unwinds rather than vanishing on a single frame. --- .changeset/dialog-stack-motion.md | 2 +- .../swingset/src/stories/dialog.component.mdx | 39 ++++---- .../mosaic/components/dialog/dialog.styles.ts | 94 ++++++++++++++----- .../mosaic/components/dialog/dialog.test.tsx | 37 ++++++-- .../src/mosaic/components/dialog/dialog.tsx | 58 ++++++++---- 5 files changed, 164 insertions(+), 66 deletions(-) diff --git a/.changeset/dialog-stack-motion.md b/.changeset/dialog-stack-motion.md index 8acfaa74312..b5ca2129e76 100644 --- a/.changeset/dialog-stack-motion.md +++ b/.changeset/dialog-stack-motion.md @@ -2,4 +2,4 @@ '@clerk/ui': patch --- -Mosaic `Dialog`s stacked on one another now share a single backdrop instead of each painting its own, so the page no longer darkens further with every level. The dialog beneath a stacked `prompt` recedes slightly to signal the layering. Only `prompt` dialogs are meant to stack; opening a `panel` or `card` inside another dialog now warns in development. +Mosaic `Dialog` now distinguishes a stack — successive `prompt` dialogs, such as a confirmation over the form it is confirming — from a dialog opened over a `panel` or `card`. A stacked prompt paints no backdrop of its own, so the page no longer darkens further with every level; the prompt beneath it dims and recedes instead, and holds briefly when a stack is dismissed all at once so the exits are staggered. Dialogs opened over a `panel` or `card` are unchanged. Opening a `panel` or `card` inside another dialog now warns in development. diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index f5682649a7d..cab87fe10cc 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -249,24 +249,29 @@ difference and adds it to its own bottom padding, which gives each size the righ A card taller than the remaining space aligns to its top rather than losing its head. Pinch-zoom — which also shrinks the visual viewport — is excluded. -### Stacked dialogs +### Nested dialogs and stacks -Only a `prompt` stacks. `panel` and `card` are root-level surfaces — they host a stack, they are -never the thing stacked — and a dialog opened inside another one warns in development if it is any -other size. +Two different relationships, which look different on purpose. -A stacked dialog carries `data-stacked` and paints **no** scrim: one backdrop serves the whole -stack, so how dark the page goes never depends on how deep the stack is. The dialog beneath carries -`data-stack-base`, and its contents dim toward its own background — enough on its own to read as a -layer further back, which is what a `panel` gets, since a panel never moves. A `prompt` also -recedes, scaling down slightly and lifting, with its radius divided by the same factor so the -corners render unchanged. +A **nested** dialog is one opened over a `panel` or a `card` — a new surface over a page-like one. +It paints its own scrim, lighter than the base so the two composite to the intended darkness rather +than doubling it. Nothing else changes. -`data-stacked` is narrower than the older `data-nested`, which reports any floating ancestor: a -dialog opened from a menu item is nested but not stacked, and still owns its scrim. +A **stack** is successive `prompt`s: the confirmation over the form it is confirming. The same +conversation, one step further in. A stacked prompt paints **no** scrim — one backdrop serves the +whole stack, so how dark the page goes never depends on how deep the stack is. Depth comes from the +prompt beneath instead: its contents dim toward its own background, and it recedes, scaling down +slightly and lifting, with its radius divided by the same factor so the corners render unchanged. -Under `prefers-reduced-motion: reduce` nothing recedes — the surface beneath holds still, keeping -the dimming as its only cue. +Whichever it is, the thing that opens is always a `prompt`. `panel` and `card` are root-level +surfaces — they host, they are never hosted — and a dialog opened inside another one warns in +development if it is any other size. + +Dismissing a stack in one action, as "discard" does, staggers the exits: the surface beneath holds +briefly so the stack unwinds rather than vanishing on one frame. + +Under `prefers-reduced-motion: reduce` the recede still happens, it just arrives in a single frame +with nothing interpolating — the setting asks for no animation, not for no distinction. --- @@ -355,10 +360,10 @@ prompt. storyModule={DialogStories} /> -A `panel` never recedes, so what separates the two levels here is the dimming and the prompt's own -shadow. +This is the nested case, not a stack: the prompt paints its own scrim over the panel, and the panel +neither dims nor recedes. -Stack a prompt on a prompt and the one beneath moves instead — the shape a close confirmation +Stack a prompt on a prompt and the relationship changes — the shape a close confirmation takes: prompt -> alert` this exists for would land on 0.83 against the 0.68 the + * nested value above was solved for. The stack reads through the surface beneath receding and + * dimming instead. + * + * Applied by `Dialog.Backdrop` rather than keyed on `data-stacked`, because whether this is a + * stack depends on the size of the dialog beneath — which the headless layer has no notion of. + * It rides in the same `stylex.props` call as `backdrop`, so this `backgroundColor` replaces + * that one outright rather than the two both emitting. + */ + backdropStacked: { + backgroundColor: 'transparent', + }, + // Centering track inside the headless `FloatingOverlay`, which owns the fixed positioning and // the scroll lock. Whether this box is a fixed height or grows with its content is the whole // outside-scroll question, and it differs per size — see `viewportSizes` below. @@ -111,11 +124,10 @@ export const styles = stylex.create({ * paint over it and stay undimmed. Never interactive: the whole subtree is inert while a * stacked dialog holds focus, and `pointer-events: none` keeps it that way regardless. * - * Kept under `prefers-reduced-motion: reduce`, where the recede is dropped. A cross-fade is - * not the kind of motion that setting is about, and without it that mode would have no depth - * cue at all. + * The variable itself is set per size — only `prompt` sets it, in `sizes` below — so this + * reads `0` on a `panel` or a `card`, which have a scrim of their own to separate them from + * what they host and would double up. */ - '--_cl-stack-veil': { default: 0, ':where([data-stack-base])': STACK_VEIL_OPACITY }, padding: space['6'], // Forced-colors mode discards `box-shadow` outright, and the ring above is the only thing // separating the surface from the page — so in HCM the dialog would float edgeless over its @@ -165,7 +177,7 @@ export const styles = stylex.create({ borderRadius: 'inherit', backgroundColor: colorVars['--cl-color-card'], content: '""', - opacity: 'var(--_cl-stack-veil)', + opacity: 'var(--_cl-stack-veil, 0)', pointerEvents: 'none', position: 'absolute', transitionDuration: durationVars['--cl-duration-base'], @@ -286,6 +298,10 @@ export const viewportSizes = stylex.create({ export const sizes = stylex.create({ prompt: { + // Read by the veil on `styles.popup`. Set here rather than there so it applies to `prompt` + // alone: a `panel` or a `card` hosting a dialog gets a scrim between the two instead, and + // would otherwise dim as well as darken. + '--_cl-stack-veil': { default: 0, ':where([data-stack-base])': STACK_VEIL_OPACITY }, // Tighter than the popup's default 1.5rem. A prompt asks one thing, so its content box is // small and a 1.5rem surround reads as a disproportionate frame around two lines of text. // Overrides `styles.popup` by position — `sizes[size]` is spread after it in the same @@ -490,6 +506,15 @@ const ENTER_SCALE = 0.94; const STACK_SCALE = 0.96; const STACK_LIFT = '-0.5rem'; +// How long a surface holds before starting its own exit while a dialog stacked on it is still +// leaving. Without it a stack dismissed in one action — "discard", which closes the confirmation +// and the form behind it together — leaves on a single frame, and the two surfaces read as one +// thing vanishing rather than as a stack unwinding. +// +// Half the child's exit rather than all of it: the two overlapping is the point. Fully sequenced, +// the dismissal takes twice as long and starts to feel like waiting. +const STACK_EXIT_STAGGER = `calc(${durationVars['--cl-duration-fast']} / 2)`; + const popupRadius = radiusVars['--cl-radius-xl']; export const popupMotion = stylex.create({ @@ -513,12 +538,12 @@ export const popupMotion = stylex.create({ ':where([data-starting-style], [data-ending-style])': popupRadius, }, // Both entrance branches resolve to the same value, so their order relative to each other - // cannot matter: there is no scale to counteract in either case. The stack branch is here - // for the same reason it is on `transform` — under `reduce` nothing scales, so there is - // nothing to correct. + // cannot matter: there is no scale to counteract in either case. The recede is the + // exception — it still applies under `reduce`, just without a duration — so its correction + // has to come with it. '@media (prefers-reduced-motion: reduce)': { default: popupRadius, - ':where([data-stack-base])': popupRadius, + ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`, ':where([data-starting-style], [data-ending-style])': popupRadius, }, }, @@ -577,14 +602,33 @@ export const popupMotion = stylex.create({ ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, ':where([data-starting-style], [data-ending-style])': 'scale(1)', }, + // The recede is NOT dropped here, unlike the entrance scale. `reduce` asks for no + // ANIMATION, not for no distinction: `transitionProperty` below narrows to `opacity` in + // this mode, so the recede lands in one frame with nothing interpolating. Dropping it + // outright leaves a stacked prompt sitting on an identical prompt with no scrim between + // them, which reads as a rendering fault rather than as a preference being honoured. '@media (prefers-reduced-motion: reduce)': { default: 'scale(1)', - // Reduced motion drops the recede entirely rather than snapping to it: the level below - // holds still and the stacked surface simply appears over it. - ':where([data-stack-base])': 'scale(1)', + ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, ':where([data-starting-style], [data-ending-style])': 'scale(1)', }, }, + /** + * Holds this surface's exit while a dialog stacked on it is still leaving, so a stack + * dismissed in one action unwinds instead of vanishing at once. + * + * Applies to every slot of `transitionProperty` rather than to the transform alone: what is + * being delayed is the whole departure. Under `reduce` that list narrows to `opacity`, so the + * stagger survives as a staggered fade. + * + * `data-stack-exiting` and not `data-stack-base`, and the difference is the whole reason the + * headless layer reports both: `data-stack-base` releases the moment its child closes, which + * is a frame before there is anything to stagger against. + */ + transitionDelay: { + default: null, + ':where([data-stack-exiting][data-ending-style])': STACK_EXIT_STAGGER, + }, // The sheet travels its OWN HEIGHT rather than the ~11px a scale does, so it runs longer than // anything else here: `slow` in, `base` out, a 1.67:1 ratio in line with the rest of Mosaic. // The dead-frame concern that caps long durations elsewhere does not apply — the delta is diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx index 43464782b69..8ff9af9dedb 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -261,17 +261,40 @@ describe('stacked backdrops', () => { ); } - it('marks only the stacked backdrop, so one scrim paints for the whole stack', async () => { + // The backdrop's two cases differ by a style rather than by an attribute, so the assertion is + // that the same tree with only the hosting size changed produces different classes. Comparing + // rather than matching a class: StyleX names are content hashes and would pin the value. + async function innerBackdropClass(hostSize: DialogSize) { const user = userEvent.setup(); - renderStack(); + render( + + Host + + Add email address + + , + ); + await user.click(screen.getByRole('button', { name: 'Add email' })); + const className = document.querySelectorAll('.cl-dialog-backdrop')[1].className; + cleanup(); + return className; + } - expect(document.querySelector('.cl-dialog-backdrop')).not.toHaveAttribute('data-stacked'); + it('drops the scrim for a prompt over a prompt, and keeps it for one over a panel', async () => { + const overPrompt = await innerBackdropClass('prompt'); + const overPanel = await innerBackdropClass('panel'); - await user.click(screen.getByRole('button', { name: 'Add email' })); + expect(overPrompt).not.toBe(overPanel); + }); - const backdrops = document.querySelectorAll('.cl-dialog-backdrop'); - expect(backdrops[0]).not.toHaveAttribute('data-stacked'); - expect(backdrops[1]).toHaveAttribute('data-stacked', ''); + it('keeps a prompt over a card on the nested scrim, same as over a panel', async () => { + const overCard = await innerBackdropClass('card'); + const overPanel = await innerBackdropClass('panel'); + + expect(overCard).toBe(overPanel); }); it('marks the popup beneath as the stack base, so it can recede', async () => { diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx index af5f84c39cf..c539aae84e4 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -29,6 +29,24 @@ export interface DialogRootProps extends HeadlessDialogProps< */ const DialogSizeContext = React.createContext('prompt'); +/** + * The size of the dialog this one was opened from, which is what decides whether the two form a + * STACK — successive prompts — or a nested dialog over a `panel` or `card`. The two want opposite + * backdrops, so the distinction has to be reachable from the parts. + * + * Read from `DialogSizeContext` before a root overwrites it with its own size. Meaningless on its + * own, since a root-level dialog reads the context default: pair it with the headless `isStacked`, + * which is what reports that there is a dialog above at all. + */ +const DialogParentSizeContext = React.createContext('prompt'); + +/** Whether this dialog is a prompt stacked on a prompt — see {@link DialogParentSizeContext}. */ +function useIsStacked() { + const { isStacked } = useDialogContext(); + const parentSize = React.useContext(DialogParentSizeContext); + return isStacked && parentSize === 'prompt'; +} + /** * The headless parts type their props (and the `render` callback's argument) against * the raw tag props, which carry the non-standard HTML `color` attribute typed @@ -74,10 +92,13 @@ export type DialogPopupProps = MosaicComponentProps<'div'> & { /** Owns the open state and the size both the backdrop and the popup read. */ function Root({ size = 'prompt', children, ...rest }: DialogRootProps) { + const parentSize = React.useContext(DialogSizeContext); return ( - - {...rest}>{children} - + + + {...rest}>{children} + + ); } @@ -167,12 +188,15 @@ const Backdrop = React.forwardRef(function ref, ) { const size = React.useContext(DialogSizeContext); + const isStacked = useIsStacked(); return ( (function }); /** - * Warns when a dialog stacked on another one is not a `prompt`. + * Warns when a dialog opened inside another dialog is not a `prompt`. * - * Only `prompt` stacks. `panel` and `card` are root-level surfaces: they host a stack, and the - * styles that make one work — dropping the scrim, receding behind the surface above — exist for - * `prompt` alone, so a `panel` opened inside a dialog silently renders with neither. + * `panel` and `card` are root-level surfaces: they host what opens over them and are never the + * thing that opens. A `panel` inside a dialog renders at a size that assumes it owns the viewport, + * over a surface it was meant to replace. * - * One rule stated on the child covers every case, without having to enumerate which sizes may - * host what. + * One rule stated on the child covers every case — panel-in-panel, card-in-panel — without having + * to enumerate which sizes may host what. */ -function useStackedSizeWarning(isStacked: boolean, size: DialogSize) { +function useNestedSizeWarning(isNestedInDialog: boolean, size: DialogSize) { React.useEffect(() => { - if (process.env.NODE_ENV === 'production' || !isStacked || size === 'prompt') { + if (process.env.NODE_ENV === 'production' || !isNestedInDialog || size === 'prompt') { return; } console.warn( `Mosaic: a Dialog opened inside another Dialog should be size="prompt", but this one is size="${size}". ` + - 'Only prompts are styled to stack — this dialog will paint no backdrop and the surface beneath it will not recede.', + 'Only prompts are meant to open over another dialog; the rest are root-level surfaces.', ); - }, [isStacked, size]); + }, [isNestedInDialog, size]); } /** The dialog surface: `role="dialog"`, focus-trapped, and the element that paints. */ @@ -234,12 +258,14 @@ const Popup = React.forwardRef(function Dialog ref, ) { const size = React.useContext(DialogSizeContext); - const { isStacked } = useDialogContext(); + // The headless flag, not `useIsStacked` — the rule is about opening a dialog inside ANY dialog, + // which is broader than the prompt-on-prompt case the stacking styles cover. + const { isStacked: isNestedInDialog } = useDialogContext(); // Observed through state rather than a plain ref, because the warning has to re-run when the // node arrives and a ref mutation does not re-render. const [node, setNode] = React.useState(null); useAccessibleNameWarning(node, 'Dialog'); - useStackedSizeWarning(isStacked, size); + useNestedSizeWarning(isNestedInDialog, size); const mergedRef = React.useCallback( (element: HTMLDivElement | null) => { From 2c07dccad7951dc4f8be628c6692fe6ae8966c77 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 20:58:27 -0600 Subject: [PATCH 4/8] feat(ui): hold the recede through a stacked dialog's exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stagger was invisible, and mostly not because it was short. The veil and the lift were keyed on `data-stack-base`, which releases the instant the child closes — so the surface beneath un-dimmed and dropped forward WHILE fading out, moving toward the viewer at the moment it should have been receding away. Both now hold through `data-stack-exiting` too, and the lift rides a custom property so the exit branch carries it without having to know whether there was ever a stacked child. The delay goes to a full `fast`. Half of it was 50ms against a 100ms exit — about a frame and a half, which is nothing. --- .../mosaic/components/dialog/dialog.styles.ts | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index c54e3a57c12..bebc27e5144 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -2,10 +2,17 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, durationVars, easingVars, radiusVars, space } from '../../tokens.stylex'; -// How far the surface beneath a stacked prompt is veiled toward its own background. Declared up -// here because `styles.popup` needs it; it belongs with `STACK_SCALE` / `STACK_LIFT` further down, -// which drive the other half of the same effect. +// The stack state a surface beneath a stacked prompt takes on: how far its contents are veiled +// toward its own background, and how far it lifts. Declared up here rather than beside +// `STACK_SCALE` further down because `styles` and `sizes` read them, and StyleX requires a +// referenced constant to be declared before the `create()` call that reads it. const STACK_VEIL_OPACITY = 0.4; +const STACK_LIFT = '-0.5rem'; + +// The lift rides a custom property rather than being written into each `transform` value, because +// the exit branch has to carry it too and cannot know whether there was ever a stacked child. The +// variable is set per state in `sizes.prompt`; every `transform` below just composes it. +const STACK_TRANSLATE = 'translateY(var(--_cl-stack-lift, 0rem))'; export const styles = stylex.create({ // The scrim. A black wash over `transparent` rather than a percentage of a neutral @@ -301,7 +308,13 @@ export const sizes = stylex.create({ // Read by the veil on `styles.popup`. Set here rather than there so it applies to `prompt` // alone: a `panel` or a `card` hosting a dialog gets a scrim between the two instead, and // would otherwise dim as well as darken. - '--_cl-stack-veil': { default: 0, ':where([data-stack-base])': STACK_VEIL_OPACITY }, + // Both held through `data-stack-exiting` as well as `data-stack-base`, and that is the whole + // reason the stagger is visible. The base releases the instant its child closes, so keyed on + // it alone the surface beneath un-dims and drops forward WHILE it is fading out — moving + // toward the viewer at the moment it should be receding away, which reads as a glitch and + // buries any stagger under it. + '--_cl-stack-lift': { default: '0rem', ':where([data-stack-base], [data-stack-exiting])': STACK_LIFT }, + '--_cl-stack-veil': { default: 0, ':where([data-stack-base], [data-stack-exiting])': STACK_VEIL_OPACITY }, // Tighter than the popup's default 1.5rem. A prompt asks one thing, so its content box is // small and a 1.5rem surround reads as a disproportionate frame around two lines of text. // Overrides `styles.popup` by position — `sizes[size]` is spread after it in the same @@ -495,25 +508,25 @@ const ENTER_SCALE = 0.94; // // Shallower than the entrance scale on purpose: the entrance is a surface arriving from nowhere, // while this is a surface that stays legible the whole time and only has to read as further back. -// The lift is what separates it from the entrance rather than the depth of the scale — a surface -// that only shrinks reads as being pushed away, one that shrinks and rises reads as being layered -// over, which is the relationship this actually is. +// The lift (`STACK_LIFT`, at the top of this file) is what separates it from the entrance rather +// than the depth of the scale — a surface that only shrinks reads as being pushed away, one that +// shrinks and rises reads as being layered over, which is the relationship this actually is. // // A single step rather than a `--cl-stack-index` formula: the headless layer counts DIRECT // children, so a third level would report the same 1 as the second and every level below the top // would recede identically anyway. The formula and the cumulative count belong in the same change, // whenever a stack deep enough to need them turns up. const STACK_SCALE = 0.96; -const STACK_LIFT = '-0.5rem'; // How long a surface holds before starting its own exit while a dialog stacked on it is still // leaving. Without it a stack dismissed in one action — "discard", which closes the confirmation // and the form behind it together — leaves on a single frame, and the two surfaces read as one // thing vanishing rather than as a stack unwinding. // -// Half the child's exit rather than all of it: the two overlapping is the point. Fully sequenced, -// the dismissal takes twice as long and starts to feel like waiting. -const STACK_EXIT_STAGGER = `calc(${durationVars['--cl-duration-fast']} / 2)`; +// A full `fast` — the same length as the child's exit — so the surface beneath starts leaving as +// the one above finishes. Half of it was tried first and `fast` is only 0.1s, so the separation +// came to about a frame and a half and read as nothing at all. +const STACK_EXIT_STAGGER = durationVars['--cl-duration-fast']; const popupRadius = radiusVars['--cl-radius-xl']; @@ -578,7 +591,7 @@ export const popupMotion = stylex.create({ * free to reorder them. */ transform: { - default: 'scale(1)', + default: `scale(1) ${STACK_TRANSLATE}`, /** * The recede: what a prompt does while another prompt is stacked on it. There is no second * scrim, so this and the stacked surface's own shadow are the entire depth cue. @@ -595,12 +608,12 @@ export const popupMotion = stylex.create({ * ordinary reaches that state — floating-ui blocks the parent's own dismissal while a child * is open — and the exit scale is the better of the two to see if anything ever does. */ - ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, - ':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE})`, + ':where([data-stack-base])': `scale(${STACK_SCALE}) ${STACK_TRANSLATE}`, + ':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE}) ${STACK_TRANSLATE}`, '@media (max-width: 47.99rem)': { - default: 'scale(1)', - ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, - ':where([data-starting-style], [data-ending-style])': 'scale(1)', + default: `scale(1) ${STACK_TRANSLATE}`, + ':where([data-stack-base])': `scale(${STACK_SCALE}) ${STACK_TRANSLATE}`, + ':where([data-starting-style], [data-ending-style])': `scale(1) ${STACK_TRANSLATE}`, }, // The recede is NOT dropped here, unlike the entrance scale. `reduce` asks for no // ANIMATION, not for no distinction: `transitionProperty` below narrows to `opacity` in @@ -608,9 +621,9 @@ export const popupMotion = stylex.create({ // outright leaves a stacked prompt sitting on an identical prompt with no scrim between // them, which reads as a rendering fault rather than as a preference being honoured. '@media (prefers-reduced-motion: reduce)': { - default: 'scale(1)', - ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, - ':where([data-starting-style], [data-ending-style])': 'scale(1)', + default: `scale(1) ${STACK_TRANSLATE}`, + ':where([data-stack-base])': `scale(${STACK_SCALE}) ${STACK_TRANSLATE}`, + ':where([data-starting-style], [data-ending-style])': `scale(1) ${STACK_TRANSLATE}`, }, }, /** From efd7fec64c035c7555ccf968a96c31b2cdaa934c Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 21:03:13 -0600 Subject: [PATCH 5/8] feat(ui): drop the stacked-dialog exit stagger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt's exit runs 0.1s, which leaves no room to separate two of them, and lengthening a dismissal to make the separation visible is the wrong trade — the dismissal is the part that should feel immediate. Takes the veil and lift back to `data-stack-base` alone. They briefly held through the exit as well, to stop the surface beneath un-dimming as it faded, but that too is below the threshold over 0.1s. --- .changeset/dialog-stack-motion.md | 2 +- .../swingset/src/stories/dialog.component.mdx | 3 - .../mosaic/components/dialog/dialog.styles.ts | 66 ++++--------------- 3 files changed, 15 insertions(+), 56 deletions(-) diff --git a/.changeset/dialog-stack-motion.md b/.changeset/dialog-stack-motion.md index b5ca2129e76..60a5b6ddef7 100644 --- a/.changeset/dialog-stack-motion.md +++ b/.changeset/dialog-stack-motion.md @@ -2,4 +2,4 @@ '@clerk/ui': patch --- -Mosaic `Dialog` now distinguishes a stack — successive `prompt` dialogs, such as a confirmation over the form it is confirming — from a dialog opened over a `panel` or `card`. A stacked prompt paints no backdrop of its own, so the page no longer darkens further with every level; the prompt beneath it dims and recedes instead, and holds briefly when a stack is dismissed all at once so the exits are staggered. Dialogs opened over a `panel` or `card` are unchanged. Opening a `panel` or `card` inside another dialog now warns in development. +Mosaic `Dialog` now distinguishes a stack — successive `prompt` dialogs, such as a confirmation over the form it is confirming — from a dialog opened over a `panel` or `card`. A stacked prompt paints no backdrop of its own, so the page no longer darkens further with every level; the prompt beneath it dims and recedes instead. Dialogs opened over a `panel` or `card` are unchanged. Opening a `panel` or `card` inside another dialog now warns in development. diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index cab87fe10cc..19a5793bf28 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -267,9 +267,6 @@ Whichever it is, the thing that opens is always a `prompt`. `panel` and `card` a surfaces — they host, they are never hosted — and a dialog opened inside another one warns in development if it is any other size. -Dismissing a stack in one action, as "discard" does, staggers the exits: the surface beneath holds -briefly so the stack unwinds rather than vanishing on one frame. - Under `prefers-reduced-motion: reduce` the recede still happens, it just arrives in a single frame with nothing interpolating — the setting asks for no animation, not for no distinction. diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index bebc27e5144..816839c9213 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -2,17 +2,10 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, durationVars, easingVars, radiusVars, space } from '../../tokens.stylex'; -// The stack state a surface beneath a stacked prompt takes on: how far its contents are veiled -// toward its own background, and how far it lifts. Declared up here rather than beside -// `STACK_SCALE` further down because `styles` and `sizes` read them, and StyleX requires a -// referenced constant to be declared before the `create()` call that reads it. +// How far the contents of a surface beneath a stacked prompt are veiled toward its own background. +// Declared up here rather than beside `STACK_SCALE` further down because `sizes` reads it, and +// StyleX requires a referenced constant to be declared before the `create()` call that reads it. const STACK_VEIL_OPACITY = 0.4; -const STACK_LIFT = '-0.5rem'; - -// The lift rides a custom property rather than being written into each `transform` value, because -// the exit branch has to carry it too and cannot know whether there was ever a stacked child. The -// variable is set per state in `sizes.prompt`; every `transform` below just composes it. -const STACK_TRANSLATE = 'translateY(var(--_cl-stack-lift, 0rem))'; export const styles = stylex.create({ // The scrim. A black wash over `transparent` rather than a percentage of a neutral @@ -308,13 +301,7 @@ export const sizes = stylex.create({ // Read by the veil on `styles.popup`. Set here rather than there so it applies to `prompt` // alone: a `panel` or a `card` hosting a dialog gets a scrim between the two instead, and // would otherwise dim as well as darken. - // Both held through `data-stack-exiting` as well as `data-stack-base`, and that is the whole - // reason the stagger is visible. The base releases the instant its child closes, so keyed on - // it alone the surface beneath un-dims and drops forward WHILE it is fading out — moving - // toward the viewer at the moment it should be receding away, which reads as a glitch and - // buries any stagger under it. - '--_cl-stack-lift': { default: '0rem', ':where([data-stack-base], [data-stack-exiting])': STACK_LIFT }, - '--_cl-stack-veil': { default: 0, ':where([data-stack-base], [data-stack-exiting])': STACK_VEIL_OPACITY }, + '--_cl-stack-veil': { default: 0, ':where([data-stack-base])': STACK_VEIL_OPACITY }, // Tighter than the popup's default 1.5rem. A prompt asks one thing, so its content box is // small and a 1.5rem surround reads as a disproportionate frame around two lines of text. // Overrides `styles.popup` by position — `sizes[size]` is spread after it in the same @@ -517,16 +504,7 @@ const ENTER_SCALE = 0.94; // would recede identically anyway. The formula and the cumulative count belong in the same change, // whenever a stack deep enough to need them turns up. const STACK_SCALE = 0.96; - -// How long a surface holds before starting its own exit while a dialog stacked on it is still -// leaving. Without it a stack dismissed in one action — "discard", which closes the confirmation -// and the form behind it together — leaves on a single frame, and the two surfaces read as one -// thing vanishing rather than as a stack unwinding. -// -// A full `fast` — the same length as the child's exit — so the surface beneath starts leaving as -// the one above finishes. Half of it was tried first and `fast` is only 0.1s, so the separation -// came to about a frame and a half and read as nothing at all. -const STACK_EXIT_STAGGER = durationVars['--cl-duration-fast']; +const STACK_LIFT = '-0.5rem'; const popupRadius = radiusVars['--cl-radius-xl']; @@ -591,7 +569,7 @@ export const popupMotion = stylex.create({ * free to reorder them. */ transform: { - default: `scale(1) ${STACK_TRANSLATE}`, + default: 'scale(1)', /** * The recede: what a prompt does while another prompt is stacked on it. There is no second * scrim, so this and the stacked surface's own shadow are the entire depth cue. @@ -608,12 +586,12 @@ export const popupMotion = stylex.create({ * ordinary reaches that state — floating-ui blocks the parent's own dismissal while a child * is open — and the exit scale is the better of the two to see if anything ever does. */ - ':where([data-stack-base])': `scale(${STACK_SCALE}) ${STACK_TRANSLATE}`, - ':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE}) ${STACK_TRANSLATE}`, + ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, + ':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE})`, '@media (max-width: 47.99rem)': { - default: `scale(1) ${STACK_TRANSLATE}`, - ':where([data-stack-base])': `scale(${STACK_SCALE}) ${STACK_TRANSLATE}`, - ':where([data-starting-style], [data-ending-style])': `scale(1) ${STACK_TRANSLATE}`, + default: 'scale(1)', + ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, + ':where([data-starting-style], [data-ending-style])': 'scale(1)', }, // The recede is NOT dropped here, unlike the entrance scale. `reduce` asks for no // ANIMATION, not for no distinction: `transitionProperty` below narrows to `opacity` in @@ -621,27 +599,11 @@ export const popupMotion = stylex.create({ // outright leaves a stacked prompt sitting on an identical prompt with no scrim between // them, which reads as a rendering fault rather than as a preference being honoured. '@media (prefers-reduced-motion: reduce)': { - default: `scale(1) ${STACK_TRANSLATE}`, - ':where([data-stack-base])': `scale(${STACK_SCALE}) ${STACK_TRANSLATE}`, - ':where([data-starting-style], [data-ending-style])': `scale(1) ${STACK_TRANSLATE}`, + default: 'scale(1)', + ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, + ':where([data-starting-style], [data-ending-style])': 'scale(1)', }, }, - /** - * Holds this surface's exit while a dialog stacked on it is still leaving, so a stack - * dismissed in one action unwinds instead of vanishing at once. - * - * Applies to every slot of `transitionProperty` rather than to the transform alone: what is - * being delayed is the whole departure. Under `reduce` that list narrows to `opacity`, so the - * stagger survives as a staggered fade. - * - * `data-stack-exiting` and not `data-stack-base`, and the difference is the whole reason the - * headless layer reports both: `data-stack-base` releases the moment its child closes, which - * is a frame before there is anything to stagger against. - */ - transitionDelay: { - default: null, - ':where([data-stack-exiting][data-ending-style])': STACK_EXIT_STAGGER, - }, // The sheet travels its OWN HEIGHT rather than the ~11px a scale does, so it runs longer than // anything else here: `slow` in, `base` out, a 1.67:1 ratio in line with the rest of Mosaic. // The dead-frame concern that caps long durations elsewhere does not apply — the delta is From 4a6b85d1ec0bde6eaa098e26b396a79565b5fc46 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 21:07:10 -0600 Subject: [PATCH 6/8] feat(ui): shorten the sheet fade for a stacked prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone a prompt fades over the full length of its slide, which earns itself against the page — the fade gives the travel somewhere to resolve into. Over an opaque surface it does the opposite: for a quarter of a second the dialog underneath shows through the one arriving, and two stacked sheets read as one muddy surface. There is already a surface there, so the slide can carry the arrival alone and the fade goes back to the desktop `fast`. Keyed on being over any open dialog rather than on the narrower prompt-on-prompt stack: what makes the long fade wrong is arriving over something opaque, and a panel is as opaque as a prompt. --- .../swingset/src/stories/dialog.component.mdx | 5 +++++ .../mosaic/components/dialog/dialog.styles.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 19a5793bf28..fccd3c71368 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -146,6 +146,11 @@ The sheet fades over the full length of its slide, while the backdrop keeps its the scrim answers the tap first, then the sheet arrives into an already-dimmed page. Under `prefers-reduced-motion: reduce` the sheet holds flat and only the fade runs. +A sheet arriving over another dialog takes the shorter desktop fade instead. The long one earns +itself against the page, where it gives the travel somewhere to resolve into; over an opaque +surface it just shows the dialog underneath through the one arriving, and the two read as one muddy +surface. The slide is unchanged, and carries the arrival on its own. + Drag-to-dismiss is deliberately absent — `Drawer` owns the drag engine, and a second one should not grow inside `Dialog`. diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 816839c9213..2c59d2977d3 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -615,12 +615,29 @@ export const popupMotion = stylex.create({ // `fast` and lands with the scrim, since the scale it accompanies barely moves. The third slot // is inert under the phone band (no scale, so no radius counter-scale) but still has to be // filled — the list is positional. + // + // EXCEPT for a sheet arriving over another dialog, which takes the desktop `fast` fade back. + // The long fade earns itself on the first sheet, where it gives the travel somewhere to + // resolve into against the page. Over an opaque surface it does the opposite: for a quarter of + // a second the dialog underneath shows through the one arriving, and two stacked surfaces + // read as one muddy one. There is already a surface there, so the fade has nothing left to do + // and the slide can carry the arrival alone. + // + // Keyed on `data-stacked` — over any open dialog, panel included — rather than on the narrower + // prompt-on-prompt stack the backdrop cares about. What makes the long fade wrong here is + // arriving over something opaque, and a panel is as opaque as a prompt. + // + // The combined exiting branch restates `base` because `@stylexjs/sort-keys` puts it after the + // plain `data-stacked` one, which would otherwise hand a stacked sheet the four-value entrance + // list on its way out and slow its exit slide. transitionDuration: { default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`, ':where([data-ending-style])': durationVars['--cl-duration-fast'], '@media (max-width: 47.99rem)': { default: `${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}`, ':where([data-ending-style])': durationVars['--cl-duration-base'], + ':where([data-stacked])': `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}`, + ':where([data-stacked][data-ending-style])': durationVars['--cl-duration-base'], }, }, transitionProperty: { From 3f3e2afb1a04f71d8bbeb75fa56657cbb8807e4b Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 21:11:09 -0600 Subject: [PATCH 7/8] feat(ui): demo a vetoed close in the nested dialog story Makes the panel -> prompt -> prompt case reachable: typing into "add email address" and then trying to close it stacks a confirmation instead. Every close request routes through the controlled `onOpenChange`, so declining to commit there covers Escape, the corner X and Cancel at once. Hand-rolled, and meant to be replaced by the AlertDialog and close confirmation work rather than kept. --- .../swingset/src/stories/dialog.component.mdx | 5 ++ .../src/stories/dialog.component.stories.tsx | 85 ++++++++++++++++--- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index fccd3c71368..0128da7fd06 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -365,6 +365,11 @@ prompt. This is the nested case, not a stack: the prompt paints its own scrim over the panel, and the panel neither dims nor recedes. +Type into **Add email address** and then try to close it — Escape, the corner X, or Cancel — and a +confirmation stacks on top instead, making the panel → prompt → prompt case reachable. The veto is +a controlled `open` whose `onOpenChange` declines to commit; every close request routes through it, +so one check covers all of them. + Stack a prompt on a prompt and the relationship changes — the shape a close confirmation takes: diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 640fcd51b9e..6dbb4001580 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -107,7 +107,14 @@ const sectionHeader = { justifyContent: 'space-between', } as const; -/** A `prompt` dialog opened from inside the `panel` — the shape the account profile uses. */ +/** + * A `prompt` dialog opened from inside the `panel` — the shape the account profile uses. + * + * With `confirmDiscard`, closing it while the field holds anything opens a confirmation stacked on + * top rather than closing: `panel -> prompt -> prompt`, and the veto is nothing more than a + * controlled `open` whose `onOpenChange` declines to commit. Hand-rolled here on purpose — it is + * what the `AlertDialog` and close-confirmation work is meant to replace. + */ function AddValueDialog({ trigger, title, @@ -115,6 +122,7 @@ function AddValueDialog({ placeholder, confirmLabel = 'Continue', confirmColor, + confirmDiscard = false, }: { trigger: (props: RenderProps) => React.ReactElement; title: string; @@ -122,34 +130,82 @@ function AddValueDialog({ placeholder: string; confirmLabel?: string; confirmColor?: 'negative'; + confirmDiscard?: boolean; }) { + const [open, setOpen] = React.useState(false); + const [discardOpen, setDiscardOpen] = React.useState(false); + const [value, setValue] = React.useState(''); + + const dismiss = () => { + setValue(''); + setOpen(false); + }; + return ( { + // The veto. Every close request lands here — Escape, the corner X, `Dialog.Close` — so + // declining to commit covers all of them at once. A footer button wired to a bare + // `setOpen(false)` would go around it, which is the argument for `Dialog.Close`. + if (!next && confirmDiscard && value.trim() !== '') { + setDiscardOpen(true); + return; + } + if (!next) { + setValue(''); + } + setOpen(next); + }} > - {({ close }) => ( - <> - - }>{title} - }>{description} - + + }>{title} + }>{description} + setValue(event.target.value)} + /> +
+ }>Cancel + +
+ {confirmDiscard ? ( + + }>Discard changes? + }> + You have not finished adding this address. It will not be saved. +
- - )} +
+ ) : null}
); } @@ -173,6 +229,7 @@ export function Nested() { title='Add email address' description="We'll send a verification code to this address." placeholder='you@example.com' + confirmDiscard /> From c4bcb9db309dd925eda5b5a9f676e95d2d11e9e3 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 13 Aug 2026 13:34:38 -0600 Subject: [PATCH 8/8] fix(ui): address review feedback on #9432 Empty the changeset (Mosaic has no consumer-visible surface), match the `[clerk] ` prefix the package's other dev warnings use, and give the stack veil the phone band's `slow` duration so it stays in step with the recede it accompanies. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/dialog-stack-motion.md | 3 --- packages/ui/src/mosaic/components/dialog/dialog.styles.ts | 8 +++++++- packages/ui/src/mosaic/components/dialog/dialog.tsx | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.changeset/dialog-stack-motion.md b/.changeset/dialog-stack-motion.md index 60a5b6ddef7..a845151cc84 100644 --- a/.changeset/dialog-stack-motion.md +++ b/.changeset/dialog-stack-motion.md @@ -1,5 +1,2 @@ --- -'@clerk/ui': patch --- - -Mosaic `Dialog` now distinguishes a stack — successive `prompt` dialogs, such as a confirmation over the form it is confirming — from a dialog opened over a `panel` or `card`. A stacked prompt paints no backdrop of its own, so the page no longer darkens further with every level; the prompt beneath it dims and recedes instead. Dialogs opened over a `panel` or `card` are unchanged. Opening a `panel` or `card` inside another dialog now warns in development. diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 2c59d2977d3..27c51dc0fc8 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -180,7 +180,13 @@ export const styles = stylex.create({ opacity: 'var(--_cl-stack-veil, 0)', pointerEvents: 'none', position: 'absolute', - transitionDuration: durationVars['--cl-duration-base'], + // Tracks the recede it accompanies rather than standing on its own: the two are halves of + // one gesture, and the phone band runs the transform at `slow`. Pinning the veil at `base` + // there finishes the dim 100ms before the surface stops moving, in both directions. + transitionDuration: { + default: durationVars['--cl-duration-base'], + '@media (max-width: 47.99rem)': durationVars['--cl-duration-slow'], + }, transitionProperty: 'opacity', transitionTimingFunction: easingVars['--cl-ease-enter'], zIndex: 1, diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx index c539aae84e4..c235c14965d 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -246,7 +246,7 @@ function useNestedSizeWarning(isNestedInDialog: boolean, size: DialogSize) { return; } console.warn( - `Mosaic: a Dialog opened inside another Dialog should be size="prompt", but this one is size="${size}". ` + + `[clerk] a Dialog opened inside another Dialog should be size="prompt", but this one is size="${size}". ` + 'Only prompts are meant to open over another dialog; the rest are root-level surfaces.', ); }, [isNestedInDialog, size]);