Composition rules
LayerDialog chooses its dismissal UI automatically:
- Without
Actions: show an X in the title and no footer. - With
Actions: remove the X and show a footer with Close or Cancel and one primary action.
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.
Informational dialog
Title always lives in the bordered body surface. Its padded title rectangle remains visible while the body scrolls, gains a bottom border after scrolling, and has the sole automatic X dismissal action. The content edge mask signals overflow.
import { Button, LayerDialog, Text } from "@cloudflare/kumo";
function LongContent() {
return (
<>
<Text variant="secondary">
Browse available shortcuts without changing a setting.
</Text>
<div className="mt-6 rounded-lg border border-kumo-line p-4 text-kumo-subtle">
Navigation and command shortcuts
</div>
<div className="mt-6">
<Text variant="secondary">
The title frame stays visible, receives a divider once content
scrolls, and the scroll mask indicates more content below.
</Text>
</div>
<div className="h-96" />
</>
);
}
export function LayerDialogInformationalDemo() {
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open keyboard shortcuts</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Keyboard shortcuts</LayerDialog.Title>
<LayerDialog.Body>
<LongContent />
</LayerDialog.Body>
</LayerDialog.Content>
</LayerDialog.Root>
);
}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 variants/sizes, or add extra CTAs.
import { useState } from "react";
import { Button, Input, LayerDialog, Text } from "@cloudflare/kumo";
export function LayerDialogActionDemo() {
const [name, setName] = useState("Production API");
const [hostname, setHostname] = useState("api.example.com");
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open settings</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Configure custom hostname</LayerDialog.Title>
<LayerDialog.Body>
<Text variant="secondary">
Route requests for this hostname to your Worker.
</Text>
<div className="mt-6 flex flex-col gap-5">
<Input
label="Hostname"
onChange={(event) => setHostname(event.target.value)}
value={hostname}
/>
<Input
label="Display name"
onChange={(event) => setName(event.target.value)}
value={name}
/>
</div>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary
disabled={!hostname || !name}
onClick={() => undefined}
>
Save hostname
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Cancellation wording
Use dismissLabel="cancel" only when the workflow has an explicit cancellation outcome. The component continues to own its copy, handler, placement, and styling.
import { useState } from "react";
import { Button, Input, LayerDialog, Text } from "@cloudflare/kumo";
export function LayerDialogCancelDemo() {
const [email, setEmail] = useState("alex@example.com");
const [name, setName] = useState("Alex Morgan");
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Edit profile</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Edit profile</LayerDialog.Title>
<LayerDialog.Body>
<Text variant="secondary">
Update the profile information shown to your teammates. Changes are
not saved until you confirm.
</Text>
<div className="mt-6 flex flex-col gap-5">
<Input
label="Display name"
onChange={(event) => setName(event.target.value)}
value={name}
/>
<Input
label="Email address"
onChange={(event) => setEmail(event.target.value)}
type="email"
value={email}
/>
</div>
</LayerDialog.Body>
<LayerDialog.Actions dismissLabel="cancel">
<LayerDialog.Actions.Primary
disabled={!email || !name}
onClick={() => undefined}
>
Save changes
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Confirmation and destructive actions
LayerDialog.Alert supplies role="alertdialog", an automatic Cancel, no X, a destructive primary action, and blocks backdrop, Escape, and swipe dismissal.
import { useState } from "react";
import { Button, Input, LayerDialog, Text } from "@cloudflare/kumo";
export function LayerDialogAlertDemo() {
const workerName = "example-worker";
const [confirmation, setConfirmation] = useState("");
return (
<LayerDialog.Alert>
<LayerDialog.Trigger
render={(props) => (
<Button variant="secondary-destructive" {...props}>
Delete Worker
</Button>
)}
/>
<LayerDialog.Content>
<LayerDialog.Title>Delete Worker</LayerDialog.Title>
<LayerDialog.Body>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Text variant="secondary">
Deleting{" "}
<strong className="text-kumo-default">{workerName}</strong> is
permanent.
</Text>
<Text variant="secondary">
This deletes the Worker, deployments, and configuration. If this
Worker consumes Queues, those connections are removed first.
Queues, D1 databases, and messages stay in your account.
</Text>
</div>
<Input
label={
<>
Type <strong>{workerName}</strong> to confirm
</>
}
onChange={(event) => setConfirmation(event.target.value)}
placeholder={workerName}
value={confirmation}
/>
</div>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary
disabled={confirmation !== workerName}
onClick={() => undefined}
>
Delete Worker
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Alert>
);
}Pending work
dismissDisabled blocks every dismissal method together while asynchronous work is in flight. The single primary action owns its separate loading or disabled state.
import { useState } from "react";
import { Button, LayerDialog, Text } from "@cloudflare/kumo";
export function LayerDialogPendingDemo() {
const [pending, setPending] = useState(false);
return (
<LayerDialog.Root dismissDisabled={pending}>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Save a setting</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Save a setting</LayerDialog.Title>
<LayerDialog.Body>
<Text variant="secondary">
While saving, Close, Escape, backdrop, and mobile swipe dismissals
are blocked together.
</Text>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary
loading={pending}
onClick={() => {
setPending(true);
window.setTimeout(() => setPending(false), 1500);
}}
>
Save changes
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Cleanup
Put cleanup in onOpenChange, rather than in a dismissal click handler, so it runs for X, Close, Escape, backdrop, and swipe.
import { useState } from "react";
import { Button, LayerDialog, Text } from "@cloudflare/kumo";
export function LayerDialogCleanupDemo() {
const [open, setOpen] = useState(false);
const [cleanupCount, setCleanupCount] = useState(0);
return (
<LayerDialog.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) setCleanupCount((count) => count + 1);
setOpen(nextOpen);
}}
>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open draft</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Draft settings</LayerDialog.Title>
<LayerDialog.Body>
<Text variant="secondary">
Cleanup has run {cleanupCount} time{cleanupCount === 1 ? "" : "s"}.
</Text>
</LayerDialog.Body>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Desktop width
Desktop dialogs use size="base" (576px) by default. Use sm (448px), lg (672px), or xl (768px) when the content needs a different width. Mobile dialogs remain full-width at every size.
import { useState } from "react";
import { Button, Input, LayerDialog, Text, KumoLayerDialogSize } from "@cloudflare/kumo";
export function LayerDialogSizeDemo() {
const [open, setOpen] = useState(false);
const [size, setSize] = useState<KumoLayerDialogSize>("base");
const openAtSize = (nextSize: KumoLayerDialogSize) => {
setSize(nextSize);
setOpen(true);
};
return (
<>
<div className="flex flex-wrap gap-2">
<Button onClick={() => openAtSize("sm")}>Small</Button>
<Button onClick={() => openAtSize("base")}>Default</Button>
<Button onClick={() => openAtSize("lg")}>Large</Button>
<Button onClick={() => openAtSize("xl")}>Extra large</Button>
</div>
<LayerDialog.Root open={open} onOpenChange={setOpen}>
<LayerDialog.Content size={size}>
<LayerDialog.Title>Review deployment configuration</LayerDialog.Title>
<LayerDialog.Body>
<Text variant="secondary">
Confirm the service details and routing configuration before this
deployment is created.
</Text>
<div className="mt-6 grid gap-5 sm:grid-cols-2">
<Input label="Service name" defaultValue="production-api" />
<Input label="Environment" defaultValue="Production" />
<Input label="Hostname" defaultValue="api.example.com" />
<Input label="Compatibility date" defaultValue="2026-09-09" />
</div>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary>
Create deployment
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
</>
);
}Desktop placement
Desktop dialogs center by default. Use the narrow verticalAlign="top" override only when its content benefits from top alignment; mobile remains a bottom sheet.
import { Button, LayerDialog, Text } from "@cloudflare/kumo";
export function LayerDialogTopAlignDemo() {
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open top-aligned dialog</Button>}
/>
<LayerDialog.Content verticalAlign="top">
<LayerDialog.Title>Top-aligned dialog</LayerDialog.Title>
<LayerDialog.Body>
<Text variant="secondary">Mobile dialogs remain bottom sheets.</Text>
</LayerDialog.Body>
</LayerDialog.Content>
</LayerDialog.Root>
);
}