diff --git a/.changeset/nested-layer-dialogs.md b/.changeset/nested-layer-dialogs.md
new file mode 100644
index 000000000..c0584cc06
--- /dev/null
+++ b/.changeset/nested-layer-dialogs.md
@@ -0,0 +1,5 @@
+---
+"@cloudflare/kumo": patch
+---
+
+Support nested LayerDialog drawers with independently layered backdrops and responsive mobile actions.
diff --git a/packages/kumo-docs-astro/src/components/demos/LayerDialogDemo.tsx b/packages/kumo-docs-astro/src/components/demos/LayerDialogDemo.tsx
index 367c6f5fe..0bdbc4a7d 100644
--- a/packages/kumo-docs-astro/src/components/demos/LayerDialogDemo.tsx
+++ b/packages/kumo-docs-astro/src/components/demos/LayerDialogDemo.tsx
@@ -246,6 +246,61 @@ export function LayerDialogCleanupDemo() {
);
}
+export function LayerDialogNestedDemo() {
+ return (
+
+ }
+ />
+
+ Edit deployment
+
+ Review the deployment settings before saving.
+
+
+
+
+ Opening a second dialog from this body should keep the first
+ dialog beneath it and restore focus when it closes.
+
+
+ (
+
+ )}
+ />
+
+ Discard unsaved changes?
+
+ Your deployment edits will be permanently lost.
+
+
+
+ This nested alert is independently portaled and should
+ dismiss back to the edit dialog.
+
+
+
+
+ Discard changes
+
+
+
+
+
+
+
+
+ Save changes
+
+
+
+
+ );
+}
+
export function LayerDialogTopAlignDemo() {
return (
diff --git a/packages/kumo-docs-astro/src/pages/components/layer-dialog.mdx b/packages/kumo-docs-astro/src/pages/components/layer-dialog.mdx
index 31440e921..d65055b21 100644
--- a/packages/kumo-docs-astro/src/pages/components/layer-dialog.mdx
+++ b/packages/kumo-docs-astro/src/pages/components/layer-dialog.mdx
@@ -16,6 +16,7 @@ import {
LayerDialogCleanupDemo,
LayerDialogInformationalDemo,
LayerDialogMaxHeightDemo,
+ LayerDialogNestedDemo,
LayerDialogPendingDemo,
LayerDialogSizeDemo,
LayerDialogTopAlignDemo,
@@ -88,7 +89,7 @@ export default function Example() {
`LayerDialog.Content` accepts exactly one `Title`, one `Body`, an optional `Description`, and an optional `Actions`. It chooses its dismissal UI automatically:
- Without `Actions`: show an X in the title frame and no footer.
-- With `Actions`: remove the X and show a footer with **Close** or **Cancel** and one primary action.
+- With `Actions`: remove the X and show **Close** or **Cancel** plus one primary action. On mobile, they remain visible beneath the scrolling body behind a hairline divider; on desktop, they remain in the fixed footer.
Consumers cannot mix these layouts or add more primary actions. Use the X for read-only content, `Actions` when the user must commit a change, and `LayerDialog.Alert` for destructive or critical confirmations.
@@ -103,13 +104,13 @@ Consumers cannot mix these layouts or add more primary actions. Use the X for re
## Standard action
-Adding `Actions` selects a fixed footer with exactly one Kumo primary action and an automatic **Close** button. Consumers cannot add custom footer controls, change button sizes, or add extra CTAs. The primary action accepts `variant="primary"` (default) or `variant="destructive"`.
+Adding `Actions` selects exactly one Kumo primary action and an automatic **Close** button. On mobile, the actions stay visible beneath the scrolling body behind a hairline divider; on desktop, they occupy the fixed footer. Consumers cannot add custom controls, change button sizes, or add extra CTAs. The primary action accepts `variant="primary"` (default) or `variant="destructive"`.
## Cancellation wording
-When `LayerDialog.Actions` is present, LayerDialog renders a secondary button that dismisses the dialog. Its default label is "Close", or "Cancel" for `LayerDialog.Alert`.
+When `LayerDialog.Actions` is present, LayerDialog renders a dismiss button. It uses the secondary button treatment on mobile and the ghost treatment in the desktop footer. Its default label is "Close", or "Cancel" for `LayerDialog.Alert`.
Use `dismissLabel` only when this dialog needs more specific wording, such as "Keep editing" or "Discard changes". The label does not change the button's behavior: it always closes the dialog.
@@ -123,6 +124,12 @@ Pass `variant="destructive"` to the primary action when the confirmation is irre
+
+## Nested dialogs
+Nest one `LayerDialog.Root` or `LayerDialog.Alert` inside the parent dialog's body when the user must confirm a related action. Each dialog portals independently; Base UI keeps the nested drawer active above its parent and returns focus to the parent trigger when it closes. The nested drawer renders its own backdrop over the parent, so the parent remains intact but clearly inactive. No z-index overrides are needed.
+
+
+
## Pending work
`dismissDisabled` blocks every user-initiated dismissal together while asynchronous work is in flight. Programmatic closes, through `actionsRef.current.close()` or a controlled `open` prop, still work so a successful action can dismiss the dialog. The single primary action owns its separate loading or disabled state.
diff --git a/packages/kumo/src/components/layer-dialog/layer-dialog.test.tsx b/packages/kumo/src/components/layer-dialog/layer-dialog.test.tsx
index 6cc511c2e..6230d1009 100644
--- a/packages/kumo/src/components/layer-dialog/layer-dialog.test.tsx
+++ b/packages/kumo/src/components/layer-dialog/layer-dialog.test.tsx
@@ -312,7 +312,10 @@ describe("LayerDialog dismissal", () => {
);
});
- it("does not turn a nested Root into an alert", () => {
+ it("keeps nested dialogs distinct and renders a backdrop for each layer", () => {
+ const initialBackdropCount = document.querySelectorAll(
+ "[data-layer-dialog-backdrop]",
+ ).length;
const { getAllByRole, getByRole } = render(
@@ -335,6 +338,9 @@ describe("LayerDialog dismissal", () => {
expect(getAllByRole("alertdialog", { hidden: true })).toHaveLength(1);
expect(getByRole("dialog", { hidden: true })).toBeDefined();
expect(getByRole("button", { hidden: true, name: "Close" })).toBeDefined();
+ expect(
+ document.querySelectorAll("[data-layer-dialog-backdrop]"),
+ ).toHaveLength(initialBackdropCount + 2);
});
it("describes the popup with its Description slot when present", () => {
diff --git a/packages/kumo/src/components/layer-dialog/layer-dialog.tsx b/packages/kumo/src/components/layer-dialog/layer-dialog.tsx
index 40ba5c528..1d47d8d6c 100644
--- a/packages/kumo/src/components/layer-dialog/layer-dialog.tsx
+++ b/packages/kumo/src/components/layer-dialog/layer-dialog.tsx
@@ -77,6 +77,7 @@ const AlertContext = createContext(false);
interface BodySlots {
title: ReactNode;
description: ReactNode;
+ actions: ReactNode;
showCloseButton: boolean;
closeLabel: string;
}
@@ -84,6 +85,7 @@ interface BodySlots {
const BodySlotsContext = createContext({
title: null,
description: null,
+ actions: null,
showCloseButton: false,
closeLabel: "",
});
@@ -272,13 +274,18 @@ function LayerDialogContent({
const bodySlots: BodySlots = {
title: title.element,
description: description.element ?? null,
+ actions: actions.element ?? null,
showCloseButton: actions.count === 0,
closeLabel: closeLabel ?? layerDialog.close,
};
return (
-
+
{/*
Desktop sizing contract: the viewport owns the vertical breathing room
(via the verticalAlign variant) and the popup fills it with
@@ -311,7 +318,7 @@ function LayerDialogContent({
{body.element}
- {actions.element}
+ {isDesktop && actions.element}
@@ -376,8 +383,9 @@ function LayerDialogBody({ children }: LayerDialogBodyProps) {
const [condensed, setCondensed] = useState(false);
const descriptionClipRef = useRef(null);
const dismissDisabled = useContext(DismissDisabledContext);
- const { title, description, showCloseButton, closeLabel } =
+ const { title, description, actions, showCloseButton, closeLabel } =
useContext(BodySlotsContext);
+ const isDesktop = useContext(DesktopContext);
const handleScroll = (event: UIEvent) => {
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
@@ -410,7 +418,7 @@ function LayerDialogBody({ children }: LayerDialogBodyProps) {
return (
-