import { useState } from "react";
import { Button, Input, LayerDialog } 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.Description>
Route requests for this hostname to your Worker.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="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>
);
}Installation
Barrel
import { LayerDialog } from "@cloudflare/kumo";Granular
import { LayerDialog } from "@cloudflare/kumo/components/layer-dialog";Usage
import { Button, LayerDialog } from "@cloudflare/kumo";
export default function Example() {
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Open settings</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Configure custom hostname</LayerDialog.Title>
<LayerDialog.Description>
Route requests for this hostname to your Worker.
</LayerDialog.Description>
<LayerDialog.Body>{/* form fields */}</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary onClick={save}>
Save hostname
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}Composition rules
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.
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.
Description renders directly beneath the title inside the sticky title frame and becomes the dialog’s accessible description. Without one, the body copy describes the dialog instead.
Informational dialog
Title and Description always live in the bordered body surface. The title frame 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 (
<div className="flex flex-col gap-5">
<div className="rounded-lg border border-kumo-line p-4 text-kumo-subtle">
Navigation and command shortcuts
</div>
<Text variant="secondary">
The title frame stays visible, receives a divider once content scrolls,
and the scroll mask indicates more content below.
</Text>
<div className="h-96" />
</div>
);
}
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.Description>
Browse available shortcuts without changing a setting.
</LayerDialog.Description>
<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 } 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.Description>
Route requests for this hostname to your Worker.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="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 } 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.Description>
Update the profile information shown to your teammates. Changes are
not saved until you confirm.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="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 follows Base UI’s alert dialog: it supplies role="alertdialog", an automatic Cancel, no X, a destructive primary action, is always modal, and blocks backdrop and swipe dismissal. Escape still cancels, matching the ARIA alert dialog pattern.
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.Description>
Deleting <strong className="text-kumo-default">{workerName}</strong>{" "}
is permanent.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="flex flex-col gap-5">
<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>
<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 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.
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.Description>
While saving, Close, Escape, backdrop, and mobile swipe dismissals are
blocked together.
</LayerDialog.Description>
<LayerDialog.Body>
<Text variant="secondary">
Programmatic closes still work, so a successful save can dismiss the
dialog through `actionsRef` or a controlled `open` prop.
</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, 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.Description>
Confirm the service details and routing configuration before this
deployment is created.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="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>
);
}API Reference
LayerDialog.Root
Controls the open state. Accepts every Base UI Drawer root prop, including
open, defaultOpen, onOpenChange, modal, and actionsRef. Doesn’t
render its own HTML element.
| Prop | Type | Default | Description |
|---|---|---|---|
| dismissDisabled | boolean | false | Blocks X, Close, Escape, backdrop, and swipe dismissal while work is pending. Programmatic closes are never blocked. |
LayerDialog.Alert
Same props as LayerDialog.Root. Forces modal, blocks pointer dismissal,
renders role="alertdialog", and requires LayerDialog.Actions.
LayerDialog.Trigger
A button that opens the dialog when clicked.
| Prop | Type | Default |
|---|
No component-specific props. Accepts standard HTML attributes.
LayerDialog.Content
Portals the backdrop and popup and validates the composition.
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactNode | - | - |
| container | PortalContainer | - | Container element for the portal. Overrides `KumoPortalProvider` context. |
| size | KumoLayerDialogSize | - | Desktop-only width. Mobile dialogs always remain full-width. |
| verticalAlign | KumoLayerDialogVerticalAlign | - | Desktop-only positioning. Mobile dialogs always remain bottom sheets. |
LayerDialog.Title
A heading that labels the dialog for accessibility.
| Prop | Type | Default |
|---|---|---|
| children* | ReactNode | - |
LayerDialog.Description
Optional supporting copy beneath the title. Becomes the dialog’s accessible description.
| Prop | Type | Default |
|---|---|---|
| children* | ReactNode | - |
LayerDialog.Body
The scrollable content region.
| Prop | Type | Default |
|---|---|---|
| children* | ReactNode | - |
LayerDialog.Actions
A footer with an automatic dismiss button and exactly one
LayerDialog.Actions.Primary.
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactElement<LayerDialogPrimaryProps> | - | - |
| dismissLabel | "close" | "cancel" | - | Use cancellation wording for a workflow that has an explicit cancel outcome. |